From c6a3e24599a7cfb49d6a4269de028116162b4acd Mon Sep 17 00:00:00 2001 From: HuanLinOTO Date: Wed, 26 Aug 2026 18:33:52 +0800 Subject: [PATCH] feat: reconcile exempts pinned agent terminals M3 of pinned terminals (v0.17.0): - reconcileAgentTerminals skips removal of agent tabs with pin !== undefined (the agent closed them / pty exited, but the user pinned them) - The xterm reconnect-failure banner is the user-visible 'disconnected' signal (design M3 convergence: no title suffix, no meta write) - A later push reviving the same uuid is a no-op (id already exists) - Unpinned agent tabs still remove as before; toAdd path unaffected --- src/client/state.ts | 8 +++- tests/agent-terminal-reconcile.spec.ts | 59 +++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/client/state.ts b/src/client/state.ts index 1c84ded..5b610b6 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -1002,7 +1002,13 @@ export function reconcileAgentTerminals( const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id))) const serverUuids = new Set(agentTerminals.map(t => t.uuid)) const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid)) - const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id))) + // Pinned agent terminals (v0.17.0+) are EXEMPT from removal: the agent + // closed them or the pty exited, but the user pinned them so the tab + // stays as a disconnected surface. The xterm view's reconnect-failure + // banner is the user-visible "disconnected" signal (the design's M3 + // convergence: no title suffix, no meta write — the tab keeps its uuid + // so a later reconcile push revives it if the agent reopens the same one). + const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined) if (toAdd.length === 0 && toRemove.length === 0) return state // Remove tabs whose uuids vanished from the server list (the agent closed // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup; diff --git a/tests/agent-terminal-reconcile.spec.ts b/tests/agent-terminal-reconcile.spec.ts index 8f9cda7..e9fdf65 100644 --- a/tests/agent-terminal-reconcile.spec.ts +++ b/tests/agent-terminal-reconcile.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { allLeaves, makeDefaultState, openTabInActivePane, reconcileAgentTerminals, - tabOpenIn, toggleBottomPanel, + setTabPin, tabOpenIn, toggleBottomPanel, } from '../src/client/state.ts' describe('agent terminal reconciliation', () => { @@ -69,4 +69,61 @@ describe('agent terminal reconciliation', () => { expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true) expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false) }) + + describe('pinned agent terminal exemption (v0.17.0)', () => { + it('keeps a pinned agent tab when its uuid vanishes from the server list', () => { + let s = makeDefaultState(400, true) + s = reconcileAgentTerminals(s, [ + { uuid: 'aaa-111', title: 'keep' }, + { uuid: 'bbb-222', title: 'pinned' }, + ]) + // Pin bbb-222, then drop it from the server list. + s = setTabPin(s, 'agent:bbb-222', { scope: 'global' }) + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'keep' }]) + // aaa-111 (unpinned) survived; bbb-222 (pinned) survived too. + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) + expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true) + // The pin marker is intact. + const tabs = allLeaves(s.splits).flatMap(l => l.tabs) + const pinned = tabs.find(t => t.id === 'agent:bbb-222')! + expect(pinned.pin).toEqual({ scope: 'global' }) + }) + + it('still removes an unpinned agent tab when the uuid vanishes', () => { + let s = makeDefaultState(400, true) + s = reconcileAgentTerminals(s, [ + { uuid: 'aaa-111', title: 'pinned' }, + { uuid: 'bbb-222', title: 'unpinned' }, + ]) + s = setTabPin(s, 'agent:aaa-111', { scope: 'workspace', homeCwd: '/p' }) + s = reconcileAgentTerminals(s, []) // both vanish + // Pinned stays, unpinned goes. + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) + expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false) + }) + + it('a pinned tab whose uuid RETURNS in a later push is a no-op (already exists)', () => { + let s = makeDefaultState(400, true) + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'gone' }]) + s = setTabPin(s, 'agent:aaa-111', { scope: 'global' }) + // First push without it: exempted, stays. + s = reconcileAgentTerminals(s, []) + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) + // Later push brings it back: reconcile sees the uuid already exists, + // does not re-add, does not touch the pin. + const before = s + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'gone' }]) + expect(s).toBe(before) + }) + + it('exemption does not block the toAdd path (new uuids still land)', () => { + let s = makeDefaultState(400, true) + s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'first' }]) + s = setTabPin(s, 'agent:aaa-111', { scope: 'global' }) + // aaa-111 vanishes (exempted), bbb-222 is brand-new. + s = reconcileAgentTerminals(s, [{ uuid: 'bbb-222', title: 'second' }]) + expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true) // pinned, kept + expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true) // new, added + }) + }) })