diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 79653baaad..e7f55b12d6 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -182,7 +182,9 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi } else if (!next.busy && wasWorking) { markSettled(storedId) - if (storedId !== $selectedStoredSessionId.get()) { + // FOCUSED, not selected: a session finishing in the tile the user is + // watching is already seen, and a tile is never the primary selection. + if (storedId !== $focusedStoredSessionId.get()) { const cur = $unreadFinishedSessionIds.get() if (!cur.includes(storedId)) { @@ -912,6 +914,18 @@ export const $focusedSessionState = computed([$focusedRuntimeId, $sessionStates] export const selectionHomesToWorkspace = (selected: null | string, tiles: readonly SessionTile[]): boolean => !(selected && tiles.some(t => t.storedSessionId === selected)) +// Bringing a finished session to the front clears its green dot. Keyed on the +// FOCUSED session, not the selected one: a tile is never $selectedStoredSessionId, +// and a tile tab click goes through activateTreePane rather than focusOpenSession, +// so this is the one hook that catches every way a tile reaches the front. +$focusedStoredSessionId.listen(focused => { + const cur = $unreadFinishedSessionIds.get() + + if (focused && cur.includes(focused)) { + $unreadFinishedSessionIds.set(cur.filter(id => id !== focused)) + } +}) + // Cold-start restore is the one selection change that is NOT a navigation: the // route already pointed at the primary session before the window loaded, and // homing on it would front the workspace tab over the PERSISTED active tab — diff --git a/apps/desktop/src/store/session-unread-tile.test.ts b/apps/desktop/src/store/session-unread-tile.test.ts new file mode 100644 index 0000000000..c93480dced --- /dev/null +++ b/apps/desktop/src/store/session-unread-tile.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// The completed-unread dot is keyed on the FOCUSED session, not the selected +// one. A tile is never $selectedStoredSessionId, so keying either half on the +// selection left a tiled session's dot green with no way to clear it. + +describe('completed-unread dot follows the focused session', () => { + beforeEach(() => { + vi.resetModules() + }) + + afterEach(() => { + vi.resetModules() + }) + + async function setup() { + const tree = await import('@/components/pane-shell/tree/store') + const model = await import('@/components/pane-shell/tree/model') + const { registry } = await import('@/contrib/registry') + const { createClientSessionState } = await import('@/lib/chat-runtime') + const session = await import('./session') + const states = await import('./session-states') + + for (const id of ['workspace', 'session-tile:tiled']) { + registry.register({ + area: 'panes', + data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' }, + id, + render: () => null, + title: id + }) + } + + // The workspace holds the primary chat, a second zone holds the tile. + tree.declareDefaultTree( + model.split('row', [ + model.group(['workspace'], { active: 'workspace', id: 'grp-main' }), + model.group(['session-tile:tiled'], { active: 'session-tile:tiled', id: 'grp-tile' }) + ]) + ) + + session.$unreadFinishedSessionIds.set([]) + session.$selectedStoredSessionId.set('primary') + + const finishTurn = (storedSessionId: string) => { + const working = { ...createClientSessionState(null), busy: true, storedSessionId } + states.publishSessionState(`rt-${storedSessionId}`, working) + states.publishSessionState(`rt-${storedSessionId}`, { ...working, busy: false }) + } + + return { finishTurn, session, tree } + } + + it('clears the dot when an already-open tile is fronted', async () => { + const { finishTurn, session, tree } = await setup() + + tree.noteActiveTreeGroup('grp-main') + finishTurn('tiled') + expect(session.$unreadFinishedSessionIds.get()).toEqual(['tiled']) + + // Fronting the tile is what a tab click does. Before the fix nothing on + // this path cleared the marker, so the dot stayed green. + tree.noteActiveTreeGroup('grp-tile') + expect(session.$unreadFinishedSessionIds.get()).toEqual([]) + }) + + it('never marks a tile that finishes while it is the focused one', async () => { + const { finishTurn, session, tree } = await setup() + + tree.noteActiveTreeGroup('grp-tile') + finishTurn('tiled') + + expect(session.$unreadFinishedSessionIds.get()).toEqual([]) + }) + + it('marks the primary session when a tile has focus', async () => { + const { finishTurn, session, tree } = await setup() + + tree.noteActiveTreeGroup('grp-tile') + finishTurn('primary') + + expect(session.$unreadFinishedSessionIds.get()).toEqual(['primary']) + }) +})