From d5bceab2a2abc0dfd6cceae178da9a10cd9d6533 Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:44:20 +0800 Subject: [PATCH 1/2] fix(ui-web): draw the rail after a re-read replaces its rows Replacing the rows is not showing them. Every other writer in the rail knows this -- leave.ts and the session registry both draw after their replace -- but the plain re-read did not, and the archive page's restore reaches the rail only through it. So a restored conversation came back in the config, on disk and in what the server lists, and stayed off the screen until the page was reloaded: the same shape as the archive report this fixes the other half of. At boot the rail is still held, where a draw sets the skeleton and releaseRail paints the rows, so the other caller is unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- ui-web/src/features/rail/source.test.ts | 19 ++++++++++++++++++- ui-web/src/features/rail/source.ts | 7 +++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ui-web/src/features/rail/source.test.ts b/ui-web/src/features/rail/source.test.ts index 681ebba64..19d79cc05 100644 --- a/ui-web/src/features/rail/source.test.ts +++ b/ui-web/src/features/rail/source.test.ts @@ -238,10 +238,11 @@ async function refreshHarness({ }, }) const registry = await import('../../state/session/registry') + const rail = await import('./source') await fakeGateway(async () => answer) const { setSources } = await import('../../state/sources') setSources({ composer: {}, rail: {}, transcript: {} } as unknown as Partial) - return { registry, log } + return { registry, rail, log } } describe('re-reading the session list', () => { @@ -275,6 +276,22 @@ describe('re-reading the session list', () => { expect(h.log.filter((c) => c[0] === 'sessionDraw')).toEqual([]) }) + it('a plain re-read draws the rows it just replaced', async () => { + /* Replacing the rows is not showing them. The archive page's restore + reaches the rail only through this function, so without the draw the + restored conversation came back on the server and on disk and stayed off + the screen until the page was reloaded. */ + const h = await refreshHarness({ + rows: [{ id: 'a' }], + answer: { sessions: [{ id: 'a', started_at: 1 }, { id: 'b', started_at: 2 }] }, + }) + + await h.rail.loadSessions() + + expect(h.log).toContainEqual(['sessionReplace', ['b', 'a']]) + expect(h.log).toContainEqual(['sessionDraw']) + }) + it('keeps the stale list when the read fails', async () => { const h = await refreshHarness({ rows: [{ id: 'a' }] }) const { setGateway } = await import('../../rpc/gateway') diff --git a/ui-web/src/features/rail/source.ts b/ui-web/src/features/rail/source.ts index 0f15884e2..61dfe06d1 100644 --- a/ui-web/src/features/rail/source.ts +++ b/ui-web/src/features/rail/source.ts @@ -124,6 +124,13 @@ export async function loadSessions(): Promise { await loadCronNames() const r = await gateway().call('session.list', { channels: SESS_CHANNELS }) sessionReplace((r.sessions || []).map(rowFrom).sort((a, b) => (b.at || 0) - (a.at || 0))) + /* Replacing the rows is not showing them, which every other writer here + already knew: leave.ts and the registry both draw after their replace. A + restore from the archive page reaches the rail only through this function, + so without the draw the row came back on the server and on disk and stayed + off the screen until the page was reloaded. At boot the rail is still held, + where a draw is the skeleton and `releaseRail` paints the rows. */ + sessionDraw() } /* ── the two writes a row makes for itself ───────────────────────────────── */ From f4a09c1c1c3bb0232d7053724f4a0087009e63bb Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:39:56 +0800 Subject: [PATCH 2/2] fix(ui-web): reconcile the re-read list before the rail draws it The draw added in the commit before this one made a older defect visible. `loadSessions` replaced the rows with the raw answer, while its sibling `refreshList` puts the same answer through `reconcileRows` first -- which is what carries across the two things `session.list` cannot know: which conversation has a turn running, and the current conversation while it is too new to be listed. Dropping those was invisible as long as nothing repainted. With the repaint it is a running badge wiped, and the row for the conversation the reader is in removed, every time they restore something from the archive page. Through the reconcile now. `currentMissing` stays `refreshList`'s business: that answer means the open conversation was deleted under the reader, and leaving it is the transition that owns moving them out. Found by an adversarial review of the previous commit, reproduced against the real store before and after. Co-Authored-By: Claude Opus 5 (1M context) --- ui-web/src/features/rail/source.test.ts | 33 ++++++++++++++++++++----- ui-web/src/features/rail/source.ts | 21 ++++++++++------ 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/ui-web/src/features/rail/source.test.ts b/ui-web/src/features/rail/source.test.ts index 19d79cc05..7069389c2 100644 --- a/ui-web/src/features/rail/source.test.ts +++ b/ui-web/src/features/rail/source.test.ts @@ -204,6 +204,7 @@ async function refreshHarness({ reconciled = null as { rows: Row[]; currentMissing: boolean } | null, }) { const log: unknown[][] = [] + const replaced: Row[][] = [] await loadPart(() => import('../../state/session/registry'), { fakes: { 'src/features/rail/store': { @@ -218,7 +219,7 @@ async function refreshHarness({ }, 'src/state/session/rows': { sess: (id: string) => rows.find((r) => r.id === id), - replace: (next: Row[]) => log.push(['sessionReplace', next.map((r) => r.id)]), + replace: (next: Row[]) => { replaced.push(next); log.push(['sessionReplace', next.map((r) => r.id)]) }, rows: () => rows, }, 'src/i18n/t': { @@ -242,7 +243,7 @@ async function refreshHarness({ await fakeGateway(async () => answer) const { setSources } = await import('../../state/sources') setSources({ composer: {}, rail: {}, transcript: {} } as unknown as Partial) - return { registry, rail, log } + return { registry, rail, log, replaced } } describe('re-reading the session list', () => { @@ -276,11 +277,12 @@ describe('re-reading the session list', () => { expect(h.log.filter((c) => c[0] === 'sessionDraw')).toEqual([]) }) - it('a plain re-read draws the rows it just replaced', async () => { + it('a plain re-read draws the rows it just replaced, in that order', async () => { /* Replacing the rows is not showing them. The archive page's restore reaches the rail only through this function, so without the draw the restored conversation came back on the server and on disk and stayed off - the screen until the page was reloaded. */ + the screen until the page was reloaded. Asserted as a sequence: a draw + before the replace paints the list the reader already had. */ const h = await refreshHarness({ rows: [{ id: 'a' }], answer: { sessions: [{ id: 'a', started_at: 1 }, { id: 'b', started_at: 2 }] }, @@ -288,8 +290,27 @@ describe('re-reading the session list', () => { await h.rail.loadSessions() - expect(h.log).toContainEqual(['sessionReplace', ['b', 'a']]) - expect(h.log).toContainEqual(['sessionDraw']) + expect(h.log.filter((c) => c[0] !== 'reconcile')) + .toEqual([['sessionReplace', ['b', 'a']], ['sessionDraw']]) + }) + + it('a plain re-read hands the list to the reconcile and replaces with what it gets', async () => { + /* The answer does not carry which conversation has a turn running, nor the + current one while it is too new to be listed; the reconcile is what + carries both across. Replacing with the raw list dropped them, and the + draw above is what makes that visible -- a running badge wiped by + restoring something from the archive page. */ + const h = await refreshHarness({ + rows: [{ id: 'a' }], + cur: 'a', + answer: { sessions: [{ id: 'b', started_at: 2 }] }, + reconciled: { rows: [{ id: 'a' }, { id: 'b' }], currentMissing: false }, + }) + + await h.rail.loadSessions() + + expect(h.log).toContainEqual(['reconcile', ['a'], ['b'], 'a']) + expect(h.replaced[0]!.map((r) => r.id)).toEqual(['a', 'b']) }) it('keeps the stale list when the read fails', async () => { diff --git a/ui-web/src/features/rail/source.ts b/ui-web/src/features/rail/source.ts index 61dfe06d1..84db67f72 100644 --- a/ui-web/src/features/rail/source.ts +++ b/ui-web/src/features/rail/source.ts @@ -18,7 +18,7 @@ import { switchTo } from '../../state/session/registry' import { replace as sessionReplace, rows as sessionRows, sess } from '../../state/session/rows' import { show as toast } from '../../state/toast' import { busy } from '../composer/turn' -import { draw as sessionDraw } from './store' +import { draw as sessionDraw, reconcileRows } from './store' import { plainTitle } from './title' import type { ResultOf } from '../../rpc/generated' @@ -123,13 +123,18 @@ export const SESS_CHANNELS = ['tui', 'cron'] export async function loadSessions(): Promise { await loadCronNames() const r = await gateway().call('session.list', { channels: SESS_CHANNELS }) - sessionReplace((r.sessions || []).map(rowFrom).sort((a, b) => (b.at || 0) - (a.at || 0))) - /* Replacing the rows is not showing them, which every other writer here - already knew: leave.ts and the registry both draw after their replace. A - restore from the archive page reaches the rail only through this function, - so without the draw the row came back on the server and on disk and stayed - off the screen until the page was reloaded. At boot the rail is still held, - where a draw is the skeleton and `releaseRail` paints the rows. */ + const listed = (r.sessions || []).map(rowFrom).sort((a, b) => (b.at || 0) - (a.at || 0)) + /* Through the reconcile, because the answer does not carry what only this + page knows: which conversation has a turn running, and the current one + while it is too new to be listed. Replacing without it dropped both, which + was invisible while nothing redrew and is a wiped running badge now that + something does. `currentMissing` is left to `refreshList`, whose job the + conversation having been deleted under the reader is. */ + sessionReplace(reconcileRows(sessionRows(), listed, sessionCurrent()).rows) + /* And replacing the rows is not showing them: wire.ts and the registry both + draw after theirs, and the archive page's restore reaches the rail only + through here. At boot the rail is held, so this draw is inert until + `releaseRail` paints. */ sessionDraw() }