From f2848181e9c775626658fc9a7cb6e24bd5c933ca Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:11:37 -0700 Subject: [PATCH 01/19] fix(sidebar): a slept chat workspace reads asleep and hides --- ...-project-session-field-disposition.test.ts | 2 + ...ofile-project-session-field-disposition.ts | 4 + .../profile-project-session-state.test.ts | 56 ++++++++ .../profile-project-session-state.ts | 8 ++ .../orcad-source-release-upgrade.test.ts | 4 + .../orcad-source-workspace-session.ts | 2 + .../session-owner-fields.test.ts | 13 +- .../session-owner-fields.ts | 26 ++-- .../session-worktree-ownership.ts | 2 + ...-identity-migration-field-coverage.test.ts | 15 ++ .../worktree-identity-migration.ts | 3 + ...tructuredAgentSessionStatusBridge.test.tsx | 27 +++- .../StructuredAgentSessionStatusBridge.tsx | 18 +++ .../sidebar/sleep-worktree-flow.test.ts | 70 +++++++++- .../components/sidebar/sleep-worktree-flow.ts | 54 +++++++- ...e-visible-workspace-kanban-worktree-ids.ts | 6 +- .../use-worktree-context-menu-model.tsx | 41 +++--- .../sidebar/use-worktree-sleep-state.test.tsx | 33 +++++ .../sidebar/use-worktree-sleep-state.ts | 7 +- .../visible-worktree-activity-inputs.ts | 40 ++++-- .../visible-worktree-options-from-state.ts | 5 +- .../workspace-lineage-menu-actions.test.ts | 31 +++-- .../sidebar/workspace-lineage-menu-actions.ts | 21 ++- .../sidebar/worktree-context-menu-policy.ts | 1 + .../worktree-list/listing/use-filters.ts | 5 +- .../listing/use-visible-worktrees.ts | 6 +- .../use-worktree-jump-palette-store-state.ts | 4 + .../use-worktree-jump-palette-worktrees.ts | 16 +-- .../lib/workspace-session-host-split.test.ts | 3 + .../src/lib/workspace-session-patch.ts | 3 + .../workspace-session-relevant-fields.test.ts | 1 + src/renderer/src/lib/workspace-session.ts | 13 +- .../src/lib/worktree-activity-state.ts | 18 ++- .../apply-final-patch.ts | 10 ++ .../runtime/web-session-tabs-sync/state.ts | 1 + .../store/slices/agent-status-live-facts.ts | 2 + .../purge-stale-runtime-host-state.test.ts | 16 +++ .../store/slices/tabs/tabs-close-actions.ts | 23 +++ .../store/slices/tabs/tabs-session-actions.ts | 53 ++++--- .../src/store/slices/worktree-helpers.ts | 15 ++ ...orktrees-workspace-selection-state.test.ts | 39 ++++++ src/renderer/src/store/slices/worktrees.ts | 2 + .../worktrees/session/set-active-worktree.ts | 11 +- .../worktrees/session/workspace-chat-sleep.ts | 81 +++++++++++ .../session/worktree-identity-rename-state.ts | 10 ++ .../session/worktree-slice-initial-state.ts | 2 + .../purge-stale-runtime-host-state.ts | 18 ++- .../remove-worktree-map-identity.test.ts | 11 +- .../teardown/remove-worktree-store-cleanup.ts | 5 + .../teardown/worktree-purge-state.ts | 9 ++ .../workspace-terminal-hydration-patch.ts | 3 + .../terminals/workspace-terminal-hydration.ts | 2 + src/shared/tab-types.ts | 1 + src/shared/workspace-chat-sleep-schema.ts | 14 ++ src/shared/workspace-chat-sleep.test.ts | 31 +++++ src/shared/workspace-chat-sleep.ts | 131 ++++++++++++++++++ .../workspace-layout-beside.ts | 1 + .../workspace-layout-fact-locations.test.ts | 3 + .../workspace-layout-round-trip.test.ts | 12 ++ .../workspace-session-host-field-ownership.ts | 4 +- ...pace-session-schema-field-coverage.test.ts | 15 ++ src/shared/workspace-session-schema.ts | 2 + src/shared/workspace-session-state-types.ts | 3 + src/shared/wsl-move/wsl-move-dispositions.ts | 4 + 64 files changed, 980 insertions(+), 112 deletions(-) create mode 100644 src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep.ts create mode 100644 src/shared/workspace-chat-sleep-schema.ts create mode 100644 src/shared/workspace-chat-sleep.test.ts create mode 100644 src/shared/workspace-chat-sleep.ts diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts index ce509767119..0fc4b40901c 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts @@ -261,6 +261,7 @@ describe('workspace session field disposition census', () => { 'activeGroupIdByWorktree', 'lastVisitedAtByWorktreeId', 'defaultTerminalTabsAppliedByWorktreeId', + 'explicitlySleptChatsByWorkspace', 'terminalTopologyRevisionByRepoId' ]) }) @@ -275,6 +276,7 @@ describe('workspace session field disposition census', () => { 'activeGroupIdByWorktree', 'lastVisitedAtByWorktreeId', 'defaultTerminalTabsAppliedByWorktreeId', + 'explicitlySleptChatsByWorkspace', 'terminalTopologyRevisionByRepoId' ]) }) diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 219ef63d8ca..933c3d5e0b9 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -117,6 +117,10 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { // the record's worktreeId on worktree and project removal. Moving a project between profiles runs // removeSourceRepo, which has no owner scan, so these records leak there. sleepingAgentSessionsByPaneKey: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, + explicitlySleptChatsByWorkspace: { + onRepoRemoval: 'prunedByOwnerKey', + onTransfer: 'copiedByOwnerKey' + }, terminalPtyIncarnationsByPaneKey: { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' diff --git a/src/main/orca-profiles/profile-project-session-state.test.ts b/src/main/orca-profiles/profile-project-session-state.test.ts index 1d9a64870c3..2ba58bec114 100644 --- a/src/main/orca-profiles/profile-project-session-state.test.ts +++ b/src/main/orca-profiles/profile-project-session-state.test.ts @@ -6,6 +6,7 @@ import { removeRepoFromWorkspaceSession } from './profile-project-session-state' import { extractSessionForTransfer } from './profile-project-session-transfer' +import { isChatExplicitlySlept, sleptChatWorkspaceKey } from '../../shared/workspace-chat-sleep' const REMOVED_WORKTREE_ID = 'repo-a::/removed' const RETAINED_WORKTREE_ID = 'repo-b::/retained' @@ -13,6 +14,61 @@ const REMOVED_REPO_ID = 'repo-a' const RETAINED_REPO_ID = 'repo-b' describe('profile project session state', () => { + it('keeps exact chat Sleep choices when a moved project needs a new repo id', () => { + const movedWorktreeId = 'repo-c::/removed' + const retainedKey = sleptChatWorkspaceKey(RETAINED_WORKTREE_ID, 'local') + const localKey = sleptChatWorkspaceKey(movedWorktreeId, 'local') + const remoteKey = sleptChatWorkspaceKey(movedWorktreeId, 'ssh:profile-test') + const tabs = (['local', 'ssh:profile-test'] as const).map((executionHostId, sortOrder) => ({ + id: `chat-${sortOrder}`, + entityId: `session-${sortOrder}`, + groupId: 'group', + worktreeId: REMOVED_WORKTREE_ID, + executionHostId, + contentType: 'agent-session' as const, + label: 'Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: 1 + })) + const source: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + unifiedTabs: { [REMOVED_WORKTREE_ID]: tabs }, + explicitlySleptChatsByWorkspace: Object.fromEntries( + tabs.map((tab) => [ + sleptChatWorkspaceKey(REMOVED_WORKTREE_ID, tab.executionHostId), + [{ tabId: tab.id, sessionId: tab.entityId }] + ]) + ) + } + const retained = [{ tabId: 'retained-chat', sessionId: 'retained-session' }] + const target = { + ...getDefaultWorkspaceSession(), + explicitlySleptChatsByWorkspace: { + [retainedKey]: retained, + [localKey]: [{ tabId: 'old-chat', sessionId: 'old-session' }] + } + } + const result = mergeWorkspaceSessions( + target, + extractSessionForTransfer(source, REMOVED_REPO_ID, 'repo-c') + ) + + expect(result.explicitlySleptChatsByWorkspace).toEqual({ + [retainedKey]: retained, + [localKey]: [{ tabId: 'chat-0', sessionId: 'session-0' }], + [remoteKey]: [{ tabId: 'chat-1', sessionId: 'session-1' }] + }) + const movedTabs = result.unifiedTabs?.[movedWorktreeId] ?? [] + expect(movedTabs).toHaveLength(2) + expect( + movedTabs.every((tab) => + isChatExplicitlySlept(result.explicitlySleptChatsByWorkspace ?? {}, tab) + ) + ).toBe(true) + }) + it('keeps topology revisions monotonic while merging session authority records', () => { const base = { ...getDefaultWorkspaceSession(), diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index b1ccc6c488f..744ab8e194c 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -123,6 +123,14 @@ export function mergeWorkspaceSessions( } } : {}), + ...(base.explicitlySleptChatsByWorkspace || incoming.explicitlySleptChatsByWorkspace + ? { + explicitlySleptChatsByWorkspace: { + ...base.explicitlySleptChatsByWorkspace, + ...incoming.explicitlySleptChatsByWorkspace + } + } + : {}), activeWorktreeIdsOnShutdown: [ ...(base.activeWorktreeIdsOnShutdown ?? []), ...(incoming.activeWorktreeIdsOnShutdown ?? []) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts index 1b1d7a39b87..039ea5654a3 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts @@ -235,9 +235,13 @@ describe('migrating saved state from released SSH clients', () => { const { state, local, host } = split() host.lastVisitedAtByWorktreeId = { [WORKTREE]: 5 } local.tabsByWorktree = { [WORKTREE]: [terminal('local')] } + local.explicitlySleptChatsByWorkspace = { + [`${HOST}|${WORKTREE}`]: [{ tabId: 'chat', sessionId: 'session' }] + } expect(census(state).totalCount).toBe(0) const session = exportState(state).payload.dormantState?.workspaceSession expect(session?.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['local']) + expect(session).not.toHaveProperty('explicitlySleptChatsByWorkspace') }) it('exports local tabs and drafts beside a host-only closed-pane resume record', () => { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts index 4934dccb66a..c71d0c17dd0 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -164,6 +164,8 @@ function collectWorkspaceSession( } : null }) + // Sleep intent belongs to the client that recorded it. + delete fragment.explicitlySleptChatsByWorkspace if (!sourceHostPartition) { // Client focus is not host state, so the destination never carries it. fragment.activeWorktreeId = null diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.test.ts b/src/main/persistence/restoring-sessions/session-owner-fields.test.ts index 5ff4e2692cf..a04615fa4e9 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.test.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.test.ts @@ -63,9 +63,10 @@ describe('deleting a workspace owner session fields', () => { ]) }) - // Why not in the list above: this map also holds host-qualified keys, so it is scanned rather + // Why not in the list above: these maps also hold host-qualified keys, so they are scanned rather // than indexed by the owner key, and the scan is the only thing that reaches a remote row. - it('deletes the recency entry under a bare and a host-qualified key alike', () => { + it('deletes recency and explicit chat Sleep entries under bare and host-qualified keys alike', () => { + const slept = [{ tabId: 'chat-1', sessionId: 'session-1' }] const session: WorkspaceSessionState = { ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { @@ -73,6 +74,11 @@ describe('deleting a workspace owner session fields', () => { [`ssh:user@host|${REMOVED_OWNER_KEY}`]: 2, [RETAINED_OWNER_KEY]: 3, [`ssh:user@host|${RETAINED_OWNER_KEY}`]: 4 + }, + explicitlySleptChatsByWorkspace: { + [`local|${REMOVED_OWNER_KEY}`]: slept, + [`ssh:user@host|${REMOVED_OWNER_KEY}`]: slept, + [`local|${RETAINED_OWNER_KEY}`]: slept } } @@ -82,5 +88,8 @@ describe('deleting a workspace owner session fields', () => { [RETAINED_OWNER_KEY]: 3, [`ssh:user@host|${RETAINED_OWNER_KEY}`]: 4 }) + expect(session.explicitlySleptChatsByWorkspace).toEqual({ + [`local|${RETAINED_OWNER_KEY}`]: slept + }) }) }) diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index 88920f660f3..c483cab536b 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -7,16 +7,24 @@ import { } from '../../../shared/worktree/host-qualified-identity' import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' +// Keyed by `${executionHostId}|${worktreeId}` as well as the bare owner key. +const HOST_QUALIFIED_SESSION_FIELDS = [ + 'lastVisitedAtByWorktreeId', + 'explicitlySleptChatsByWorkspace' +] as const + /** * The census fields this path deletes by indexing the owner key. * - * Two classified fields are handled separately below: the topology revision is keyed by repo - * rather than by owner, so removing one owner advances it instead of dropping it; and the recency - * map also holds host-qualified keys, so it is scanned rather than indexed. + * Some classified fields are handled separately below: the topology revision is keyed by repo + * rather than by owner, so removing one owner advances it instead of dropping it; and the + * host-qualified maps are scanned rather than indexed. */ export const OWNER_KEYED_SESSION_FIELDS_DELETED_WITH_THEIR_OWNER = SESSION_FIELDS_PRUNED_BY_OWNER_KEY.filter( - (field) => field !== 'terminalTopologyRevisionByRepoId' && field !== 'lastVisitedAtByWorktreeId' + (field) => + field !== 'terminalTopologyRevisionByRepoId' && + !HOST_QUALIFIED_SESSION_FIELDS.some((hostQualified) => hostQualified === field) ) export function createMinimalPersistedTerminalTab(args: { @@ -89,12 +97,12 @@ export function deleteOwnerKeyedSessionFields( delete record[ownerKey] } } - // Scanned, not indexed: this map also holds `${executionHostId}|${worktreeId}` keys. - if (next.lastVisitedAtByWorktreeId) { - for (const key of Object.keys(next.lastVisitedAtByWorktreeId)) { + for (const field of HOST_QUALIFIED_SESSION_FIELDS) { + const record: Record | undefined = next[field] + for (const key of Object.keys(record ?? {})) { const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key - if (key === ownerKey || rawId === ownerKey) { - delete next.lastVisitedAtByWorktreeId[key] + if (record && (key === ownerKey || rawId === ownerKey)) { + delete record[key] } } } diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts index 414db2867f0..aae3b99b37f 100644 --- a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts +++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts @@ -50,6 +50,8 @@ export const WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND = { lastVisitedAtByWorktreeId: 'owner-keyed', defaultTerminalTabsAppliedByWorktreeId: 'owner-keyed', sleepingAgentSessionsByPaneKey: 'row-record', + // A remembered Sleep is a view choice, not evidence that a checkout still exists. + explicitlySleptChatsByWorkspace: 'none', terminalPtyIncarnationsByPaneKey: 'none', terminalTopologyRevisionByRepoId: 'none', terminalSurfaceTombstonesByPaneKey: 'row-record', diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts b/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts index deabb7197ca..1b13ef3509f 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts @@ -206,6 +206,21 @@ const referencingFields = Object.keys(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND) .sort() describe('migrateWorktreeIdentity worktree-reference coverage', () => { + it('rekeys view-only Sleep intent without treating it as checkout ownership', () => { + const session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + explicitlySleptChatsByWorkspace: { + [`local|${OLD}`]: [{ tabId: 'chat', sessionId: 'session' }] + } + } + expect([...collectWorkspaceSessionWorktreeOwners(session, CANDIDATES)]).toEqual([]) + + expect(migrateWorktreeIdentity(persistedState(session), OLD, NEW)).toBe(true) + expect(session.explicitlySleptChatsByWorkspace).toEqual({ + [`local|${NEW}`]: [{ tabId: 'chat', sessionId: 'session' }] + }) + }) + it('has a fixture for every field the ownership census says can name a worktree', () => { const missing = referencingFields.filter((field) => !REFERENCE_FIXTURES[field]) expect(missing).toEqual([]) diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index 2bc7fa95e6f..bef5211b773 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -9,6 +9,7 @@ import { isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +import { renameSleptChatWorkspaceInSession } from '../../../shared/workspace-chat-sleep' type WorktreeNamingRow = { worktreeId: string } @@ -189,6 +190,8 @@ export function migrateWorktreeIdentity( moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged + sessionChanged = + renameSleptChatWorkspaceInSession(session, oldWorktreeId, newWorktreeId) || sessionChanged if (session.lastVisitedAtByWorktreeId) { const nextRecency = { ...session.lastVisitedAtByWorktreeId } let recencyChanged = false diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 7b135e0fce1..2c2bf69a0d1 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -207,6 +207,19 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()[0]).not.toHaveProperty('structuredHostOwned') }) + it('clears a marked chat when the first confirmed live summary is still working', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { 'wt-1': [{ ...structuredTab, executionHostId: 'local' }] }, + explicitlySleptChatsByWorkspace: { + 'local|wt-1': [{ tabId: structuredTab.id, sessionId: structuredTab.entityId }] + } + }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) + expect(mocks.store?.getState().explicitlySleptChatsByWorkspace['local|wt-1']).toBeUndefined() + }) + it('maps each host status onto the sidebar agent state', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) @@ -651,9 +664,12 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).toHaveBeenCalledTimes(calls) }) - it('keeps the host-fed chat row through Sleep and stops offering Sleep once the host lets go', async () => { + it('keeps the host-fed chat row through Sleep and offers Sleep until the chat is recorded slept', async () => { const state = (): AppState => useAppStore.getState() const sleepable = (): boolean => hasSleepableWorkspaceActivity('wt-1', state()) + mocks.store?.setState({ + unifiedTabsByWorktree: { 'wt-1': [{ ...structuredTab, executionHostId: 'local' }] } + }) render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) act(() => feed().emit({ type: 'status', session: summary({ status: 'idle', updatedAt: 2 }) })) @@ -675,7 +691,16 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()).toEqual([expect.objectContaining({ state: 'done' })]) expect(statuses()[0].structuredHostOwned).toBeUndefined() expect(state().acknowledgedAgentsByPaneKey).toEqual({ [paneKey]: 3 }) + // The host let go; only the explicit Sleep's record stops offering Sleep, and new work clears it. + expect(sleepable()).toBe(true) + const chat = { tabId: structuredTab.id, sessionId: structuredTab.entityId } + act(() => state().markWorkspaceChatsSlept('wt-1', 'local', [chat])) expect(sleepable()).toBe(false) + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 3 }) })) + expect(state().explicitlySleptChatsByWorkspace['local|wt-1']).toBeUndefined() + // Work reported before the Sleep flow records the chat still wakes it. + act(() => state().markWorkspaceChatsSlept('wt-1', 'local', [chat])) + expect(state().explicitlySleptChatsByWorkspace['local|wt-1']).toBeUndefined() }) it('drops the status and the feed when the last structured tab closes', async () => { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index cd32e239fa0..8e9d844a864 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -23,6 +23,7 @@ import { } from '../../../../shared/agent-status-types' import { structuredChildWorkLegacySubagents } from '../../../../shared/structured-agent-session-child-work-legacy' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { isChatExplicitlySlept } from '../../../../shared/workspace-chat-sleep' import { structuredAgentSessionAgentStatus } from '../../../../shared/structured-agent-session-agent-status' import { structuredAgentSessionDatedMainAgent, @@ -328,6 +329,23 @@ function StructuredAgentSessionOwnedStatusProjection({ target: RuntimeClientTarget }): null { const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target) + const { id: tabId, entityId: sessionId, executionHostId, worktreeId } = tab + // Why: a send after Sleep's host stop can report working before the flow records the chat slept. + const slept = useAppStore((state) => + isChatExplicitlySlept(state.explicitlySleptChatsByWorkspace, tab) + ) + useEffect(() => { + if (!slept || observation !== 'live' || summary?.status !== 'working') { + return + } + const store = useAppStore.getState() + if (executionHostId) { + store.clearWorkspaceChatsSlept(worktreeId, { + hostId: executionHostId, + chats: [{ tabId, sessionId }] + }) + } + }, [slept, summary, observation, tabId, sessionId, executionHostId, worktreeId]) const launchFailed = useStructuredAgentSessionLaunchLifecycle(tab.worktreeId, tab.entityId) === 'failed' const failedAt = useStructuredAgentSessionLaunchFailedAt(tab.entityId) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 865541279fe..2a17802c403 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -13,6 +13,9 @@ const mocks = vi.hoisted(() => { ptyIdsByTabId: Record worktreesByRepo: Record detectedWorktreesByRepo: Record + unifiedTabsByWorktree: Record + markWorkspaceChatsSlept: ReturnType + clearWorkspaceChatsSlept: ReturnType } = { activeWorktreeId: null, setActiveWorktree: vi.fn((worktreeId: string | null) => { @@ -25,15 +28,20 @@ const mocks = vi.hoisted(() => { tabsByWorktree: {}, ptyIdsByTabId: {}, worktreesByRepo: {}, - detectedWorktreesByRepo: {} + detectedWorktreesByRepo: {}, + unifiedTabsByWorktree: {}, + markWorkspaceChatsSlept: vi.fn(), + clearWorkspaceChatsSlept: vi.fn() } const suspendWorkspace = vi.fn().mockResolvedValue(null) const toastError = vi.fn() const markWorktreeSleepIntent = vi.fn() const clearWorktreeSleepIntent = vi.fn() const callChat = vi.fn() + const hasWorktreeSleepIntent = vi.fn(() => true) return { callChat, + hasWorktreeSleepIntent, clearWorktreeSleepIntent, markWorktreeSleepIntent, state, @@ -52,7 +60,7 @@ vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/lib/worktree-sleep-intent', () => ({ clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, markWorktreeSleepIntent: mocks.markWorktreeSleepIntent, - hasWorktreeSleepIntent: () => true, + hasWorktreeSleepIntent: mocks.hasWorktreeSleepIntent, withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -60,6 +68,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' +import type { Tab } from '../../../../shared/tab-types' import { runSleepWorktree, runSleepWorktrees, @@ -97,6 +106,25 @@ describe('runSleepWorktree', () => { mocks.state.ptyIdsByTabId = {} mocks.state.worktreesByRepo = {} mocks.callChat.mockReset().mockResolvedValue({ stoppedSessionIds: [] }) + mocks.hasWorktreeSleepIntent.mockReset().mockReturnValue(true) + mocks.state.unifiedTabsByWorktree = {} + mocks.state.markWorkspaceChatsSlept.mockClear() + mocks.state.clearWorkspaceChatsSlept.mockClear() + }) + + const chat = (id: string, worktreeId: string, executionHostId: Tab['executionHostId']): Tab => ({ + id, + entityId: `session-${id}`, + groupId: 'group-1', + worktreeId, + contentType: 'agent-session', + agentSessionAgent: 'claude', + executionHostId, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 }) it('tears down browsers before terminals on the sleep path', async () => { @@ -305,11 +333,17 @@ describe('runSleepWorktree', () => { } }) + mocks.state.unifiedTabsByWorktree = { 'wt-2': [chat('a', 'wt-2', 'local')] } + mocks.callChat.mockResolvedValue({ stoppedSessionIds: ['session-a'] }) + const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2']) expect(outcome.failedWorktreeIds).toEqual(['wt-1']) expect(outcome.errors).toHaveLength(1) expect(mocks.toastError).not.toHaveBeenCalled() + // A project move pauses through this core; only the user's Sleep records chats asleep. + expect(mocks.state.markWorkspaceChatsSlept).not.toHaveBeenCalled() + expect(mocks.state.clearWorkspaceChatsSlept).not.toHaveBeenCalled() }) it('sleeps multiple worktrees and clears active only once when included', async () => { @@ -345,20 +379,43 @@ describe('runSleepWorktree', () => { mocks.state.worktreesByRepo = twinRows mocks.state.tabsByWorktree = { [twinId]: [{ id: 'tab-1' }] } mocks.state.ptyIdsByTabId = { 'tab-1': [toRemoteRuntimePtyId('pty-1', 'env-a')] } + mocks.state.unifiedTabsByWorktree = { + [twinId]: [ + chat('local', twinId, 'local'), + chat('environment', twinId, 'runtime:env-a'), + chat('unconfirmed', twinId, 'runtime:env-a') + ] + } mocks.callChat.mockImplementation(async (target: { kind: string }) => ({ - stoppedSessionIds: [`chat-${target.kind}`] + stoppedSessionIds: [`session-${target.kind}`] })) const results = await runSleepWorktrees([twinId]) expect(results).toEqual([ - expect.objectContaining({ executionHostId: 'local', stoppedSessionIds: ['chat-local'] }), + expect.objectContaining({ executionHostId: 'local', stoppedSessionIds: ['session-local'] }), expect.objectContaining({ executionHostId: 'runtime:env-a', - stoppedSessionIds: ['chat-environment'], + stoppedSessionIds: ['session-environment'], completed: true }) ]) + // Only chats a host confirmed stopped are recorded, each under its own host. + expect(mocks.state.markWorkspaceChatsSlept.mock.calls).toEqual([ + [twinId, 'local', [{ tabId: 'local', sessionId: 'session-local' }]], + [twinId, 'runtime:env-a', [{ tabId: 'environment', sessionId: 'session-environment' }]] + ]) + }) + + it('records nothing when the workspace wakes during the chat stop', async () => { + mocks.state.unifiedTabsByWorktree = { 'wt-1': [chat('a', 'wt-1', 'local')] } + mocks.callChat.mockResolvedValue({ stoppedSessionIds: ['session-a'] }) + mocks.hasWorktreeSleepIntent.mockReturnValue(false) + + const results = await runSleepWorktree('wt-1') + + expect(results).toEqual([expect.objectContaining({ completed: false })]) + expect(mocks.state.markWorkspaceChatsSlept).not.toHaveBeenCalled() }) it('stops no chat when it cannot tell which copy runs the terminals', async () => { @@ -383,6 +440,9 @@ describe('runSleepWorktree', () => { expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() expect(mocks.state.setActiveWorktree.mock.calls).toEqual([[null]]) expect(results).toEqual([]) + // A Sleep that can't confirm its chats leaves the workspace awake on every host. + expect(mocks.state.clearWorkspaceChatsSlept).toHaveBeenCalledWith('wt-1') + expect(mocks.state.markWorkspaceChatsSlept).not.toHaveBeenCalled() expect(mocks.toastError).toHaveBeenCalledOnce() expect(mocks.toastError).toHaveBeenCalledWith('Failed to sleep workspace', { description: diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index 3959cbc1039..a17c84408fe 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -8,8 +8,14 @@ import { } from '@/lib/worktree-sleep-intent' import { translate } from '@/i18n/i18n' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { Tab } from '../../../../shared/tab-types' import { preserveSidebarWorktreePosition } from './sleep-worktree-sidebar-position' -import { stopWorkspaceChats, type WorkspaceChatStop } from './sleep-workspace-chat-stop' +import { + stopWorkspaceChats, + type WorkspaceChatStop, + type WorkspaceChatStopReceipt +} from './sleep-workspace-chat-stop' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' /** One per host asked to stop the workspace's chats. */ export type WorkspaceSleepResult = { @@ -79,7 +85,9 @@ function describeChatStopFailure(): string { export async function runSleepWorktrees( worktreeIds: readonly string[] ): Promise { - const { errors, results } = await sleepWorktreesReportingFailures(worktreeIds) + const { errors, results } = await sleepWorktreesReportingFailures(worktreeIds, { + recordExplicitChatSleep: true + }) if (errors.length > 0) { // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. toast.error( @@ -100,6 +108,31 @@ export async function runSleepWorktrees( return results } +/** Records the chats each host confirmed stopped; a failed chat stop leaves the workspace awake. */ +function recordExplicitChatSleep( + worktreeId: string, + receipts: readonly WorkspaceChatStopReceipt[] | null, + capturedChats: readonly Tab[] +): void { + const store = useAppStore.getState() + if (!receipts) { + store.clearWorkspaceChatsSlept(worktreeId) + return + } + // Why by session id: a copy reached through a paired runtime stamps its tabs with its own host. + const stopped = new Set(receipts.flatMap((receipt) => receipt.stoppedSessionIds)) + const chatsByHost = new Map() + for (const tab of capturedChats) { + if (tab.executionHostId && stopped.has(tab.entityId)) { + const chats = chatsByHost.get(tab.executionHostId) ?? [] + chatsByHost.set(tab.executionHostId, [...chats, { tabId: tab.id, sessionId: tab.entityId }]) + } + } + for (const [hostId, chats] of chatsByHost) { + store.markWorkspaceChatsSlept(worktreeId, hostId, chats) + } +} + export type WorktreeSleepOutcome = { /** Every workspace not verifiably slept, including one whose chat stop went unconfirmed. */ failedWorktreeIds: string[] @@ -109,7 +142,9 @@ export type WorktreeSleepOutcome = { /** The sleep without the toast, for callers that report failures themselves. */ export async function sleepWorktreesReportingFailures( - requestedWorktreeIds: readonly string[] + requestedWorktreeIds: readonly string[], + // Why: only the user's Sleep hides its chats; a project move pauses through this core too. + options: { recordExplicitChatSleep?: boolean } = {} ): Promise { // Why unique: both copies of a twin share one id, and sleeping it sleeps the whole workspace. const worktreeIds = [...new Set(requestedWorktreeIds)] @@ -140,6 +175,9 @@ export async function sleepWorktreesReportingFailures( const chatFailedIds = new Set() const attempts = worktreeIds.map((worktreeId) => { markWorktreeSleepIntent(worktreeId) + const capturedChats = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).filter( + isStructuredTab + ) let chats: Promise = Promise.resolve({ receipts: [], failed: false }) // Browser cleanup and the authoritative terminal admission run before any batch wait. const panels = withWorktreeSleepTeardown(worktreeId, async () => { @@ -165,13 +203,13 @@ export async function sleepWorktreesReportingFailures( errors.push(describeSleepFailure(error)) } ) - return { worktreeId, settled: panels.then(() => chats) } + return { worktreeId, capturedChats, settled: panels.then(() => chats) } }) const results: WorkspaceSleepResult[] = [] try { // A VM may carry another workspace's host: settle the entire batch before suspending any. const chatStops = await Promise.all(attempts.map(({ settled }) => settled)) - for (const [index, { worktreeId }] of attempts.entries()) { + for (const [index, { worktreeId, capturedChats }] of attempts.entries()) { const { receipts, failed } = chatStops[index] if (failed) { chatFailedIds.add(worktreeId) @@ -196,6 +234,12 @@ export async function sleepWorktreesReportingFailures( results.push( ...receipts.map((receipt) => ({ workspaceId: worktreeId, ...receipt, completed: false })) ) + // Why re-read the marker: a wake during the batch keeps the workspace awake. + const completed = + !failed && !failedWorktreeIds.has(worktreeId) && hasWorktreeSleepIntent(worktreeId) + if (options.recordExplicitChatSleep && (completed || failed)) { + recordExplicitChatSleep(worktreeId, completed ? receipts : null, capturedChats) + } } } finally { // Why: a failed sleep leaves the workspace awake and retryable. diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 262aa9823eb..36cd9aa7d0c 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -53,7 +53,11 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ !showSleepingWorkspaces ? s.browserTabsByWorktree : null ) const worktreeIdsWithStructuredChat = useAppStore((s) => - getStructuredChatWorktreeIds(showSleepingWorkspaces, s.unifiedTabsByWorktree) + getStructuredChatWorktreeIds( + showSleepingWorkspaces, + s.unifiedTabsByWorktree, + s.explicitlySleptChatsByWorkspace + ) ) const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) // Why: skip the clock entirely when the epoch is the opt-out sentinel, so a diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index d7f4849ba31..ae93319237f 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -19,6 +19,7 @@ import { EMPTY_BROWSER_TABS_BY_WORKTREE, EMPTY_CYCLIC_LINEAGE_IDS, EMPTY_DELETE_STATE_BY_WORKTREE_ID, + EMPTY_EXPLICITLY_SLEPT_CHATS, EMPTY_PTY_IDS_BY_TAB_ID, EMPTY_TABS_BY_WORKTREE, EMPTY_UNIFIED_TABS_BY_WORKTREE, @@ -129,6 +130,9 @@ export function useWorktreeContextMenuModel({ const agentStatusByPaneKey = useAppStore((s) => selectMenuScopedMap(menuOpen, s.agentStatusByPaneKey, EMPTY_AGENT_STATUS_BY_PANE_KEY) ) + const explicitlySleptChatsByWorkspace = useAppStore((s) => + selectMenuScopedMap(menuOpen, s.explicitlySleptChatsByWorkspace, EMPTY_EXPLICITLY_SLEPT_CHATS) + ) const deleteStateByWorktreeId = useAppStore((s) => selectMenuScopedMap(menuOpen, s.deleteStateByWorktreeId, EMPTY_DELETE_STATE_BY_WORKTREE_ID) ) @@ -139,38 +143,37 @@ export function useWorktreeContextMenuModel({ const workspaceScope = parseWorkspaceKey(worktree.id) const folderWorkspaceId = workspaceScope?.type === 'folder' ? workspaceScope.folderWorkspaceId : null - const sleepableWorktrees = useMemo( - () => - activeContextWorktrees.filter((item) => - hasSleepableWorkspaceActivity(item.id, { - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - unifiedTabsByWorktree, - agentStatusByPaneKey - }) - ), + const sleepActivity = useMemo( + () => ({ + tabsByWorktree, + ptyIdsByTabId, + browserTabsByWorktree, + unifiedTabsByWorktree, + agentStatusByPaneKey, + explicitlySleptChatsByWorkspace + }), [ - activeContextWorktrees, agentStatusByPaneKey, browserTabsByWorktree, + explicitlySleptChatsByWorkspace, ptyIdsByTabId, tabsByWorktree, unifiedTabsByWorktree ] ) + const sleepableWorktrees = useMemo( + () => + activeContextWorktrees.filter((item) => + hasSleepableWorkspaceActivity(item.id, sleepActivity) + ), + [activeContextWorktrees, sleepActivity] + ) const lineageMenuActions = useWorkspaceLineageMenuActions({ enabled: !isMultiContext, parent: worktree, worktrees: allWorktrees, lineageById: worktreeLineageById, - activity: { - tabsByWorktree, - ptyIdsByTabId, - browserTabsByWorktree, - unifiedTabsByWorktree, - agentStatusByPaneKey - } + activity: sleepActivity }) const lineageDescendantCount = lineageMenuActions.descendants.length const subtreeSleepableWorktrees = lineageMenuActions.sleepableTargets diff --git a/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx b/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx index e62b0759c30..9ff1acefb99 100644 --- a/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx @@ -16,6 +16,7 @@ type MockState = { tabsByWorktree: Record browserTabsByWorktree: Record unifiedTabsByWorktree: Record + explicitlySleptChatsByWorkspace: Record ptyIdsByTabId: Record agentStatusEpoch: number agentStatusByPaneKey: Record @@ -96,6 +97,7 @@ describe('useIsSleepingWorktree', () => { tabsByWorktree: {}, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, + explicitlySleptChatsByWorkspace: {}, ptyIdsByTabId: {}, agentStatusEpoch: 0, agentStatusByPaneKey: {}, @@ -159,6 +161,37 @@ describe('useIsSleepingWorktree', () => { expect(renderToStaticMarkup()).toBe('false') }) + it('reads a slept chat as asleep until its agent runs again', () => { + const worktreeId = 'repo1::/path/wt1' + const paneKey = makePaneKey('chat-1', LEAF_ID) + const chat = makeUnifiedTab({ + id: 'chat-1', + worktreeId, + contentType: 'agent-session', + agentSessionAgent: 'claude' + }) + chat.executionHostId = 'local' + const doneRow = makeAgentStatusEntry({ paneKey, state: 'done', worktreeId }) + mockState = { + ...mockState, + unifiedTabsByWorktree: { [worktreeId]: [chat] }, + explicitlySleptChatsByWorkspace: { + [`local|${worktreeId}`]: [{ tabId: chat.id, sessionId: chat.entityId }] + }, + agentStatusByPaneKey: { [paneKey]: doneRow } + } + expect(renderToStaticMarkup()).toBe('true') + + // A send from another client starts the agent; its turn ends but the process lives on. + mockState = { + ...mockState, + agentStatusByPaneKey: { + [paneKey]: { ...doneRow, tabId: 'chat-1', structuredHostOwned: true } + } + } + expect(renderToStaticMarkup()).toBe('false') + }) + it('does not treat a non-chat unified tab as activity', () => { // Negative control: the term keys on a structured chat, not on any unified tab existing. const worktreeId = 'repo1::/path/wt1' diff --git a/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts b/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts index 328e10c03cd..6be8ba5bff3 100644 --- a/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts +++ b/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts @@ -4,6 +4,7 @@ import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree import { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' +import type { ExplicitlySleptChatsByWorkspace } from '../../../../shared/workspace-chat-sleep' type TabLike = { id: string } @@ -16,6 +17,7 @@ type SleepStateInput = { ptyIdsByTabId?: Record | null browserTabsByWorktree?: Record | null unifiedTabsByWorktree?: Record | null + explicitlySleptChatsByWorkspace?: ExplicitlySleptChatsByWorkspace } type LiveAgentGeneration = { @@ -74,7 +76,10 @@ export function selectIsSleepingWorktree(state: SleepStateInput, worktreeId: str state.ptyIdsByTabId, state.browserTabsByWorktree, selectWorktreeIdsWithLiveAgent(state), - getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree) + getWorktreeIdsWithStructuredChat( + state.unifiedTabsByWorktree, + state.explicitlySleptChatsByWorkspace + ) ) } diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts index 0d65e41a214..5cac1cf13e8 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -3,6 +3,10 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getStructuredAgentSessionTabs } from '@/components/native-chat/structured-agent-session-tabs' import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' +import { + isChatExplicitlySlept, + type ExplicitlySleptChatsByWorkspace +} from '../../../../shared/workspace-chat-sleep' export type TerminalActivityTab = Pick export type BrowserActivityTab = Pick @@ -37,40 +41,52 @@ export function getVisibleWorktreeBrowserActivityTabs( } export const EMPTY_STRUCTURED_CHAT_WORKTREE_IDS: ReadonlySet = new Set() -const structuredChatWorktreeIds = new WeakMap, ReadonlySet>() +const EMPTY_SLEPT_CHATS: ExplicitlySleptChatsByWorkspace = {} +const structuredChatWorktreeIds = new WeakMap< + Record, + WeakMap> +>() /** - * Worktree ids holding a structured chat tab. + * Workspace ids holding a structured chat that Sleep has not put to rest. * - * Why existence rather than a live provider child: that child is held only while the chat's pane is - * visible and is evicted after it sits idle unheld, so keying on it would flip a workspace to sleeping - * after any worktree switch and report a process recycle the user never sees. The chat itself — its - * transcript, and its ability to take the next send — outlives the child. + * Why the chat rather than its provider child: the child starts only on a send and the host's idle + * sweep stops it, so keying on it would put every quiet chat workspace to sleep after a restart or a + * pause. Only Sleep does that; activating the workspace clears the mark, and a child running anyway + * keeps it awake through the live-agent term. */ export function getWorktreeIdsWithStructuredChat( - unifiedTabsByWorktree: Record | null | undefined + unifiedTabsByWorktree: Record | null | undefined, + explicitlySleptChatsByWorkspace: ExplicitlySleptChatsByWorkspace = EMPTY_SLEPT_CHATS ): ReadonlySet { if (!unifiedTabsByWorktree) { return EMPTY_STRUCTURED_CHAT_WORKTREE_IDS } // Keyed on the snapshot, like the tab projection it reads: zustand re-runs every mounted card's // selector on each store write, and this is a whole-store scan. - const cached = structuredChatWorktreeIds.get(unifiedTabsByWorktree) + const cached = structuredChatWorktreeIds + .get(unifiedTabsByWorktree) + ?.get(explicitlySleptChatsByWorkspace) if (cached) { return cached } const worktreeIds = new Set( - getStructuredAgentSessionTabs(unifiedTabsByWorktree).map((tab) => tab.worktreeId) + getStructuredAgentSessionTabs(unifiedTabsByWorktree) + .filter((tab) => !isChatExplicitlySlept(explicitlySleptChatsByWorkspace, tab)) + .map((tab) => tab.worktreeId) ) - structuredChatWorktreeIds.set(unifiedTabsByWorktree, worktreeIds) + const byIntent = structuredChatWorktreeIds.get(unifiedTabsByWorktree) ?? new WeakMap() + byIntent.set(explicitlySleptChatsByWorkspace, worktreeIds) + structuredChatWorktreeIds.set(unifiedTabsByWorktree, byIntent) return worktreeIds } export function getStructuredChatWorktreeIds( showSleepingWorkspaces: boolean, - unifiedTabsByWorktree: Record | null | undefined + unifiedTabsByWorktree: Record | null | undefined, + explicitlySleptChatsByWorkspace: ExplicitlySleptChatsByWorkspace = EMPTY_SLEPT_CHATS ): ReadonlySet { return showSleepingWorkspaces ? EMPTY_STRUCTURED_CHAT_WORKTREE_IDS - : getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree) + : getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree, explicitlySleptChatsByWorkspace) } diff --git a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts index 61eb0313bce..43d153186cc 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts @@ -31,7 +31,10 @@ export function buildVisibleWorktreeOptionsFromState( state.tabsByWorktree, Date.now() ), - worktreeIdsWithStructuredChat: getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree), + worktreeIdsWithStructuredChat: getWorktreeIdsWithStructuredChat( + state.unifiedTabsByWorktree, + state.explicitlySleptChatsByWorkspace + ), hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces, diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index 319880a34b8..c794b1bb658 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -70,7 +70,8 @@ describe('workspace lineage menu actions', () => { [grandchild.id]: [{ id: 'grandchild-browser' }] }, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + explicitlySleptChatsByWorkspace: {} } }) @@ -97,7 +98,8 @@ describe('workspace lineage menu actions', () => { ptyIdsByTabId: { 'child-tab': ['child-pty'] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + explicitlySleptChatsByWorkspace: {} } }) @@ -118,7 +120,8 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: { 'tab-1': [] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + explicitlySleptChatsByWorkspace: {} }) ).toBe(false) }) @@ -130,7 +133,8 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: { 'tab-1': ['pty-1'] }, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + explicitlySleptChatsByWorkspace: {} }) ).toBe(true) expect( @@ -139,7 +143,8 @@ describe('hasSleepableWorkspaceActivity', () => { ptyIdsByTabId: {}, browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] }, unifiedTabsByWorktree: {}, - agentStatusByPaneKey: {} + agentStatusByPaneKey: {}, + explicitlySleptChatsByWorkspace: {} }) ).toBe(true) }) @@ -158,15 +163,25 @@ describe('hasSleepableWorkspaceActivity', () => { sortOrder: 0, createdAt: 0 } - const activity = (agentStatusByPaneKey: Record) => + const activity = ( + agentStatusByPaneKey: Record, + tab: Tab = chat, + slept: { tabId: string; sessionId: string }[] = [] + ) => hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: {}, ptyIdsByTabId: {}, browserTabsByWorktree: {}, - unifiedTabsByWorktree: { 'wt-1': [chat] }, - agentStatusByPaneKey + unifiedTabsByWorktree: { 'wt-1': [tab] }, + agentStatusByPaneKey, + explicitlySleptChatsByWorkspace: { 'local|wt-1': slept } }) expect(activity(hostHeld(chat))).toBe(true) expect(activity({})).toBe(false) + // An ended chat stays sleepable until an explicit Sleep records that exact session. + const ended = { ...chat, executionHostId: 'local' as const } + expect(activity({}, ended)).toBe(true) + expect(activity({}, ended, [{ tabId: 'chat-1', sessionId: 'session-1' }])).toBe(false) + expect(activity({}, ended, [{ tabId: 'chat-1', sessionId: 'old' }])).toBe(true) }) }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 975c3b0338a..8dd25106200 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -5,6 +5,10 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { Tab } from '../../../../shared/tab-types' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import { + isChatExplicitlySlept, + type ExplicitlySleptChatsByWorkspace +} from '../../../../shared/workspace-chat-sleep' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' type WorkspaceActivityMaps = { @@ -13,6 +17,7 @@ type WorkspaceActivityMaps = { browserTabsByWorktree: Record unifiedTabsByWorktree: Record agentStatusByPaneKey: Record + explicitlySleptChatsByWorkspace: ExplicitlySleptChatsByWorkspace } export type WorkspaceLineageMenuActions = { @@ -29,12 +34,15 @@ export function hasSleepableWorkspaceActivity( return ( tabs.some((tab) => tabHasLivePty(activity.ptyIdsByTabId, tab.id)) || (activity.browserTabsByWorktree[worktreeId] ?? []).length > 0 || - // A chat counts only while its host holds its process; a stopped one has nothing to sleep. + // A chat counts while its host holds its process, or until an explicit Sleep records it + // stopped, so an idle-ended chat can still be slept (and hidden) on purpose. (activity.unifiedTabsByWorktree[worktreeId] ?? []).some( (tab) => isStructuredTab(tab) && - activity.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] - ?.structuredHostOwned === true + (activity.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] + ?.structuredHostOwned === true || + (!!tab.executionHostId && + !isChatExplicitlySlept(activity.explicitlySleptChatsByWorkspace, tab))) ) ) } @@ -71,7 +79,8 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - agentStatusByPaneKey + agentStatusByPaneKey, + explicitlySleptChatsByWorkspace } = activity return useMemo( () => @@ -85,7 +94,8 @@ export function useWorkspaceLineageMenuActions( ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - agentStatusByPaneKey + agentStatusByPaneKey, + explicitlySleptChatsByWorkspace } }) : EMPTY_LINEAGE_MENU_ACTIONS, @@ -93,6 +103,7 @@ export function useWorkspaceLineageMenuActions( agentStatusByPaneKey, browserTabsByWorktree, enabled, + explicitlySleptChatsByWorkspace, lineageById, parent, ptyIdsByTabId, diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts index e3a24afd41e..ad84272be15 100644 --- a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts @@ -29,6 +29,7 @@ export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} export const EMPTY_UNIFIED_TABS_BY_WORKTREE: AppState['unifiedTabsByWorktree'] = {} export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} +export const EMPTY_EXPLICITLY_SLEPT_CHATS: AppState['explicitlySleptChatsByWorkspace'] = {} export const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {} export const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {} export const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts index 95cf9b7b963..e36cd7f10dd 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts @@ -114,7 +114,10 @@ const REVEAL_WORKSPACE: Record - getStructuredChatWorktreeIds(showSleepingWorkspaces, s.unifiedTabsByWorktree) + getStructuredChatWorktreeIds( + showSleepingWorkspaces, + s.unifiedTabsByWorktree, + s.explicitlySleptChatsByWorkspace + ) ) // Why snapshot on agentStatusEpoch: update membership immediately without repainting on every hook ping. diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index e9bfd270e24..1fe93439b73 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -69,6 +69,9 @@ export function useWorktreeJumpPaletteStoreState({ const browserTabsByWorktree = useAppStore((state) => state.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((state) => state.browserPagesByWorkspace) const unifiedTabsByWorktree = useAppStore((state) => state.unifiedTabsByWorktree) + const explicitlySleptChatsByWorkspace = useAppStore( + (state) => state.explicitlySleptChatsByWorkspace + ) const paletteIndexStatus = useMemo( () => selectPaletteIndexStatusSnapshot(useAppStore.getState(), paletteStatusInputsActive), // oxlint-disable-next-line react-hooks/exhaustive-deps -- these deps are the snapshot refresh policy. @@ -161,6 +164,7 @@ export function useWorktreeJumpPaletteStoreState({ browserTabsByWorktree, browserPagesByWorkspace, unifiedTabsByWorktree, + explicitlySleptChatsByWorkspace, agentStatusByPaneKey, runtimePaneTitlesByTabId, unreadTerminalTabs, diff --git a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts index cd6bd019ce5..a044d00a0ee 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts @@ -4,7 +4,7 @@ import { filterWorkspaceRows } from '../../../shared/workspace-view/workspace-ro import { buildDesktopWorkspaceRowFactReader } from '@/components/sidebar/desktop-workspace-row-facts' import { sortWorktreesSmart } from '../../../shared/sidebar-list/smart-sort' import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index' -import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' +import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import { getWorktreePaletteSearchScope, @@ -40,6 +40,7 @@ export function useWorktreeJumpPaletteWorktrees({ ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, + explicitlySleptChatsByWorkspace, activeWorktreeId, activeWorkspaceExecutionHostId, runtimeEnvironments, @@ -63,13 +64,9 @@ export function useWorktreeJumpPaletteWorktrees({ const hasQuery = paletteSearchQuery.length > 0 const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 const worktreeIdsWithLiveAgent = useMemo( - () => - new Set( - // The palette recomputes this snapshot when status inputs change; the - // clock intentionally reflects the render that performs that snapshot. - // oxlint-disable-next-line react/purity - getLiveAgentStatusByWorktreeId(agentStatusByPaneKey, tabsByWorktree, Date.now()).keys() - ), + // The clock reflects the render producing this snapshot. + // oxlint-disable-next-line react/purity + () => getWorktreeIdsWithLiveAgent(agentStatusByPaneKey, tabsByWorktree, Date.now()), [agentStatusByPaneKey, tabsByWorktree] ) const pairedDeviceIdsByEnvironment = useMemo( @@ -81,7 +78,8 @@ export function useWorktreeJumpPaletteWorktrees({ ) const worktreeIdsWithStructuredChat = getStructuredChatWorktreeIds( showSleepingWorkspaces, - unifiedTabsByWorktree + unifiedTabsByWorktree, + explicitlySleptChatsByWorkspace ) const emptyQueryVisibleWorktrees = useMemo(() => { // Why no project or host scope: the palette ignores the sidebar's scope; filterPredicate scopes it. diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts index e2f090df121..8d46e01ba5e 100644 --- a/src/renderer/src/lib/workspace-session-host-split.test.ts +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -114,6 +114,9 @@ describe('splitWorkspaceSessionByHost', () => { visitCount: 1 } ], + explicitlySleptChatsByWorkspace: { + 'runtime:env-a|a-wt': [{ tabId: 'chat-a', sessionId: 'session-a' }] + }, tabsByWorktree: { 'local-wt': [makeTab('t-local', 'local-wt')], 'a-wt': [makeTab('t-a', 'a-wt')], diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index d1a16cf25e9..a50e604c0fb 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -192,6 +192,9 @@ export function buildWorkspaceSessionPatch( if (changed.has('lastVisitedAtByWorktreeId')) { patch.lastVisitedAtByWorktreeId = buildLastVisitedAtByWorktreeId(snapshot) } + if (changed.has('explicitlySleptChatsByWorkspace')) { + patch.explicitlySleptChatsByWorkspace = snapshot.explicitlySleptChatsByWorkspace ?? {} + } if (changed.has('defaultTerminalTabsAppliedByWorktreeId')) { patch.defaultTerminalTabsAppliedByWorktreeId = snapshot.defaultTerminalTabsAppliedByWorktreeId && diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 9aadf057964..048005b2704 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -35,6 +35,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { worktreesByRepo: true, lastKnownRelayPtyIdByTabId: true, lastVisitedAtByWorktreeId: true, + explicitlySleptChatsByWorkspace: true, defaultTerminalTabsAppliedByWorktreeId: true, sleepingAgentSessionsByPaneKey: true, clientHostedBrowserCloseIntentsByEnvironment: true, diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index c519fb05de6..308599713b5 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -59,6 +59,7 @@ export type WorkspaceSessionSnapshot = Pick< | 'defaultTerminalTabsAppliedByWorktreeId' > & { activeWorkspaceExecutionHostId?: AppState['activeWorkspaceExecutionHostId'] + explicitlySleptChatsByWorkspace?: AppState['explicitlySleptChatsByWorkspace'] sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] clientHostedBrowserCloseIntentsByEnvironment?: AppState['clientHostedBrowserCloseIntentsByEnvironment'] /** Optional so the many partial snapshot fixtures keep type-checking; see buildTerminalSessionData. */ @@ -98,6 +99,7 @@ export const SESSION_RELEVANT_FIELDS = [ 'worktreesByRepo', 'lastKnownRelayPtyIdByTabId', 'lastVisitedAtByWorktreeId', + 'explicitlySleptChatsByWorkspace', 'defaultTerminalTabsAppliedByWorktreeId', 'sleepingAgentSessionsByPaneKey', 'clientHostedBrowserCloseIntentsByEnvironment', @@ -264,7 +266,7 @@ export function buildTerminalSessionData( // Why: derive here to avoid a fragile sync IPC round-trip during beforeunload (Chromium can drop it under shutdown pressure). // Why: pre-indexed above so large workspaces don't rescan every repo/worktree per terminal tab while the renderer is quitting. - const remoteSessionIdsByTabId: Record = {} + const remoteSessions: Record = {} for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { const worktree = worktreeById.get(worktreeId) const repo = worktree ? repoById.get(worktree.repoId) : null @@ -277,15 +279,14 @@ export function buildTerminalSessionData( } const sessionId = tab.ptyId || restoredSessionId(tab.id) if (sessionId) { - remoteSessionIdsByTabId[tab.id] = sessionId + remoteSessions[tab.id] = sessionId } } } return { activeWorktreeIdsOnShutdown, - remoteSessionIdsByTabId: - Object.keys(remoteSessionIdsByTabId).length > 0 ? remoteSessionIdsByTabId : undefined + remoteSessionIdsByTabId: Object.keys(remoteSessions).length ? remoteSessions : undefined } } @@ -332,9 +333,9 @@ export function buildWorkspaceSessionPayload( remoteSessionIdsByTabId: terminalSessionData.remoteSessionIdsByTabId, // Why: omit when empty so builds that never stamped focus-recency don't bloat the payload. See docs/cmd-j-empty-query-ordering.md. lastVisitedAtByWorktreeId: buildLastVisitedAtByWorktreeId(snapshot), + explicitlySleptChatsByWorkspace: snapshot.explicitlySleptChatsByWorkspace ?? {}, defaultTerminalTabsAppliedByWorktreeId: - snapshot.defaultTerminalTabsAppliedByWorktreeId && - Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId).length > 0 + Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId ?? {}).length > 0 ? snapshot.defaultTerminalTabsAppliedByWorktreeId : undefined, ...buildSleepingAgentSessionData(snapshot), diff --git a/src/renderer/src/lib/worktree-activity-state.ts b/src/renderer/src/lib/worktree-activity-state.ts index d8440a044ef..ce056f4f81a 100644 --- a/src/renderer/src/lib/worktree-activity-state.ts +++ b/src/renderer/src/lib/worktree-activity-state.ts @@ -25,15 +25,27 @@ const EMPTY_WORKTREE_IDS: ReadonlySet = new Set() * Worktree ids that currently have a live agent session, derived from the * live `agentStatusByPaneKey` map. * - * Why only fresh in-progress rows: disconnected SSH and completed headless - * agents can retain status entries without an open session. + * Why only fresh in-progress rows, plus chats whose host still runs their + * agent: disconnected SSH and completed headless agents can retain status + * entries without an open session. */ export function getWorktreeIdsWithLiveAgent( agentStatusByPaneKey: Record | null | undefined, tabsByWorktree: TabsByWorktree | null | undefined, now: number ): Set { - return new Set(getLiveAgentStatusByWorktreeId(agentStatusByPaneKey, tabsByWorktree, now).keys()) + const rows = Object.values(agentStatusByPaneKey ?? {}) + const otherRows = Object.fromEntries( + rows.filter((entry) => !entry.structuredHostOwned).map((entry) => [entry.paneKey, entry]) + ) + const worktreeIds = new Set(getLiveAgentStatusByWorktreeId(otherRows, tabsByWorktree, now).keys()) + // A chat's agent outlives its turn, so a done row can still have a running process. + for (const entry of rows) { + if (entry.structuredHostOwned && entry.worktreeId) { + worktreeIds.add(entry.worktreeId) + } + } + return worktreeIds } export function getLiveAgentStatusByWorktreeId( diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts index 1b5b4892181..a8c664698d5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts @@ -7,6 +7,7 @@ import { buildRetractedMirroredTabSweepPatch } from './agent-status-primitives' import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence' +import { withoutReplacedSleptChats } from '../../../../shared/workspace-chat-sleep' type FinalPatchContext = ReturnType @@ -90,6 +91,11 @@ export function buildWebSessionTabsFinalPatch( state.recentlyClosedAgentStatusTabIds, mirroredTerminalIds ) + const nextSleptChats = withoutReplacedSleptChats( + state.explicitlySleptChatsByWorkspace ?? {}, + nextUnifiedTabsByWorktree, + worktreeId + ) const patch: Partial = { ...agentStatusPatch, @@ -103,6 +109,10 @@ export function buildWebSessionTabsFinalPatch( ...(nextUnifiedTabsByWorktree !== state.unifiedTabsByWorktree ? { unifiedTabsByWorktree: nextUnifiedTabsByWorktree } : {}), + ...(state.explicitlySleptChatsByWorkspace && + nextSleptChats !== state.explicitlySleptChatsByWorkspace + ? { explicitlySleptChatsByWorkspace: nextSleptChats } + : {}), ...(nextGroupsByWorktree !== state.groupsByWorktree ? { groupsByWorktree: nextGroupsByWorktree } : {}), diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index aa0297c250f..189337cc284 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -240,6 +240,7 @@ export type WebSessionTabsSyncState = Pick< // Why: a client draft is the evidence that a mirrored file's dirty flag is the client's // own and must survive a host republish (#21392); absent here, the host flag wins. | 'editorDrafts' + | 'explicitlySleptChatsByWorkspace' | 'localOnlyScrollbackByTabId' | 'migrationUnsupportedByPtyId' | 'manuallyUnreadTurnsByPaneKey' diff --git a/src/renderer/src/store/slices/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status-live-facts.ts index ffbb3d8396f..ccdb9ef2810 100644 --- a/src/renderer/src/store/slices/agent-status-live-facts.ts +++ b/src/renderer/src/store/slices/agent-status-live-facts.ts @@ -89,6 +89,8 @@ export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): Agen sortRelevantChange || attributionChanged || existing?.workingMode !== entry.workingMode || + // A chat's agent starting or stopping on a done row wakes or sleeps its workspace. + existing?.structuredHostOwned !== entry.structuredHostOwned || verdictChanged || doneRetentionFieldsChanged const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey] diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index 71cde0bac72..22a7cfcfa20 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -195,6 +195,22 @@ describe('purgeStaleRuntimeHostState', () => { expect(s.projectHostSetups).toBe(setupsRef) }) + it('drops removed-host Sleep intent even before its workspace catalog loads', () => { + const store = createTestStore() + store.setState({ + explicitlySleptChatsByWorkspace: { + 'runtime:env-a|repo1::/wt': [{ tabId: 'a', sessionId: 'a' }], + 'local|repo1::/wt': [{ tabId: 'local', sessionId: 'local' }] + } + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + expect(store.getState().explicitlySleptChatsByWorkspace).toEqual({ + 'local|repo1::/wt': [{ tabId: 'local', sessionId: 'local' }] + }) + }) + it('same-repoId-on-two-hosts: keeps an ambiguous unhosted row and the repo key', () => { const store = createTestStore() // repoId 'shared' exists under both local and runtime:env-a in worktreesByRepo. diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index 0391b354ce5..3aa885e0296 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -16,6 +16,7 @@ import { import { structuredAgentSessionTabId } from '../../../../../shared/structured-agent-session-projection' import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import { ownsGlobalSelection } from '../../global-selection-owner' +import { sleptChatWorkspaceKey } from '../../../../../shared/workspace-chat-sleep' import { structuredAgentSessionFocusOwner, structuredAgentSessionTargetForTab @@ -107,6 +108,25 @@ export function createTabsCloseActions( const nextTabs = (current.unifiedTabsByWorktree[worktreeId] ?? []).filter( (item) => item.id !== tabId ) + const sleptKey = + tab.contentType === 'agent-session' && tab.executionHostId + ? sleptChatWorkspaceKey(worktreeId, tab.executionHostId) + : null + const slept = sleptKey ? current.explicitlySleptChatsByWorkspace[sleptKey] : undefined + const retainedSlept = slept?.filter( + (entry) => entry.tabId !== tabId || entry.sessionId !== tab.entityId + ) + const nextSlept = + retainedSlept && retainedSlept.length !== slept?.length + ? { ...current.explicitlySleptChatsByWorkspace } + : current.explicitlySleptChatsByWorkspace + if (nextSlept !== current.explicitlySleptChatsByWorkspace && sleptKey) { + if (retainedSlept?.length) { + nextSlept[sleptKey] = retainedSlept + } else { + delete nextSlept[sleptKey] + } + } // Why: close-to-right/others bypass terminals.closeTab, so clear the entityId-keyed unread flag here or a stale dot leaks. const nextUnreadTerminalTabs = terminalEntityId ? omitRecordKeys(current.unreadTerminalTabs, [terminalEntityId]) @@ -154,6 +174,9 @@ export function createTabsCloseActions( !current.openFiles.some((file) => file.worktreeId === worktreeId) return { unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, + ...(nextSlept !== current.explicitlySleptChatsByWorkspace + ? { explicitlySleptChatsByWorkspace: nextSlept } + : {}), groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: nextGroups diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts index 6fb187f53aa..0f221be5a20 100644 --- a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -11,6 +11,7 @@ import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation' import { createWorktreeTabModelReconciliationBatch } from './tabs-reconciliation-batch' import type { AppState } from '../../types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { withoutReplacedSleptChats } from '../../../../../shared/workspace-chat-sleep' function replaceWorkspaceRecordKeys( current: Record, @@ -93,32 +94,46 @@ export function createTabsSessionActions( addAdditionalValidWorkspaceKeys(validWorktreeIds, options) const hydrated = buildHydratedTabState(session, validWorktreeIds) if (!options?.replaceWorkspaceKeys) { - set(hydrated) + set((current) => { + let intents = current.explicitlySleptChatsByWorkspace + for (const worktreeId of Object.keys(hydrated.unifiedTabsByWorktree)) { + intents = withoutReplacedSleptChats(intents, hydrated.unifiedTabsByWorktree, worktreeId) + } + return { ...hydrated, explicitlySleptChatsByWorkspace: intents } + }) return } const replaceWorkspaceKeys = new Set(options.replaceWorkspaceKeys) - set((current) => ({ - unifiedTabsByWorktree: replaceWorkspaceRecordKeys( + set((current) => { + const unifiedTabsByWorktree = replaceWorkspaceRecordKeys( current.unifiedTabsByWorktree, hydrated.unifiedTabsByWorktree, replaceWorkspaceKeys - ), - groupsByWorktree: replaceWorkspaceRecordKeys( - current.groupsByWorktree, - hydrated.groupsByWorktree, - replaceWorkspaceKeys - ), - activeGroupIdByWorktree: replaceWorkspaceRecordKeys( - current.activeGroupIdByWorktree, - hydrated.activeGroupIdByWorktree, - replaceWorkspaceKeys - ), - layoutByWorktree: replaceWorkspaceRecordKeys( - current.layoutByWorktree, - hydrated.layoutByWorktree, - replaceWorkspaceKeys ) - })) + let intents = current.explicitlySleptChatsByWorkspace + for (const worktreeId of replaceWorkspaceKeys) { + intents = withoutReplacedSleptChats(intents, unifiedTabsByWorktree, worktreeId) + } + return { + unifiedTabsByWorktree, + explicitlySleptChatsByWorkspace: intents, + groupsByWorktree: replaceWorkspaceRecordKeys( + current.groupsByWorktree, + hydrated.groupsByWorktree, + replaceWorkspaceKeys + ), + activeGroupIdByWorktree: replaceWorkspaceRecordKeys( + current.activeGroupIdByWorktree, + hydrated.activeGroupIdByWorktree, + replaceWorkspaceKeys + ), + layoutByWorktree: replaceWorkspaceRecordKeys( + current.layoutByWorktree, + hydrated.layoutByWorktree, + replaceWorkspaceKeys + ) + } + }) } } } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index a3aa6173b76..ff63fe4e25f 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -44,6 +44,10 @@ import type { WorktreeCreationPhase } from '@/lib/pending-worktree-creation' import type { AppState } from '../types' +import type { + ExplicitlySleptChatsByWorkspace, + SleptChatIdentity +} from '../../../../shared/workspace-chat-sleep' import type { WorktreeRefreshAllOptions } from './worktree-refresh-options' export type { WorktreePurgeTarget, WorktreePurgeTargets } from './worktree-purge-target' import type { WorktreePurgeTargets } from './worktree-purge-target' @@ -106,6 +110,17 @@ export type WorktreeSlice = { activeWorktreeId: string | null activeWorkspaceKey: WorkspaceKey | null activeWorkspaceExecutionHostId: ExecutionHostId | null + explicitlySleptChatsByWorkspace: ExplicitlySleptChatsByWorkspace + markWorkspaceChatsSlept: ( + worktreeId: string, + hostId: ExecutionHostId, + chats: readonly SleptChatIdentity[] + ) => void + /** Without a scope, clears the workspace's record on every host. */ + clearWorkspaceChatsSlept: ( + worktreeId: string, + scope?: { hostId: ExecutionHostId; chats: readonly SleptChatIdentity[] } + ) => void /** * In-flight / failed background worktree creations, keyed by a renderer * `creationId`. Kept separate from `worktreesByRepo` on purpose — a real diff --git a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts index b845b76e594..29fe1640afa 100644 --- a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts +++ b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' +import type { Tab } from '../../../../shared/tab-types' import { registerPersistentWebview, unregisterPersistentWebview @@ -56,6 +57,44 @@ describe('folder workspace lookups', () => { }) describe('setActiveWorktree focus handling', () => { + it('wakes every host copy of a shared workspace id when either copy opens', () => { + const store = createTestStore() + const worktreeId = 'repo1::/same' + const tab = (id: string, host: 'ssh:one' | 'ssh:two'): Tab => ({ + id, + entityId: id, + groupId: 'group', + worktreeId, + executionHostId: host, + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + }) + store.setState({ + worktreesByRepo: { + repo1: [ + makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'ssh:one' }), + makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'ssh:two' }) + ] + }, + unifiedTabsByWorktree: { [worktreeId]: [tab('one', 'ssh:one'), tab('two', 'ssh:two')] } + }) + store + .getState() + .markWorkspaceChatsSlept(worktreeId, 'ssh:one', [{ tabId: 'one', sessionId: 'one' }]) + store + .getState() + .markWorkspaceChatsSlept(worktreeId, 'ssh:two', [{ tabId: 'two', sessionId: 'two' }]) + + store.getState().setActiveWorktree(worktreeId, 'ssh:two') + + expect(store.getState().explicitlySleptChatsByWorkspace).toEqual({}) + }) + beforeEach(() => { vi.clearAllMocks() resetRemoteRuntimeMocks() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 423a1466a09..e967c331a70 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3,6 +3,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { WorktreeSlice } from './worktree-helpers' import { worktreeSliceInitialState } from './worktrees/session/worktree-slice-initial-state' +import { createWorkspaceChatSleepActions } from './worktrees/session/workspace-chat-sleep' import { createFetchDetectedWorktrees } from './worktrees/listing/fetch-detected-worktrees' import { createFetchWorktrees } from './worktrees/listing/fetch-worktrees' import { createFetchAllWorktrees } from './worktrees/listing/fetch-all-worktrees' @@ -70,6 +71,7 @@ export { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees/l export const createWorktreeSlice: StateCreator = (set, get) => ({ ...worktreeSliceInitialState, + ...createWorkspaceChatSleepActions(set), fetchDetectedWorktrees: createFetchDetectedWorktrees(set, get), fetchWorktrees: createFetchWorktrees(set, get), fetchAllWorktrees: createFetchAllWorktrees(set, get), diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 2b18b7bb21e..ea1ce8fd654 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -18,6 +18,7 @@ import { import { findKnownWorktreeById } from '../listing/detected-worktree-meta' import { resolveActivatedWorktreeSurface } from './active-worktree-surface' import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { withoutSleptChatsForWorkspaces } from '../../../../../../shared/workspace-chat-sleep' import { pendingActivationTerminalPrepCancels, shouldDeferActivationTerminalPrep @@ -116,6 +117,10 @@ export function createSetActiveWorktree( allDead && worktreeId != null ? markWorktreeTabsForActivationSpawn(s, worktreeId, { isWakeable, bumpGeneration: true }) : {} + // Why every host: opening either copy wakes the whole workspace, as it does its terminals. + const nextSleptChats = withoutSleptChatsForWorkspaces(s.explicitlySleptChatsByWorkspace, [ + { id: worktreeId } + ]) const nextActiveTabTypeByWorktree = s.activeTabTypeByWorktree[worktreeId] === activeTabType @@ -134,6 +139,7 @@ export function createSetActiveWorktree( nextActiveTabTypeByWorktree !== s.activeTabTypeByWorktree || nextEverActivated !== s.everActivatedWorktreeIds || nextActiveRepoId !== s.activeRepoId || + nextSleptChats !== s.explicitlySleptChatsByWorkspace || allDead || reconciliationChanged || stateTransition !== undefined @@ -159,7 +165,10 @@ export function createSetActiveWorktree( rightSidebarExplorerView: restoredRightSidebarExplorerView, activeTabId, everActivatedWorktreeIds: nextEverActivated, - ...tabsByWorktreeUpdate + ...tabsByWorktreeUpdate, + ...(nextSleptChats !== s.explicitlySleptChatsByWorkspace + ? { explicitlySleptChatsByWorkspace: nextSleptChats } + : {}) } }) diff --git a/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep.ts b/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep.ts new file mode 100644 index 00000000000..a762f500347 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep.ts @@ -0,0 +1,81 @@ +import type { WorktreeSlice } from '../../worktree-helpers' +import type { WorktreeSliceSet } from '../listing/worktree-slice-types' +import { isStructuredTab } from '../../../../../../shared/structured-agent-session-tab' +import { + sleptChatWorkspaceKey, + withoutSleptChatsForWorkspaces, + type SleptChatIdentity +} from '../../../../../../shared/workspace-chat-sleep' + +export function createWorkspaceChatSleepActions( + set: WorktreeSliceSet +): Pick { + return { + markWorkspaceChatsSlept: (worktreeId, hostId, chats) => + set((state) => { + const key = sleptChatWorkspaceKey(worktreeId, hostId) + const current = state.explicitlySleptChatsByWorkspace[key] ?? [] + const accepted: SleptChatIdentity[] = [] + for (const chat of chats) { + const tab = state.unifiedTabsByWorktree[worktreeId]?.find( + (candidate) => candidate.id === chat.tabId + ) + if ( + !tab || + !isStructuredTab(tab) || + tab.entityId !== chat.sessionId || + tab.executionHostId !== hostId + ) { + continue + } + if ( + !current.some( + (entry) => entry.tabId === chat.tabId && entry.sessionId === chat.sessionId + ) + ) { + accepted.push({ tabId: chat.tabId, sessionId: chat.sessionId }) + } + } + if (accepted.length === 0) { + return state + } + return { + explicitlySleptChatsByWorkspace: { + ...state.explicitlySleptChatsByWorkspace, + [key]: [...current, ...accepted] + } + } + }), + clearWorkspaceChatsSlept: (worktreeId, scope) => + set((state) => { + if (!scope) { + const next = withoutSleptChatsForWorkspaces(state.explicitlySleptChatsByWorkspace, [ + { id: worktreeId } + ]) + return next === state.explicitlySleptChatsByWorkspace + ? state + : { explicitlySleptChatsByWorkspace: next } + } + const { hostId, chats } = scope + const key = sleptChatWorkspaceKey(worktreeId, hostId) + const current = state.explicitlySleptChatsByWorkspace[key] + if (!current) { + return state + } + const retained = current.filter( + (entry) => + !chats.some((chat) => chat.tabId === entry.tabId && chat.sessionId === entry.sessionId) + ) + if (retained.length === current.length) { + return state + } + const next = { ...state.explicitlySleptChatsByWorkspace } + if (retained.length > 0) { + next[key] = retained + } else { + delete next[key] + } + return { explicitlySleptChatsByWorkspace: next } + }) + } +} diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts index 0fa6af89996..3fec0370705 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts @@ -7,6 +7,7 @@ import { remapBrowserPageDocLocation } from '../../../../../../shared/browser-pa import { splitWorktreeIdForFilesystem } from '../../../../../../shared/worktree/id' import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency' +import { withRenamedSleptChatWorkspace } from '../../../../../../shared/workspace-chat-sleep' import { remapClosedTerminalTabSnapshotCwds, type ClosedTerminalTabSnapshot @@ -223,9 +224,18 @@ export function buildWorktreeRenameState( ) : s.sleepingAgentSessionsByPaneKey + const explicitlySleptChatsByWorkspace = withRenamedSleptChatWorkspace( + s.explicitlySleptChatsByWorkspace, + oldWorktreeId, + newWorktreeId + ) + return { ...renameWorktreeLayoutKeys(s, oldWorktreeId, newWorktreeId, withNewBrowserWorktreeId), ...(renamed as Partial), + ...(explicitlySleptChatsByWorkspace !== s.explicitlySleptChatsByWorkspace + ? { explicitlySleptChatsByWorkspace } + : {}), ...(browserPagesByWorkspace !== s.browserPagesByWorkspace ? { browserPagesByWorkspace } : {}), ...(recentlyClosedBrowserPagesByWorkspace !== s.recentlyClosedBrowserPagesByWorkspace ? { recentlyClosedBrowserPagesByWorkspace } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts index 19f760ae738..0db147b909c 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts @@ -10,6 +10,7 @@ export const worktreeSliceInitialState: Pick< | 'activeWorktreeId' | 'activeWorkspaceKey' | 'activeWorkspaceExecutionHostId' + | 'explicitlySleptChatsByWorkspace' | 'pendingWorktreeCreations' | 'activePendingCreationId' | 'renamingWorktreeId' @@ -32,6 +33,7 @@ export const worktreeSliceInitialState: Pick< activeWorktreeId: null, activeWorkspaceKey: null, activeWorkspaceExecutionHostId: null, + explicitlySleptChatsByWorkspace: {}, pendingWorktreeCreations: {}, activePendingCreationId: null, renamingWorktreeId: null, diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index bdd36325980..1742083769f 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -1,4 +1,5 @@ import type { WorktreePurgeTarget, WorktreeSlice } from '../../worktree-helpers' +import type { AppState } from '../../../types' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import type { ProjectHostSetup } from '../../../../../../shared/project-types' import type { Repo } from '../../../../../../shared/repo-types' @@ -18,6 +19,10 @@ import { import { buildFullWorktreePurgeState } from './worktree-purge-state' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { settleAttentionRemovedBy } from '../../workspace-attention/attention-removal-follow-up' +import { + withoutSleptChatsForHosts, + withoutSleptChatsForWorkspaces +} from '../../../../../../shared/workspace-chat-sleep' export function createPurgeStaleRuntimeHostState( set: WorktreeSliceSet, @@ -212,8 +217,18 @@ export function createPurgeStaleRuntimeHostState( return targets.length > 0 ? targets : [{ id: worktreeId }] }) // Why full: survivor ids were dropped above unless the session partition proves the bare-id state is the removed host's. - const purgeState = + const purgeState: Partial = purgeTargets.length > 0 ? buildFullWorktreePurgeState(s, purgeTargets) : {} + const nextSleptChats = withoutSleptChatsForHosts( + withoutSleptChatsForWorkspaces( + purgeState.explicitlySleptChatsByWorkspace ?? s.explicitlySleptChatsByWorkspace, + removedWorktreeTargets + ), + new Set([...removed].map(toRuntimeExecutionHostId)) + ) + if (nextSleptChats !== s.explicitlySleptChatsByWorkspace) { + purgeState.explicitlySleptChatsByWorkspace = nextSleptChats + } const visitPurgeTargets = [ ...removedWorktreeTargets, ...purgeTargets.filter( @@ -258,6 +273,7 @@ export function createPurgeStaleRuntimeHostState( !visibilityDefaultsChanged && !visibilitySupportChanged && !visibilitySourceSupportChanged && + nextSleptChats === s.explicitlySleptChatsByWorkspace && removedWorktreeIds.size === 0 ) { return s diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts index 673d2760bd2..85bf7bce901 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts @@ -25,6 +25,7 @@ function buildState(): AppState { layoutByWorktree: { [SURVIVING_ID]: 'grid' }, groupsByWorktree: {}, unifiedTabsByWorktree: {}, + explicitlySleptChatsByWorkspace: {}, // Tab-keyed maps with no entry for the removed worktree's tabs. terminalLayoutsByTabId: { 'other-tab': 'single' }, ptyIdsByTabId: {}, @@ -65,6 +66,7 @@ describe('removeWorktree map identity', () => { 'layoutByWorktree', 'groupsByWorktree', 'unifiedTabsByWorktree', + 'explicitlySleptChatsByWorkspace', 'terminalLayoutsByTabId', 'ptyIdsByTabId', 'expandedPaneByTabId', @@ -79,7 +81,11 @@ describe('removeWorktree map identity', () => { const before = buildState() Object.assign(before, { gitStatusByWorktree: { [REMOVED_ID]: 'dirty', [SURVIVING_ID]: 'clean' }, - terminalLayoutsByTabId: { 'removed-tab': 'single', 'other-tab': 'single' } + terminalLayoutsByTabId: { 'removed-tab': 'single', 'other-tab': 'single' }, + explicitlySleptChatsByWorkspace: { + [`local|${REMOVED_ID}`]: [{ tabId: 'removed-tab', sessionId: 's' }], + [`local|${SURVIVING_ID}`]: [{ tabId: 'other-tab', sessionId: 's' }] + } }) const after = removeWorktree(before) @@ -88,6 +94,9 @@ describe('removeWorktree map identity', () => { expect(after.gitStatusByWorktree).toEqual({ [SURVIVING_ID]: 'clean' }) expect(after.terminalLayoutsByTabId).toEqual({ 'other-tab': 'single' }) expect(after.tabsByWorktree).toEqual({ [SURVIVING_ID]: [] }) + expect(after.explicitlySleptChatsByWorkspace).toEqual({ + [`local|${SURVIVING_ID}`]: [{ tabId: 'other-tab', sessionId: 's' }] + }) }) it('drops sleeping records written for the removed worktree after its terminals stopped (#24784)', () => { diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index e180780a6b1..783e52b10da 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -7,6 +7,7 @@ import { forgetAmbiguousOwnerWarnings } from '@/lib/ambiguous-owner-warning' import { omitRecordKeys } from './record-key-omission' import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle' import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission' +import { withoutSleptChatsForWorkspaces } from '../../../../../../shared/workspace-chat-sleep' /** Clears worktree-owned renderer state after successful removal so a reused identity cannot inherit stale preferences. */ export function applyRemoveWorktreeSuccessState( @@ -88,6 +89,10 @@ export function applyRemoveWorktreeSuccessState( recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree), rightSidebarExplorerViewByWorktree: omitByWorktree(s.rightSidebarExplorerViewByWorktree), pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree), + explicitlySleptChatsByWorkspace: withoutSleptChatsForWorkspaces( + s.explicitlySleptChatsByWorkspace, + [{ id: worktreeId, hostId: executionHostId }] + ), editorDrafts: omitByFileId(s.editorDrafts), markdownViewMode: omitByFileId(s.markdownViewMode), markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride), diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 3a73578c0b3..d18d86f2740 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -11,6 +11,7 @@ import { omitRecordKeys } from './record-key-omission' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '@/lib/ambiguous-owner-warning' import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' +import { withoutSleptChatsForWorkspaces } from '../../../../../../shared/workspace-chat-sleep' import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle' import { cancelStructuredLaunchesForWorktrees, @@ -59,6 +60,10 @@ export function buildWorktreePurgeState( deleteStateByWorktreeId: omitRecordKeys( next.deleteStateByWorktreeId, hostKeys.map((target) => composeWorktreeHostIdentity(target.hostId, target.id)) + ), + explicitlySleptChatsByWorkspace: withoutSleptChatsForWorkspaces( + next.explicitlySleptChatsByWorkspace, + hostKeys ) } } @@ -152,6 +157,10 @@ export function buildFullWorktreePurgeState( pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree), rightSidebarTabByWorktree: pruneRightSidebarTabByWorktree(), rightSidebarExplorerViewByWorktree: omitByWorktree(s.rightSidebarExplorerViewByWorktree ?? {}), + explicitlySleptChatsByWorkspace: withoutSleptChatsForWorkspaces( + s.explicitlySleptChatsByWorkspace, + [...worktreeIdSet].map((id) => ({ id })) + ), // Git status caches gitStatusByWorktree: omitByWorktree(s.gitStatusByWorktree), // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (upstream-status entry). diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index 06eb392f30c..6a042a268ce 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -18,6 +18,7 @@ export type WorkspaceHydrationPatch = Pick< | 'tabsByWorktree' | 'worktreesByRepo' | 'lastVisitedAtByWorktreeId' + | 'explicitlySleptChatsByWorkspace' | 'defaultTerminalTabsAppliedByWorktreeId' | 'closedTerminalTabTombstonesByTabId' | 'automaticAgentResumeClaimsByTabId' @@ -165,6 +166,8 @@ export function targetScopedWorkspaceHydrationPatch( hydrated.lastVisitedAtByWorktreeId, workspaceKeys ), + // Client-only global intent is loaded once from the local desktop, never from a host pull. + explicitlySleptChatsByWorkspace: state.explicitlySleptChatsByWorkspace, // Why local marks win: the applied mark is write-once. defaultTerminalTabsAppliedByWorktreeId: { ...replaceHydratedRecordKeys( diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 00856998b6d..60cd5c928ce 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -168,6 +168,8 @@ export function createWorkspaceTerminalHydrationActions( worktreesByRepo, // Why: restore the focus-recency map; pruning is deferred to App.tsx (post-hydration) because SSH worktrees may still be appearing in worktreesByRepo. lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {}, + explicitlySleptChatsByWorkspace: + session.explicitlySleptChatsByWorkspace ?? s.explicitlySleptChatsByWorkspace, // Why union: a persist snapshot that omits the write-once marker is a writer that never // stamped it, not a report that default tabs were never applied (#18117). defaultTerminalTabsAppliedByWorktreeId: { diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index a788f246bab..8af3fa4ca5d 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -70,6 +70,7 @@ export type Tab = { isPinned?: boolean // pinned tabs survive "close others" /** Provider backing a structured agent-session tab. */ agentSessionAgent?: AgentType + /** When Sleep stopped this chat's agent; activating its workspace clears it. Client-only. */ /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the * native chat view as an overlay while the live terminal stays mounted * underneath; `'terminal'` (the default for legacy/missing) shows the raw diff --git a/src/shared/workspace-chat-sleep-schema.ts b/src/shared/workspace-chat-sleep-schema.ts new file mode 100644 index 00000000000..8f97290292c --- /dev/null +++ b/src/shared/workspace-chat-sleep-schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' + +export const explicitlySleptChatsByWorkspaceSchema = salvagingRecord( + z.string(), + salvagingArray(z.object({ tabId: z.string().min(1), sessionId: z.string().min(1) })) +) + +export const workspaceChatSleepSessionFieldSchema = { + explicitlySleptChatsByWorkspace: salvagedOptional( + 'explicitlySleptChatsByWorkspace', + explicitlySleptChatsByWorkspaceSchema + ) +} diff --git a/src/shared/workspace-chat-sleep.test.ts b/src/shared/workspace-chat-sleep.test.ts new file mode 100644 index 00000000000..b3a76d63ba3 --- /dev/null +++ b/src/shared/workspace-chat-sleep.test.ts @@ -0,0 +1,31 @@ +import { expect, it } from 'vitest' +import type { Tab } from './tab-types' +import { withoutReplacedSleptChats } from './workspace-chat-sleep' + +it('retires only a replaced host chat and keeps absent disconnected chats', () => { + const workspace = 'repo::/same' + const intents = { + [`runtime:a|${workspace}`]: [{ tabId: 'same', sessionId: 'old' }], + [`runtime:b|${workspace}`]: [{ tabId: 'same', sessionId: 'other' }], + [`runtime:c|${workspace}`]: [{ tabId: 'absent', sessionId: 'offline' }] + } + const replacement: Tab = { + id: 'same', + entityId: 'new', + executionHostId: 'runtime:a', + worktreeId: workspace, + groupId: 'group', + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + + expect(withoutReplacedSleptChats(intents, { [workspace]: [replacement] }, workspace)).toEqual({ + [`runtime:b|${workspace}`]: intents[`runtime:b|${workspace}`], + [`runtime:c|${workspace}`]: intents[`runtime:c|${workspace}`] + }) +}) diff --git a/src/shared/workspace-chat-sleep.ts b/src/shared/workspace-chat-sleep.ts new file mode 100644 index 00000000000..956c7191167 --- /dev/null +++ b/src/shared/workspace-chat-sleep.ts @@ -0,0 +1,131 @@ +import type { ExecutionHostId } from './execution-host' +import type { Tab } from './tab-types' +import type { WorkspaceSessionState } from './workspace-session-state-types' +import { + composeWorktreeHostIdentity, + getExecutionHostIdFromWorktreeHostIdentity, + getWorktreeIdFromHostIdentity +} from './worktree/host-qualified-identity' + +export type SleptChatIdentity = { tabId: string; sessionId: string } +export type ExplicitlySleptChatsByWorkspace = Record + +export function sleptChatWorkspaceKey(worktreeId: string, hostId: ExecutionHostId): string { + return composeWorktreeHostIdentity(hostId, worktreeId) +} + +export function isChatExplicitlySlept(intents: ExplicitlySleptChatsByWorkspace, tab: Tab): boolean { + if (!tab.executionHostId || tab.contentType !== 'agent-session') { + return false + } + return ( + intents[sleptChatWorkspaceKey(tab.worktreeId, tab.executionHostId)]?.some( + (entry) => entry.tabId === tab.id && entry.sessionId === tab.entityId + ) ?? false + ) +} + +export function withoutSleptChatsForWorkspaces( + intents: ExplicitlySleptChatsByWorkspace, + targets: readonly { id: string; hostId?: ExecutionHostId }[] +): ExplicitlySleptChatsByWorkspace { + const next = { ...intents } + let changed = false + for (const key of Object.keys(next)) { + if ( + targets.some( + (target) => + target.id === getWorktreeIdFromHostIdentity(key) && + (!target.hostId || key === sleptChatWorkspaceKey(target.id, target.hostId)) + ) + ) { + delete next[key] + changed = true + } + } + return changed ? next : intents +} + +export function withoutSleptChatsForHosts( + intents: ExplicitlySleptChatsByWorkspace, + hosts: ReadonlySet +): ExplicitlySleptChatsByWorkspace { + let next = intents + for (const key of Object.keys(intents)) { + const hostId = getExecutionHostIdFromWorktreeHostIdentity(key) + if (!hostId || !hosts.has(hostId)) { + continue + } + next = next === intents ? { ...intents } : next + delete next[key] + } + return next +} + +export function withoutReplacedSleptChats( + intents: ExplicitlySleptChatsByWorkspace, + tabsByWorktree: Record, + worktreeId: string +): ExplicitlySleptChatsByWorkspace { + const tabs = tabsByWorktree[worktreeId] + if (!tabs) { + return intents + } + let next = intents + for (const [key, entries] of Object.entries(intents)) { + if (getWorktreeIdFromHostIdentity(key) !== worktreeId) { + continue + } + const hostId = getExecutionHostIdFromWorktreeHostIdentity(key) + const retained = entries.filter((entry) => { + const tab = tabs.find( + (candidate) => candidate.id === entry.tabId && candidate.executionHostId === hostId + ) + if (!tab) { + return true + } + return tab.entityId === entry.sessionId + }) + if (retained.length === entries.length) { + continue + } + next = next === intents ? { ...intents } : next + if (retained.length) { + next[key] = retained + } else { + delete next[key] + } + } + return next +} + +export function withRenamedSleptChatWorkspace( + intents: ExplicitlySleptChatsByWorkspace, + oldId: string, + newId: string +): ExplicitlySleptChatsByWorkspace { + let next = intents + for (const [key, entries] of Object.entries(intents)) { + if (getWorktreeIdFromHostIdentity(key) !== oldId) { + continue + } + next = next === intents ? { ...intents } : next + next[`${key.slice(0, key.length - oldId.length)}${newId}`] = entries + delete next[key] + } + return next +} + +export function renameSleptChatWorkspaceInSession( + session: WorkspaceSessionState, + oldId: string, + newId: string +): boolean { + const current = session.explicitlySleptChatsByWorkspace + if (!current) { + return false + } + const renamed = withRenamedSleptChatWorkspace(current, oldId, newId) + session.explicitlySleptChatsByWorkspace = renamed + return renamed !== current +} diff --git a/src/shared/workspace-layout/workspace-layout-beside.ts b/src/shared/workspace-layout/workspace-layout-beside.ts index 0fa036ddd2b..20ba3511723 100644 --- a/src/shared/workspace-layout/workspace-layout-beside.ts +++ b/src/shared/workspace-layout/workspace-layout-beside.ts @@ -43,6 +43,7 @@ export const SESSION_FIELD_OWNERS = { lastVisitedAtByWorktreeId: 'view', defaultTerminalTabsAppliedByWorktreeId: 'layout', sleepingAgentSessionsByPaneKey: 'layout', + explicitlySleptChatsByWorkspace: 'view', terminalPtyIncarnationsByPaneKey: 'layout', terminalTopologyRevisionByRepoId: 'layout', // Applied and cleared by the Loader; never written again. diff --git a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts index ce8523166aa..6bf8d5332ba 100644 --- a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts +++ b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts @@ -149,6 +149,9 @@ function populate(loaded: LoadedWorkspaceLayout): LoadedWorkspaceLayout { fill(loaded, 'tab.agentSessionAgent', 'claude', terminal) fill(loaded, 'view.lastFocusedAt', { [GIT_KEY]: { 'tab-agent': 1, 'browser-1': 2 } }) fill(loaded, 'view.markdownFrontmatterVisible', { '/doc.md': true }) + fill(loaded, 'view.explicitlySleptChatsByWorkspace', { + [`local|${GIT_KEY}`]: [{ tabId: 'tab-agent', sessionId: 's' }] + }) fill(loaded, 'carried.activeConnectionIdsAtShutdown', ['target-1', 'target-2']) fill(loaded, 'carried.browserUrlHistory', [{ url: 'a' }, { url: 'b' }]) fill(loaded, 'carried.workspaceDocHistory', [{ path: 'a' }, { path: 'b' }]) diff --git a/src/shared/workspace-layout/workspace-layout-round-trip.test.ts b/src/shared/workspace-layout/workspace-layout-round-trip.test.ts index 96567a9fe64..5f6500741ff 100644 --- a/src/shared/workspace-layout/workspace-layout-round-trip.test.ts +++ b/src/shared/workspace-layout/workspace-layout-round-trip.test.ts @@ -37,6 +37,18 @@ const PROFILES = WORKSPACE_LAYOUT_PROFILES.map( ) describe('workspace layout Loader and Serializer', () => { + it('round trips explicit chat Sleep as desktop view state', () => { + const stored = localDesktopSession() + stored.explicitlySleptChatsByWorkspace = { + 'local|repo-1::/Users/dev/orca': [{ tabId: 'agent-session-1', sessionId: 'agent-session-1' }] + } + const { loaded, saved } = roundTrip(LOCAL_EXECUTION_HOST_ID, stored) + expect(loaded.desktopView.explicitlySleptChatsByWorkspace).toEqual( + stored.explicitlySleptChatsByWorkspace + ) + expect(JSON.stringify(loaded.layout)).not.toContain('explicitlySleptChatsByWorkspace') + expect(saved.explicitlySleptChatsByWorkspace).toEqual(stored.explicitlySleptChatsByWorkspace) + }) it.each(PROFILES)( '%s: save(load(stored)) writes exactly what was stored', (_name, hostId, build) => { diff --git a/src/shared/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts index ce8ce0f75b3..acf1ecc32e1 100644 --- a/src/shared/workspace-session-host-field-ownership.ts +++ b/src/shared/workspace-session-host-field-ownership.ts @@ -51,6 +51,7 @@ export const WORKSPACE_SESSION_FIELD_OWNERSHIP = { browserPagesByWorkspace: 'browserWorkspaceKeyed', markdownFrontmatterVisible: 'fileKeyed', sleepingAgentSessionsByPaneKey: 'sleepingAgentKeyed', + explicitlySleptChatsByWorkspace: 'global', terminalPtyIncarnationsByPaneKey: 'paneKeyed', // Why: this host-issued fence must never collide while unified renderer state merges equal repo ids across hosts. terminalTopologyRevisionByRepoId: 'hostPrivate', @@ -102,7 +103,8 @@ export const GLOBAL_WORKSPACE_SESSION_FIELDS = ( */ export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [ 'browserUrlHistory', - 'workspaceDocHistory' + 'workspaceDocHistory', + 'explicitlySleptChatsByWorkspace' ] as const satisfies readonly (keyof WorkspaceSessionState)[] /** Serialize-side sweep over every non-local partition. The load path re-seeds these fields at diff --git a/src/shared/workspace-session-schema-field-coverage.test.ts b/src/shared/workspace-session-schema-field-coverage.test.ts index c7001060b7b..8b8e9ae88ad 100644 --- a/src/shared/workspace-session-schema-field-coverage.test.ts +++ b/src/shared/workspace-session-schema-field-coverage.test.ts @@ -51,6 +51,7 @@ const PERSISTED_WORKSPACE_SESSION_FIELDS = { lastVisitedAtByWorktreeId: true, defaultTerminalTabsAppliedByWorktreeId: true, sleepingAgentSessionsByPaneKey: true, + explicitlySleptChatsByWorkspace: true, terminalPtyIncarnationsByPaneKey: true, terminalTopologyRevisionByRepoId: true, terminalSurfaceTombstonesByPaneKey: true, @@ -104,6 +105,20 @@ describe('workspaceSessionStateSchema field coverage', () => { }) }) + it('loads explicit chat sleep intent without a tab layout field', () => { + const parsed = parseWorkspaceSession({ + ...MINIMAL_SESSION, + explicitlySleptChatsByWorkspace: { + 'local|repo:wt-1': [{ tabId: 'chat-tab', sessionId: 'session-1' }] + } + }) + + expect(parsed.ok).toBe(true) + expect(parsed.ok && parsed.value.explicitlySleptChatsByWorkspace).toEqual({ + 'local|repo:wt-1': [{ tabId: 'chat-tab', sessionId: 'session-1' }] + }) + }) + it('salvages a malformed tombstone entry rather than dropping the whole map', () => { const parsed = parseWorkspaceSession({ ...MINIMAL_SESSION, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 73fc668fd40..55551fcd30d 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -33,6 +33,7 @@ import { clientHostedBrowserCloseIntentSchema } from './client-hosted-browser-cl import { persistedClientHostedBrowserPageSchema } from './client-hosted-browser-page-record' import { persistedOpenFileSchema } from './workspace-session-editor-schema' import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents' +import { workspaceChatSleepSessionFieldSchema } from './workspace-chat-sleep-schema' import { tabContentTypeSchema, workspaceVisibleTabTypeSchema @@ -309,6 +310,7 @@ export const workspaceSessionStateSchema: z.ZodType = z.o 'sleepingAgentSessionsByPaneKey', sleepingAgentSessionsByPaneKeySchema ), + ...workspaceChatSleepSessionFieldSchema, terminalPtyIncarnationsByPaneKey: salvagedOptional( 'terminalPtyIncarnationsByPaneKey', salvagingRecord(z.string(), z.string().min(1).max(128)) diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index bd2bb3fb38a..c255524f1f1 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -8,6 +8,7 @@ import type { WorkspaceDocHistoryEntry } from './workspace-doc-history' import type { ClientHostedBrowserCloseIntent } from './client-hosted-browser-close-intent' import type { PersistedClientHostedBrowserPage } from './client-hosted-browser-page-record' import type { ClosedTerminalTabTombstonesByTabId } from './closed-terminal-tab-tombstones' +import type { ExplicitlySleptChatsByWorkspace } from './workspace-chat-sleep' /** Minimal subset of OpenFile persisted across restarts. * Only edit-mode files are saved — diffs, conflict reviews, and other @@ -132,6 +133,8 @@ export type WorkspaceSessionState = { defaultTerminalTabsAppliedByWorktreeId?: Record /** Provider-session resume records captured when workspaces sleep. */ sleepingAgentSessionsByPaneKey?: Record + /** Client view intent from explicit Sleep, keyed by execution host and workspace. */ + explicitlySleptChatsByWorkspace?: ExplicitlySleptChatsByWorkspace /** Host-issued process incarnation for each durable terminal surface. */ terminalPtyIncarnationsByPaneKey?: Record /** Monotonic host authority watermark for terminal membership in each repo. */ diff --git a/src/shared/wsl-move/wsl-move-dispositions.ts b/src/shared/wsl-move/wsl-move-dispositions.ts index 09a9ef64533..9027d895ef9 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.ts @@ -135,6 +135,10 @@ export const WORKSPACE_SESSION_DISPOSITIONS = { browserPagesByWorkspace: moveDisposition(29), markdownFrontmatterVisible: moveDisposition(28), sleepingAgentSessionsByPaneKey: moveDisposition(33), + explicitlySleptChatsByWorkspace: notApplicableDisposition( + 32, + 'Explicit Sleep intent belongs to the old execution host.' + ), terminalPtyIncarnationsByPaneKey: { disposition: 'drop-pty-binding', item: 36 From 86df5e4d91d6a6f4ba6ad65303035d61181bf3ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:23:33 -0700 Subject: [PATCH 02/19] fix(sidebar): a wake during Sleep's teardown keeps the workspace awake --- .../sidebar/sleep-worktree-admission.test.ts | 37 +++++++++++++- .../components/sidebar/sleep-worktree-flow.ts | 51 +++++++++++++------ .../worktrees/session/set-active-worktree.ts | 4 ++ .../session/workspace-chat-sleep-attempt.ts | 24 +++++++++ 4 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep-attempt.ts diff --git a/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts index d6f28753d44..e237e70d720 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts @@ -30,6 +30,7 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) import { runSleepWorktrees } from './sleep-worktree-flow' +import type { Tab } from '../../../../shared/tab-types' const ids = ['repo1::/one', 'repo1::/two'] beforeEach(() => { @@ -49,7 +50,7 @@ beforeEach(() => { mocks.terminal.mockResolvedValue(undefined) mocks.suspend.mockResolvedValue(null) vi.stubGlobal('window', { - api: { ephemeralVm: { suspendWorkspace: mocks.suspend }, pty: { kill: vi.fn() } } + api: { ephemeralVm: { suspendWorkspace: mocks.suspend }, pty: { kill: vi.fn() }, gh: {} } }) }) @@ -150,3 +151,37 @@ it('keeps the real chat-only twin refusal after synchronous browser cleanup', as expect(mocks.terminal).not.toHaveBeenCalled() expect(mocks.toast).toHaveBeenCalledOnce() }) + +it.each([ + { wake: false, completed: true }, + { wake: true, completed: false } +])('an open during Sleep teardown keeps the chat awake: %o', async ({ wake, completed }) => { + const chat: Tab = { + id: 'chat-tab', + entityId: 'chat', + groupId: 'g', + worktreeId: ids[0], + contentType: 'agent-session', + agentSessionAgent: 'claude', + executionHostId: 'runtime:env-a', + label: 'chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + store.setState({ unifiedTabsByWorktree: { [ids[0]]: [chat] } }) + const terminal = Promise.withResolvers() + mocks.terminal.mockReturnValue(terminal.promise) + const sleeping = runSleepWorktrees([ids[0]]) + if (wake) { + store.getState().setActiveWorktree(ids[0]) + store.getState().setActiveWorktree(ids[1]) + // The terminal reconnect barrier still holds until its teardown settles. + expect(hasWorktreeSleepIntent(ids[0])).toBe(true) + } + terminal.resolve() + const results = await sleeping + expect(Object.keys(store.getState().explicitlySleptChatsByWorkspace).length).toBe(wake ? 0 : 1) + expect(results).toEqual([expect.objectContaining({ stoppedSessionIds: ['chat'], completed })]) +}) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index a17c84408fe..c9e2efd5428 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -16,6 +16,11 @@ import { type WorkspaceChatStopReceipt } from './sleep-workspace-chat-stop' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' +import { + beginChatSleepAttempt, + endChatSleepAttempt, + isCurrentChatSleepAttempt +} from '@/store/slices/worktrees/session/workspace-chat-sleep-attempt' /** One per host asked to stop the workspace's chats. */ export type WorkspaceSleepResult = { @@ -173,8 +178,20 @@ export async function sleepWorktreesReportingFailures( const failedWorktreeIds = new Set() // Why apart from failedWorktreeIds: stopped terminals stay stopped when only a chat stop fails. const chatFailedIds = new Set() + const chatSleepAttempts = new Map() + // Why not the marker alone: it outlives an open during teardown, and that open wakes the chats. + const isStillAsleep = (worktreeId: string): boolean => { + const attempt = chatSleepAttempts.get(worktreeId) + return ( + hasWorktreeSleepIntent(worktreeId) && + (!attempt || isCurrentChatSleepAttempt(worktreeId, attempt)) + ) + } const attempts = worktreeIds.map((worktreeId) => { markWorktreeSleepIntent(worktreeId) + if (options.recordExplicitChatSleep) { + chatSleepAttempts.set(worktreeId, beginChatSleepAttempt(worktreeId)) + } const capturedChats = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).filter( isStructuredTab ) @@ -234,13 +251,25 @@ export async function sleepWorktreesReportingFailures( results.push( ...receipts.map((receipt) => ({ workspaceId: worktreeId, ...receipt, completed: false })) ) - // Why re-read the marker: a wake during the batch keeps the workspace awake. - const completed = - !failed && !failedWorktreeIds.has(worktreeId) && hasWorktreeSleepIntent(worktreeId) - if (options.recordExplicitChatSleep && (completed || failed)) { + const completed = !failed && !failedWorktreeIds.has(worktreeId) && isStillAsleep(worktreeId) + const attempt = chatSleepAttempts.get(worktreeId) + // Why current only: a later open or Sleep owns the saved chats, even after a failed stop. + if (attempt && isCurrentChatSleepAttempt(worktreeId, attempt) && (completed || failed)) { recordExplicitChatSleep(worktreeId, completed ? receipts : null, capturedChats) } } + // Why inside try: the finally below releases the attempts this result is judged against. + return { + failedWorktreeIds: [...new Set([...failedWorktreeIds, ...chatFailedIds])], + errors: [...new Set(errors)], + results: results.map((result) => ({ + ...result, + completed: + !failedWorktreeIds.has(result.workspaceId) && + !chatFailedIds.has(result.workspaceId) && + isStillAsleep(result.workspaceId) + })) + } } finally { // Why: a failed sleep leaves the workspace awake and retryable. for (const worktreeId of failedWorktreeIds) { @@ -249,16 +278,8 @@ export async function sleepWorktreesReportingFailures( if (sleptActiveWorktreeId && failedWorktreeIds.has(sleptActiveWorktreeId)) { setActiveWorktree(sleptActiveWorktreeId) } - } - return { - failedWorktreeIds: [...new Set([...failedWorktreeIds, ...chatFailedIds])], - errors: [...new Set(errors)], - results: results.map((result) => ({ - ...result, - completed: - !failedWorktreeIds.has(result.workspaceId) && - !chatFailedIds.has(result.workspaceId) && - hasWorktreeSleepIntent(result.workspaceId) - })) + for (const [worktreeId, attempt] of chatSleepAttempts) { + endChatSleepAttempt(worktreeId, attempt) + } } } diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index ea1ce8fd654..75ce554b4c4 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -19,6 +19,7 @@ import { findKnownWorktreeById } from '../listing/detected-worktree-meta' import { resolveActivatedWorktreeSurface } from './active-worktree-surface' import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import { withoutSleptChatsForWorkspaces } from '../../../../../../shared/workspace-chat-sleep' +import { supersedeChatSleepAttempt } from './workspace-chat-sleep-attempt' import { pendingActivationTerminalPrepCancels, shouldDeferActivationTerminalPrep @@ -175,6 +176,9 @@ export function createSetActiveWorktree( // Why: any activation is an explicit wake (null is the sleep flow clearing selection). // Cleared after the set() above so a pane still waiting on the marker connects once, // in the remounted generation, instead of connecting and then being remounted. + if (worktreeId) { + supersedeChatSleepAttempt(worktreeId) + } clearWorktreeSleepIntent(worktreeId) if (worktreeId && shouldPrepareTerminalTabs) { diff --git a/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep-attempt.ts b/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep-attempt.ts new file mode 100644 index 00000000000..5433d0c3b54 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep-attempt.ts @@ -0,0 +1,24 @@ +// Why apart from the terminal sleep marker: that marker ignores wakes while teardown runs, +// but the user's latest open or Sleep must decide whether an earlier Sleep may hide chats. +const currentAttemptByWorkspaceId = new Map() + +/** A newer explicit Sleep of the same workspace replaces the older one. */ +export function beginChatSleepAttempt(workspaceId: string): symbol { + const attempt = Symbol(workspaceId) + currentAttemptByWorkspaceId.set(workspaceId, attempt) + return attempt +} + +export function supersedeChatSleepAttempt(workspaceId: string): void { + currentAttemptByWorkspaceId.delete(workspaceId) +} + +export function isCurrentChatSleepAttempt(workspaceId: string, attempt: symbol): boolean { + return currentAttemptByWorkspaceId.get(workspaceId) === attempt +} + +export function endChatSleepAttempt(workspaceId: string, attempt: symbol): void { + if (isCurrentChatSleepAttempt(workspaceId, attempt)) { + currentAttemptByWorkspaceId.delete(workspaceId) + } +} From 00afaa6f36f8b61ead24a6c1464a756eee982383 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:44:09 -0700 Subject: [PATCH 03/19] test(sidebar): give the activation-race mock store the chat tab map Sleep reads --- .../components/sidebar/sleep-worktree-activation-race.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index d5f0a8d6d56..be9b6260241 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => { worktreesByRepo: {}, detectedWorktreesByRepo: {}, tabsByWorktree: {} as Record, + unifiedTabsByWorktree: {}, ptyIdsByTabId: {} as Record } const activateAndRevealWorktree = vi.fn() From 274eff82e8b67565300b5e84b610753924dbacc8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:42:19 -0700 Subject: [PATCH 04/19] feat(native-chat): the host marks the chats an explicit Sleep stopped The fact "the user's Sleep put this chat to rest" now lives on the host that runs the chat, on its session record, instead of in the window. - New runtime capability agent-session.workspace-sleep-mark.v1 and an optional markAsleep on agentSession.sleepWorkspace (strict params, so only hosts advertising the capability are sent it). - The Sleep close decides each chat's receipt inside the same per-session step that stopped it; with markAsleep it writes sleptAt in that step's record write, so a chat is marked exactly when it is in the receipt. - The next send the host hands over clears sleptAt, whichever client sent it; the idle sweep and plain stops never mark. - sleptAt rides the status summary (optional field), its equality, and the retained summary of a closed chat, so it survives restart through the persisted tab index. --- ...tructured-agent-session-client-delivery.ts | 3 +- ...red-agent-session-conversation-lifetime.ts | 12 +- .../structured-agent-session-host-delivery.ts | 6 + .../structured-agent-session-host.ts | 10 +- ...sion-status-feed-conversation-name.test.ts | 6 +- .../structured-agent-session-status-feed.ts | 17 +-- ...d-agent-session-status-summary-equality.ts | 1 + ...structured-agent-session-status-summary.ts | 27 +++- ...red-agent-session-surface-lifetime.test.ts | 16 --- ...t-session-workspace-sleep-lifetime.test.ts | 71 +++++++++- ...tructured-agent-session-workspace-sleep.ts | 132 +++++++++++++----- .../rpc/methods/structured-agent-session.ts | 6 +- src/shared/agent-session-record.ts | 3 + src/shared/agent-session-wire.ts | 3 + src/shared/agent-session-workspace-sleep.ts | 5 + src/shared/protocol-version.ts | 2 + .../structured-agent-session-params.ts | 17 +-- 17 files changed, 249 insertions(+), 88 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index e6e139c8471..d660d4a1d8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -101,8 +101,7 @@ export class StructuredAgentSessionClientDelivery { publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId) - publishConversationName = (sessionId: string): void => - this.statusFeed.publishConversationName(sessionId) + publishRecordNotes = (sessionId: string): void => this.statusFeed.publishRecordNotes(sessionId) publishChildWork = ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index da63a25dee2..199c578916c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -114,11 +114,13 @@ export function createStructuredAgentSessionConversationLifetime(host: { return result } - const closeForWorkspaceSleep = ( + /** `receipt` runs in the same step as the stop, so nothing can run the chat between them. */ + const closeForWorkspaceSleep = ( sessionId: string, workspaceId: string, - executionHostId: ExecutionHostId - ): Promise<{ fence: number; stopped: boolean } | null> => { + executionHostId: ExecutionHostId, + receipt: (stopped: boolean) => Promise + ): Promise => { const matches = (): boolean => { const location = deps().store.getRecord(sessionId)?.location return location?.workspaceId === workspaceId && location.executionHostId === executionHostId @@ -160,9 +162,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { error }) ) - // Lease release advances the fence; preserve the value after this close for the caller's - // final identity check against a later restart. - return { fence: deps().store.getRecord(sessionId)?.lease.runtimeFence ?? fence, stopped } + return receipt(stopped) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 974fa2111ac..d4b59cc156d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -30,6 +30,7 @@ import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-se import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' import { applyStructuredAgentSessionMessageOptions } from './structured-agent-session-message-options' import type { StructuredAgentSessionMutationContext } from './structured-agent-session-mutation-context' +import { clearStructuredAgentSessionSleepMark } from './structured-agent-session-workspace-sleep' export type StructuredAgentSessionConversationDelivery = { loop: StructuredAgentSessionDeliveryLoop @@ -115,6 +116,11 @@ export function createStructuredAgentSessionConversationDelivery(input: { stopAgent: input.stopOutgrownAgent, logger: deps.logger }) + await clearStructuredAgentSessionSleepMark( + deps, + sessionId, + input.clientDelivery.publishStatus + ) return input.ensureProviderChild(sessionId, startedFor) }, readiness: input.readiness, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 1bba744c55e..678fdeca76b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -213,7 +213,7 @@ export class StructuredAgentSessionHost { this.lifetime.close(sessionId, cause) /** One bounded host-owned snapshot; a tab need not be visible for its child to be stopped. */ - sleepWorkspace = (workspaceId: string): Promise<{ stoppedSessionIds: string[] }> => + sleepWorkspace = (workspaceId: string, markAsleep?: boolean) => sleepStructuredAgentSessionWorkspace( { deps: this.deps, @@ -221,9 +221,11 @@ export class StructuredAgentSessionHost { lifetime: this.lifetime, sessions: this.sessions, deliveryRunning: (sessionId) => this.conversationDelivery.loop.isRunning(sessionId), - childWorkIncomplete: this.clientDelivery.childWorkIncomplete + childWorkIncomplete: this.clientDelivery.childWorkIncomplete, + publishRecordNotes: this.clientDelivery.publishRecordNotes }, - workspaceId + workspaceId, + markAsleep ) supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => @@ -350,7 +352,7 @@ export class StructuredAgentSessionHost { /** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */ subscribeStatus = this.clientDelivery.subscribeStatus - publishConversationName = this.clientDelivery.publishConversationName + publishConversationName = this.clientDelivery.publishRecordNotes /** Turns that settle, and prompts raised, from now on. Live-only: nothing missed is replayed. */ subscribeTurnCompletions = this.clientDelivery.subscribeTurnCompletions diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts index 5aba4871f55..5b829b2b507 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-conversation-name.test.ts @@ -46,7 +46,7 @@ async function namedFeed() { feed.publish('session') const name = (conversationName: string) => { record = { ...record, conversationName } - feed.publishConversationName('session') + feed.publishRecordNotes('session') } return { feed, events, sessions, name } } @@ -63,7 +63,7 @@ it('publishes a held chat its saved name once, and not again for an unchanged re expect(lastName(events)).toBe('Explain the parser') const count = events.length feed.publish('session') - feed.publishConversationName('session') + feed.publishRecordNotes('session') expect(events).toHaveLength(count) }) @@ -91,6 +91,6 @@ it('keeps the name on the summary a closing chat retains', async () => { it('publishes nothing for a chat this host never projected', async () => { const { feed, events } = await namedFeed() const count = events.length - feed.publishConversationName('unknown-session') + feed.publishRecordNotes('unknown-session') expect(events).toHaveLength(count) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 1dff7203f01..93ca19b0907 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -23,7 +23,10 @@ import type { StatusFeedSession, StructuredAgentSessionStatusFeedDeps } from './structured-agent-session-status-feed-deps' -import { structuredAgentSessionStatusSummary } from './structured-agent-session-status-summary' +import { + structuredAgentSessionStatusSummary, + withRecordNotes +} from './structured-agent-session-status-summary' import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, @@ -158,20 +161,18 @@ export class StructuredAgentSessionStatusFeed { }) } - /** The record's name changed outside the journal. A closed chat's retained row follows it too, - * so every list still showing that conversation learns the name without a tab. */ - publishConversationName(sessionId: string): void { + /** The record's name or Sleep mark changed outside the journal. A closed chat's retained row + * follows it too, so every list still showing that conversation learns it without a tab. */ + publishRecordNotes(sessionId: string): void { if (this.deps.sessions.has(sessionId)) { this.publish(sessionId) return } const publication = this.published.get(sessionId) - const name = this.deps.getRecord(sessionId)?.conversationName - if (!publication || publication.summary.conversationName === name) { + const summary = withRecordNotes(publication?.summary, this.deps.getRecord(sessionId)) + if (!publication || !summary) { return } - const { conversationName: _previous, ...rest } = publication.summary - const summary = name ? { ...rest, conversationName: name } : rest this.published.set(sessionId, { ...publication, summary }) this.broadcast({ type: 'status', session: summary }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 46a62a05dc4..83f93389a1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -37,6 +37,7 @@ export function structuredStatusSummariesEqual( a.lastAssistantMessage === b.lastAssistantMessage && a.launchDirectory === b.launchDirectory && a.conversationName === b.conversationName && + a.sleptAt === b.sleptAt && a.turnOutcome === b.turnOutcome && a.stopping === b.stopping && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 786fa8b246e..885649b6707 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -62,7 +62,32 @@ export function structuredAgentSessionStatusSummary({ ...childWork, ...(providerSession ? { providerSession } : {}), ...(launchDirectory ? { launchDirectory } : {}), - ...(record?.conversationName ? { conversationName: record.conversationName } : {}), + ...structuredAgentSessionRecordNotes(record), updatedAt: journal.lastActivityAt() || now() } } + +/** What the row reads from the record alone; a closed chat's retained summary re-reads it too. */ +function structuredAgentSessionRecordNotes( + record: AgentSessionRecord | null +): Pick { + return { + ...(record?.conversationName ? { conversationName: record.conversationName } : {}), + ...(typeof record?.sleptAt === 'number' ? { sleptAt: record.sleptAt } : {}) + } +} + +/** A retained summary re-read from its record; null when there is none or nothing it shows moved. */ +export function withRecordNotes( + summary: AgentSessionStatusSummary | undefined, + record: AgentSessionRecord | null +): AgentSessionStatusSummary | null { + if (!summary) { + return null + } + const { conversationName: _name, sleptAt: _sleptAt, ...rest } = summary + const next = { ...rest, ...structuredAgentSessionRecordNotes(record) } + return next.conversationName === summary.conversationName && next.sleptAt === summary.sleptAt + ? null + : next +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 0d3144dd76f..286965bd394 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -291,22 +291,6 @@ describe('a chat that closes', () => { expect(store.getRecord(SESSION)).not.toBeNull() }) - it('does not count a chat whose send was accepted after the stop as stopped', async () => { - await attach() - await vi.waitFor(() => expect(host['conversationDelivery'].loop.isRunning(SESSION)).toBe(false)) - const close = host['lifetime'].closeForWorkspaceSleep - vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { - const result = await close(...args) - const body = hostTestMessage('newer send') - // The later work exists once the send is accepted, not merely once it is queued. - const sent = host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - expect(await sent).toMatchObject({ ok: true }) - return result - }) - - expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [] }) - }) - it('stops the provider child it started', async () => { await attach() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index 5b2d8115522..1f4cfa074d9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -45,7 +45,7 @@ it('omits an unverified stop superseded by a newer send while retaining a siblin vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { if (args[0] === sibling) { await slowSibling - return { fence: siblingRecord.lease.runtimeFence, stopped: true } + return args[3](true) } const result = await close(...args) failedStopDone() @@ -163,3 +163,72 @@ it('keeps a send admitted after the host Sleep close alive', async () => { expect(close).toHaveBeenCalledOnce() expect(host.hasSession('session-alpha')).toBe(true) }) + +it('marks only the chats a Sleep that asked proved stopped, until the next send', async () => { + const { host, store, acquire } = hostTestState() + await attach() + const send = async (text: string) => { + const body = hostTestMessage(text) + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: true }) + } + // A stop that did not ask (a project move) marks nothing. + expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: ['session-alpha'] }) + expect(store.getRecord('session-alpha')?.sleptAt).toBeUndefined() + await send('after the move') + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(2)) + + expect(await host.sleepWorkspace('workspace-1', true)).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + const sleptAt = store.getRecord('session-alpha')?.sleptAt + expect(sleptAt).toEqual(expect.any(Number)) + expect(host.readStatusSummary('session-alpha')?.sleptAt).toBe(sleptAt) + + await send('wake up') + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(3)) + expect(store.getRecord('session-alpha')?.sleptAt).toBeUndefined() + expect(host.readStatusSummary('session-alpha')?.sleptAt).toBeUndefined() + + // A send accepted right after the Sleep step still wakes the chat that step marked. + const close = host['lifetime'].closeForWorkspaceSleep + vi.spyOn(host['lifetime'], 'closeForWorkspaceSleep').mockImplementation(async (...args) => { + const result = await close(...args) + await send('right after Sleep') + return result + }) + expect(await host.sleepWorkspace('workspace-1', true)).toEqual({ + stoppedSessionIds: ['session-alpha'] + }) + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(4)) + expect(store.getRecord('session-alpha')?.sleptAt).toBeUndefined() +}) + +it('marks no chat whose stop it could not prove', async () => { + const { host, store } = hostTestState() + await attach() + host.deps.adapter.closeSession = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true) + expect(await host.sleepWorkspace('workspace-1', true)).toMatchObject({ + failures: [{ sessionId: 'session-alpha', reason: 'stop-unverifiable' }] + }) + expect(store.getRecord('session-alpha')?.sleptAt).toBeUndefined() +}) + +it('restores a slept chat listed by its tab still marked after a restart', async () => { + const { host, store, root } = hostTestState() + await attach() + await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') + await host.sleepWorkspace('workspace-1', true) + const sleptAt = store.getRecord('session-alpha')?.sleptAt + expect(sleptAt).toEqual(expect.any(Number)) + + const restarted = new StructuredAgentSessionHost({ + ...host.deps, + store: await openTestAgentSessionRecordStore(root) + }) + const listed = restarted.getPersistedVisibleSessionTabIndex() + expect(listed.sessionIds).toEqual(['session-alpha']) + await restarted.restoreReadableSessions(listed.sessionIds) + expect(restarted.readStatusSummary('session-alpha')?.sleptAt).toBe(sleptAt) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 5ebbe337fcf..1ab5ce16d4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -1,4 +1,7 @@ -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' import type { AgentSessionWorkspaceSleepResult } from '../../../shared/agent-session-workspace-sleep' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -7,18 +10,23 @@ import type { StructuredAgentSessionConversations } from './structured-agent-ses import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' type WorkspaceSleepHost = { - deps: Pick + deps: Pick tasks: StructuredAgentSessionTaskQueue lifetime: Pick sessions: StructuredAgentSessionConversations deliveryRunning: (sessionId: string) => boolean childWorkIncomplete: (sessionId: string) => boolean + publishRecordNotes: (sessionId: string) => void } -/** Snapshot host-owned members and return only same-call, current positive stop receipts. */ +type WorkspaceSleepVerdict = { stopped: true } | { stopped: false; fence: number } | null + +/** Snapshot host-owned members and return only same-call, current positive stop receipts. With + * `markAsleep`, exactly the chats in the receipt carry the Sleep mark. */ export async function sleepStructuredAgentSessionWorkspace( host: WorkspaceSleepHost, - workspaceId: string + workspaceId: string, + markAsleep = false ): Promise { let records: ReturnType try { @@ -41,7 +49,12 @@ export async function sleepStructuredAgentSessionWorkspace( // Admits every close synchronously; `async` turns a synchronous throw into a rejection. const stops = await Promise.allSettled( sessionIds.map(async (sessionId) => - host.lifetime.closeForWorkspaceSleep(sessionId, workspaceId, LOCAL_EXECUTION_HOST_ID) + host.lifetime.closeForWorkspaceSleep( + sessionId, + workspaceId, + LOCAL_EXECUTION_HOST_ID, + (stopped) => settleWorkspaceSleepChat(host, sessionId, stopped, markAsleep) + ) ) ) const stoppedSessionIds: string[] = [] @@ -49,42 +62,89 @@ export async function sleepStructuredAgentSessionWorkspace( for (const [index, stop] of stops.entries()) { const sessionId = sessionIds[index] if (stop.status === 'rejected') { - failures.push({ sessionId, reason: 'stop-unverifiable' }) - continue - } - if (!stop.value) { - continue - } - try { - const record = host.deps.store.getRecord(sessionId) - if (!record || !belongs(record.location) || record.lease.runtimeFence !== stop.value.fence) { - continue - } - if (!stop.value.stopped) { - failures.push({ sessionId, reason: 'stop-unverifiable' }) - continue - } - // Judge later work by facts, not lane occupancy: the stop queues its own end report. - const session = host.sessions.get(sessionId) - if ( - session?.child || - host.tasks.hasPendingAttach(sessionId) || - host.deliveryRunning(sessionId) || - host.childWorkIncomplete(sessionId) || - session?.journal.pendingSubmissions().length || - host.deps.hasOpenDispatch?.(record) - ) { - continue - } - stoppedSessionIds.push(sessionId) - } catch (error) { - host.deps.logger.warn('workspace chat stop receipt could not be checked', { + host.deps.logger.warn('workspace chat stop receipt could not be decided', { scope: 'workspace-chat-receipt', sessionId, - error + error: stop.reason }) failures.push({ sessionId, reason: 'stop-unverifiable' }) + } else if (stop.value?.stopped) { + stoppedSessionIds.push(sessionId) + } else if ( + stop.value && + // A newer start superseded the unproven stop, so there is nothing left to report. + host.deps.store.getRecord(sessionId)?.lease.runtimeFence === stop.value.fence + ) { + failures.push({ sessionId, reason: 'stop-unverifiable' }) } } return failures.length > 0 ? { stoppedSessionIds, failures } : { stoppedSessionIds } } + +/** Decides one chat's receipt, and writes its mark, in the step that stopped it. */ +async function settleWorkspaceSleepChat( + host: WorkspaceSleepHost, + sessionId: string, + stopped: boolean, + markAsleep: boolean +): Promise { + const current = host.deps.store.getRecord(sessionId) + if (!current) { + return null + } + if (!stopped) { + return { stopped: false, fence: current.lease.runtimeFence } + } + // Judge later work by facts, not lane occupancy: the stop queues its own end report. + const quiet = (record: AgentSessionRecord): boolean => { + const session = host.sessions.get(sessionId) + return !( + session?.child || + host.tasks.hasPendingAttach(sessionId) || + host.deliveryRunning(sessionId) || + host.childWorkIncomplete(sessionId) || + session?.journal.pendingSubmissions().length || + host.deps.hasOpenDispatch?.(record) + ) + } + if (!markAsleep) { + return quiet(current) ? { stopped: true } : null + } + let marked = false + const now = host.deps.now?.() ?? Date.now() + // Judged inside the write, so the receipt and the mark cannot disagree. + await host.deps.store.transitionHandoff(sessionId, (record) => { + marked = quiet(record) + return marked ? { ...record, sleptAt: now, updatedAt: now } : record + }) + if (marked) { + host.publishRecordNotes(sessionId) + } + return marked ? { stopped: true } : null +} + +/** A send the host hands over wakes a chat Sleep put to rest, whichever client sent it. Never + * holds the send back: a failed write is only logged. */ +export async function clearStructuredAgentSessionSleepMark( + deps: Pick, + sessionId: string, + publishStatus: (sessionId: string) => void +): Promise { + if (deps.store.getRecord(sessionId)?.sleptAt === undefined) { + return + } + const now = deps.now?.() ?? Date.now() + try { + await deps.store.transitionHandoff(sessionId, ({ sleptAt: _sleptAt, ...record }) => ({ + ...record, + updatedAt: now + })) + publishStatus(sessionId) + } catch (error) { + deps.logger.warn('clearing a chat Sleep mark failed', { + scope: 'workspace-chat-wake', + sessionId, + error + }) + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 1fe96d81d0b..0c58549cb15 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -175,7 +175,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ requireStructuredCapability(ctx) const installed = getStructuredAgentSessionHost() if (installed) { - return installed.sleepWorkspace(params.workspaceId) + return installed.sleepWorkspace(params.workspaceId, params.markAsleep) } let store: Awaited> try { @@ -183,14 +183,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ } catch (error) { const appeared = getStructuredAgentSessionHost() if (appeared) { - return appeared.sleepWorkspace(params.workspaceId) + return appeared.sleepWorkspace(params.workspaceId, params.markAsleep) } console.warn('[agent-session] workspace sleep record inventory unavailable', error) return { stoppedSessionIds: [], failures: [{ reason: 'inventory-unavailable' }] } } const appeared = getStructuredAgentSessionHost() if (appeared) { - return appeared.sleepWorkspace(params.workspaceId) + return appeared.sleepWorkspace(params.workspaceId, params.markAsleep) } try { const records = store diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 554630ee241..e9163079929 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -163,6 +163,9 @@ export type AgentSessionRecord = { conversationCommand?: AgentSessionConversationCommandRecord /** The name Orca gave this conversation, so a later acquisition need not name it again. */ conversationName?: string + /** When the user's Sleep stopped this chat; the next send it hands over clears it. Not + * validated on load: a bad value only goes unpublished, and must never cost the chat. */ + sleptAt?: number launchArgs?: AgentSessionLaunchArgs lease: AgentSessionLease createdAt: number diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index d7c079a7487..4230908cb61 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -276,6 +276,9 @@ export type AgentSessionStatusSummary = { /** The record's saved conversation name; absent while unnamed and from older hosts. Rides this * feed because a retained summary outlives the chat's tab, so a closed chat keeps its name. */ conversationName?: string + /** The record's Sleep mark: the user's Sleep stopped this chat and nothing has been sent since. + * Absent from older hosts. */ + sleptAt?: number /** Host-path directory the session is held to regardless of its workspace's current directory * (a floating chat's pinned folder). Absent means resolve the workspace id; older hosts omit it. */ launchDirectory?: string diff --git a/src/shared/agent-session-workspace-sleep.ts b/src/shared/agent-session-workspace-sleep.ts index ac275d90c06..0c4ceab6c30 100644 --- a/src/shared/agent-session-workspace-sleep.ts +++ b/src/shared/agent-session-workspace-sleep.ts @@ -1,3 +1,8 @@ +// Why: `agentSession.sleepWorkspace` params are strict, so only a host advertising this is sent +// `markAsleep`; an older one stops the chats and marks nothing, leaving their workspace awake. +export const AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY = + 'agent-session.workspace-sleep-mark.v1' as const + export type AgentSessionWorkspaceSleepResult = { stoppedSessionIds: string[] failures?: { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d7c6775b7a3..6529d7eb10c 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -31,6 +31,7 @@ export { STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY } from './structured-agent-session-surface-capabilities' import { AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES } from './agent-session-attention-capabilities' +import { AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY } from './agent-session-workspace-sleep' export { AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_COMPLETION_RUNTIME_CAPABILITY, @@ -259,6 +260,7 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_PERMISSION_MODES_RUNTIME_CAPABILITY, ...STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES, ...AGENT_SESSION_ATTENTION_RUNTIME_CAPABILITIES, + AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RECOVERY_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index aece5f4ac65..bdfdaeef119 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -268,16 +268,17 @@ export const RespondToQuestionParams = z } }) -export const SetOptionParams = z - .object({ - envelope: MutationEnvelope, - key: Identifier('Invalid option key'), - value: z.string().max(MAX_OPTION_LABEL) - }) - .strict() +export const SetOptionParams = z.strictObject({ + envelope: MutationEnvelope, + key: Identifier('Invalid option key'), + value: z.string().max(MAX_OPTION_LABEL) +}) export const OptionsParams = z.object({ sessionId: SessionId }).strict() -export const SleepWorkspaceParams = z.object({ workspaceId: z.string().min(1) }).strict() +// Why optional: only a host advertising the workspace-sleep-mark capability is sent `markAsleep`. +export const SleepWorkspaceParams = z + .object({ workspaceId: z.string().min(1), markAsleep: z.literal(true).optional() }) + .strict() export const AcknowledgeAttentionParams = OptionsParams.extend({ observedCursor: JournalCursor }) /** `agentSession.agents` takes nothing: the list is the host's, whichever client asks. */ From 16e610505afee7f766a134fea826f2da16111435 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:43:03 -0700 Subject: [PATCH 05/19] refactor(sidebar): read a slept chat from its host's mark, not a window record The window no longer keeps its own saved record of which chats Sleep stopped (explicitlySleptChatsByWorkspace). That record had to mirror every host event that should end it (sends, replaced conversations, renames, profile and WSL moves, removal, wakes during teardown), and each missed mirror was a bug. The host now owns the fact (previous commit). - Delete the record, its schema, store slice, workspace-session field registrations, moves, renames, purges, migrations and hydration, and the renderer clearing hooks. Those files are back to main. - Opening a workspace no longer wakes its slept chats (product ruling): drop the chat Sleep-attempt tokens and their set-active-worktree hook. A slept chat wakes only when a send reaches its host, from any client. - The status bridge mirrors each chat tab's published sleptAt into a store map keyed by the chat's pane key, dropped when its projection unmounts and never saved; a chat whose host runs it again is not slept. - The asleep derivation, hide filter, palette, board and context menu read that map. Explicit Sleep sends markAsleep only to the local host and to hosts last probed with the new capability; a project move never does. --- ...-project-session-field-disposition.test.ts | 2 - ...ofile-project-session-field-disposition.ts | 4 - .../profile-project-session-state.test.ts | 56 -------- .../profile-project-session-state.ts | 8 -- .../orcad-source-release-upgrade.test.ts | 4 - .../orcad-source-workspace-session.ts | 2 - .../session-owner-fields.test.ts | 13 +- .../session-owner-fields.ts | 26 ++-- .../session-worktree-ownership.ts | 2 - ...-identity-migration-field-coverage.test.ts | 15 -- .../worktree-identity-migration.ts | 3 - ...tructuredAgentSessionStatusBridge.test.tsx | 34 ++--- .../StructuredAgentSessionStatusBridge.tsx | 24 +--- .../sidebar/sleep-workspace-chat-stop.ts | 27 +++- .../sleep-worktree-activation-race.test.ts | 1 - .../sidebar/sleep-worktree-admission.test.ts | 37 +---- .../sidebar/sleep-worktree-flow.test.ts | 92 ++++-------- .../components/sidebar/sleep-worktree-flow.ts | 99 +++---------- ...e-visible-workspace-kanban-worktree-ids.ts | 2 +- .../use-worktree-context-menu-model.tsx | 10 +- .../sidebar/use-worktree-sleep-state.test.tsx | 25 ++-- .../sidebar/use-worktree-sleep-state.ts | 6 +- .../visible-worktree-activity-inputs.ts | 33 +++-- .../visible-worktree-options-from-state.ts | 2 +- .../workspace-lineage-menu-actions.test.ts | 21 +-- .../sidebar/workspace-lineage-menu-actions.ts | 20 +-- .../sidebar/worktree-context-menu-policy.ts | 2 +- .../worktree-list/listing/use-filters.ts | 2 +- .../listing/use-visible-worktrees.ts | 2 +- .../use-worktree-jump-palette-store-state.ts | 6 +- .../use-worktree-jump-palette-worktrees.ts | 16 ++- .../lib/workspace-session-host-split.test.ts | 3 - .../src/lib/workspace-session-patch.ts | 3 - .../workspace-session-relevant-fields.test.ts | 1 - src/renderer/src/lib/workspace-session.ts | 13 +- .../src/lib/worktree-activity-state.ts | 18 +-- .../apply-final-patch.ts | 10 -- .../runtime/web-session-tabs-sync/state.ts | 1 - src/renderer/src/store/index.ts | 2 + .../store/slices/agent-status-live-facts.ts | 2 - .../purge-stale-runtime-host-state.test.ts | 16 --- .../src/store/slices/store-test-helpers.ts | 2 + .../store/slices/structured-chat-slept-at.ts | 40 ++++++ .../store/slices/tabs/tabs-close-actions.ts | 23 --- .../store/slices/tabs/tabs-session-actions.ts | 53 +++---- .../src/store/slices/worktree-helpers.ts | 15 -- ...orktrees-workspace-selection-state.test.ts | 39 ------ src/renderer/src/store/slices/worktrees.ts | 2 - .../worktrees/session/set-active-worktree.ts | 15 +- .../session/workspace-chat-sleep-attempt.ts | 24 ---- .../worktrees/session/workspace-chat-sleep.ts | 81 ----------- .../session/worktree-identity-rename-state.ts | 10 -- .../session/worktree-slice-initial-state.ts | 2 - .../purge-stale-runtime-host-state.ts | 18 +-- .../remove-worktree-map-identity.test.ts | 11 +- .../teardown/remove-worktree-store-cleanup.ts | 5 - .../teardown/worktree-purge-state.ts | 9 -- .../workspace-terminal-hydration-patch.ts | 3 - .../terminals/workspace-terminal-hydration.ts | 2 - src/renderer/src/store/types.ts | 2 + src/shared/tab-types.ts | 1 - src/shared/workspace-chat-sleep-schema.ts | 14 -- src/shared/workspace-chat-sleep.test.ts | 31 ----- src/shared/workspace-chat-sleep.ts | 131 ------------------ .../workspace-layout-beside.ts | 1 - .../workspace-layout-fact-locations.test.ts | 3 - .../workspace-layout-round-trip.test.ts | 12 -- .../workspace-session-host-field-ownership.ts | 4 +- ...pace-session-schema-field-coverage.test.ts | 15 -- src/shared/workspace-session-schema.ts | 2 - src/shared/workspace-session-state-types.ts | 3 - src/shared/wsl-move/wsl-move-dispositions.ts | 4 - 72 files changed, 251 insertions(+), 991 deletions(-) create mode 100644 src/renderer/src/store/slices/structured-chat-slept-at.ts delete mode 100644 src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep-attempt.ts delete mode 100644 src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep.ts delete mode 100644 src/shared/workspace-chat-sleep-schema.ts delete mode 100644 src/shared/workspace-chat-sleep.test.ts delete mode 100644 src/shared/workspace-chat-sleep.ts diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts index 0fc4b40901c..ce509767119 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.test.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.test.ts @@ -261,7 +261,6 @@ describe('workspace session field disposition census', () => { 'activeGroupIdByWorktree', 'lastVisitedAtByWorktreeId', 'defaultTerminalTabsAppliedByWorktreeId', - 'explicitlySleptChatsByWorkspace', 'terminalTopologyRevisionByRepoId' ]) }) @@ -276,7 +275,6 @@ describe('workspace session field disposition census', () => { 'activeGroupIdByWorktree', 'lastVisitedAtByWorktreeId', 'defaultTerminalTabsAppliedByWorktreeId', - 'explicitlySleptChatsByWorkspace', 'terminalTopologyRevisionByRepoId' ]) }) diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 933c3d5e0b9..219ef63d8ca 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -117,10 +117,6 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { // the record's worktreeId on worktree and project removal. Moving a project between profiles runs // removeSourceRepo, which has no owner scan, so these records leak there. sleepingAgentSessionsByPaneKey: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, - explicitlySleptChatsByWorkspace: { - onRepoRemoval: 'prunedByOwnerKey', - onTransfer: 'copiedByOwnerKey' - }, terminalPtyIncarnationsByPaneKey: { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' diff --git a/src/main/orca-profiles/profile-project-session-state.test.ts b/src/main/orca-profiles/profile-project-session-state.test.ts index 2ba58bec114..1d9a64870c3 100644 --- a/src/main/orca-profiles/profile-project-session-state.test.ts +++ b/src/main/orca-profiles/profile-project-session-state.test.ts @@ -6,7 +6,6 @@ import { removeRepoFromWorkspaceSession } from './profile-project-session-state' import { extractSessionForTransfer } from './profile-project-session-transfer' -import { isChatExplicitlySlept, sleptChatWorkspaceKey } from '../../shared/workspace-chat-sleep' const REMOVED_WORKTREE_ID = 'repo-a::/removed' const RETAINED_WORKTREE_ID = 'repo-b::/retained' @@ -14,61 +13,6 @@ const REMOVED_REPO_ID = 'repo-a' const RETAINED_REPO_ID = 'repo-b' describe('profile project session state', () => { - it('keeps exact chat Sleep choices when a moved project needs a new repo id', () => { - const movedWorktreeId = 'repo-c::/removed' - const retainedKey = sleptChatWorkspaceKey(RETAINED_WORKTREE_ID, 'local') - const localKey = sleptChatWorkspaceKey(movedWorktreeId, 'local') - const remoteKey = sleptChatWorkspaceKey(movedWorktreeId, 'ssh:profile-test') - const tabs = (['local', 'ssh:profile-test'] as const).map((executionHostId, sortOrder) => ({ - id: `chat-${sortOrder}`, - entityId: `session-${sortOrder}`, - groupId: 'group', - worktreeId: REMOVED_WORKTREE_ID, - executionHostId, - contentType: 'agent-session' as const, - label: 'Chat', - customLabel: null, - color: null, - sortOrder, - createdAt: 1 - })) - const source: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - unifiedTabs: { [REMOVED_WORKTREE_ID]: tabs }, - explicitlySleptChatsByWorkspace: Object.fromEntries( - tabs.map((tab) => [ - sleptChatWorkspaceKey(REMOVED_WORKTREE_ID, tab.executionHostId), - [{ tabId: tab.id, sessionId: tab.entityId }] - ]) - ) - } - const retained = [{ tabId: 'retained-chat', sessionId: 'retained-session' }] - const target = { - ...getDefaultWorkspaceSession(), - explicitlySleptChatsByWorkspace: { - [retainedKey]: retained, - [localKey]: [{ tabId: 'old-chat', sessionId: 'old-session' }] - } - } - const result = mergeWorkspaceSessions( - target, - extractSessionForTransfer(source, REMOVED_REPO_ID, 'repo-c') - ) - - expect(result.explicitlySleptChatsByWorkspace).toEqual({ - [retainedKey]: retained, - [localKey]: [{ tabId: 'chat-0', sessionId: 'session-0' }], - [remoteKey]: [{ tabId: 'chat-1', sessionId: 'session-1' }] - }) - const movedTabs = result.unifiedTabs?.[movedWorktreeId] ?? [] - expect(movedTabs).toHaveLength(2) - expect( - movedTabs.every((tab) => - isChatExplicitlySlept(result.explicitlySleptChatsByWorkspace ?? {}, tab) - ) - ).toBe(true) - }) - it('keeps topology revisions monotonic while merging session authority records', () => { const base = { ...getDefaultWorkspaceSession(), diff --git a/src/main/orca-profiles/profile-project-session-state.ts b/src/main/orca-profiles/profile-project-session-state.ts index 744ab8e194c..b1ccc6c488f 100644 --- a/src/main/orca-profiles/profile-project-session-state.ts +++ b/src/main/orca-profiles/profile-project-session-state.ts @@ -123,14 +123,6 @@ export function mergeWorkspaceSessions( } } : {}), - ...(base.explicitlySleptChatsByWorkspace || incoming.explicitlySleptChatsByWorkspace - ? { - explicitlySleptChatsByWorkspace: { - ...base.explicitlySleptChatsByWorkspace, - ...incoming.explicitlySleptChatsByWorkspace - } - } - : {}), activeWorktreeIdsOnShutdown: [ ...(base.activeWorktreeIdsOnShutdown ?? []), ...(incoming.activeWorktreeIdsOnShutdown ?? []) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts index 039ea5654a3..1b1d7a39b87 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-release-upgrade.test.ts @@ -235,13 +235,9 @@ describe('migrating saved state from released SSH clients', () => { const { state, local, host } = split() host.lastVisitedAtByWorktreeId = { [WORKTREE]: 5 } local.tabsByWorktree = { [WORKTREE]: [terminal('local')] } - local.explicitlySleptChatsByWorkspace = { - [`${HOST}|${WORKTREE}`]: [{ tabId: 'chat', sessionId: 'session' }] - } expect(census(state).totalCount).toBe(0) const session = exportState(state).payload.dormantState?.workspaceSession expect(session?.tabsByWorktree[WORKTREE]?.map((tab) => tab.id)).toEqual(['local']) - expect(session).not.toHaveProperty('explicitlySleptChatsByWorkspace') }) it('exports local tabs and drafts beside a host-only closed-pane resume record', () => { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts index c71d0c17dd0..4934dccb66a 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -164,8 +164,6 @@ function collectWorkspaceSession( } : null }) - // Sleep intent belongs to the client that recorded it. - delete fragment.explicitlySleptChatsByWorkspace if (!sourceHostPartition) { // Client focus is not host state, so the destination never carries it. fragment.activeWorktreeId = null diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.test.ts b/src/main/persistence/restoring-sessions/session-owner-fields.test.ts index a04615fa4e9..5ff4e2692cf 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.test.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.test.ts @@ -63,10 +63,9 @@ describe('deleting a workspace owner session fields', () => { ]) }) - // Why not in the list above: these maps also hold host-qualified keys, so they are scanned rather + // Why not in the list above: this map also holds host-qualified keys, so it is scanned rather // than indexed by the owner key, and the scan is the only thing that reaches a remote row. - it('deletes recency and explicit chat Sleep entries under bare and host-qualified keys alike', () => { - const slept = [{ tabId: 'chat-1', sessionId: 'session-1' }] + it('deletes the recency entry under a bare and a host-qualified key alike', () => { const session: WorkspaceSessionState = { ...getDefaultWorkspaceSession(), lastVisitedAtByWorktreeId: { @@ -74,11 +73,6 @@ describe('deleting a workspace owner session fields', () => { [`ssh:user@host|${REMOVED_OWNER_KEY}`]: 2, [RETAINED_OWNER_KEY]: 3, [`ssh:user@host|${RETAINED_OWNER_KEY}`]: 4 - }, - explicitlySleptChatsByWorkspace: { - [`local|${REMOVED_OWNER_KEY}`]: slept, - [`ssh:user@host|${REMOVED_OWNER_KEY}`]: slept, - [`local|${RETAINED_OWNER_KEY}`]: slept } } @@ -88,8 +82,5 @@ describe('deleting a workspace owner session fields', () => { [RETAINED_OWNER_KEY]: 3, [`ssh:user@host|${RETAINED_OWNER_KEY}`]: 4 }) - expect(session.explicitlySleptChatsByWorkspace).toEqual({ - [`local|${RETAINED_OWNER_KEY}`]: slept - }) }) }) diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index c483cab536b..88920f660f3 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -7,24 +7,16 @@ import { } from '../../../shared/worktree/host-qualified-identity' import { SESSION_FIELDS_PRUNED_BY_OWNER_KEY } from '../../orca-profiles/profile-project-session-field-disposition' -// Keyed by `${executionHostId}|${worktreeId}` as well as the bare owner key. -const HOST_QUALIFIED_SESSION_FIELDS = [ - 'lastVisitedAtByWorktreeId', - 'explicitlySleptChatsByWorkspace' -] as const - /** * The census fields this path deletes by indexing the owner key. * - * Some classified fields are handled separately below: the topology revision is keyed by repo - * rather than by owner, so removing one owner advances it instead of dropping it; and the - * host-qualified maps are scanned rather than indexed. + * Two classified fields are handled separately below: the topology revision is keyed by repo + * rather than by owner, so removing one owner advances it instead of dropping it; and the recency + * map also holds host-qualified keys, so it is scanned rather than indexed. */ export const OWNER_KEYED_SESSION_FIELDS_DELETED_WITH_THEIR_OWNER = SESSION_FIELDS_PRUNED_BY_OWNER_KEY.filter( - (field) => - field !== 'terminalTopologyRevisionByRepoId' && - !HOST_QUALIFIED_SESSION_FIELDS.some((hostQualified) => hostQualified === field) + (field) => field !== 'terminalTopologyRevisionByRepoId' && field !== 'lastVisitedAtByWorktreeId' ) export function createMinimalPersistedTerminalTab(args: { @@ -97,12 +89,12 @@ export function deleteOwnerKeyedSessionFields( delete record[ownerKey] } } - for (const field of HOST_QUALIFIED_SESSION_FIELDS) { - const record: Record | undefined = next[field] - for (const key of Object.keys(record ?? {})) { + // Scanned, not indexed: this map also holds `${executionHostId}|${worktreeId}` keys. + if (next.lastVisitedAtByWorktreeId) { + for (const key of Object.keys(next.lastVisitedAtByWorktreeId)) { const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key - if (record && (key === ownerKey || rawId === ownerKey)) { - delete record[key] + if (key === ownerKey || rawId === ownerKey) { + delete next.lastVisitedAtByWorktreeId[key] } } } diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts index aae3b99b37f..414db2867f0 100644 --- a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts +++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts @@ -50,8 +50,6 @@ export const WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND = { lastVisitedAtByWorktreeId: 'owner-keyed', defaultTerminalTabsAppliedByWorktreeId: 'owner-keyed', sleepingAgentSessionsByPaneKey: 'row-record', - // A remembered Sleep is a view choice, not evidence that a checkout still exists. - explicitlySleptChatsByWorkspace: 'none', terminalPtyIncarnationsByPaneKey: 'none', terminalTopologyRevisionByRepoId: 'none', terminalSurfaceTombstonesByPaneKey: 'row-record', diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts b/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts index 1b13ef3509f..deabb7197ca 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration-field-coverage.test.ts @@ -206,21 +206,6 @@ const referencingFields = Object.keys(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND) .sort() describe('migrateWorktreeIdentity worktree-reference coverage', () => { - it('rekeys view-only Sleep intent without treating it as checkout ownership', () => { - const session: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - explicitlySleptChatsByWorkspace: { - [`local|${OLD}`]: [{ tabId: 'chat', sessionId: 'session' }] - } - } - expect([...collectWorkspaceSessionWorktreeOwners(session, CANDIDATES)]).toEqual([]) - - expect(migrateWorktreeIdentity(persistedState(session), OLD, NEW)).toBe(true) - expect(session.explicitlySleptChatsByWorkspace).toEqual({ - [`local|${NEW}`]: [{ tabId: 'chat', sessionId: 'session' }] - }) - }) - it('has a fixture for every field the ownership census says can name a worktree', () => { const missing = referencingFields.filter((field) => !REFERENCE_FIXTURES[field]) expect(missing).toEqual([]) diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index bef5211b773..2bc7fa95e6f 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -9,7 +9,6 @@ import { isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' -import { renameSleptChatWorkspaceInSession } from '../../../shared/workspace-chat-sleep' type WorktreeNamingRow = { worktreeId: string } @@ -190,8 +189,6 @@ export function migrateWorktreeIdentity( moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged - sessionChanged = - renameSleptChatWorkspaceInSession(session, oldWorktreeId, newWorktreeId) || sessionChanged if (session.lastVisitedAtByWorktreeId) { const nextRecency = { ...session.lastVisitedAtByWorktreeId } let recencyChanged = false diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index 2c2bf69a0d1..cc1bc6a3164 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -207,19 +207,6 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()[0]).not.toHaveProperty('structuredHostOwned') }) - it('clears a marked chat when the first confirmed live summary is still working', async () => { - mocks.store?.setState({ - unifiedTabsByWorktree: { 'wt-1': [{ ...structuredTab, executionHostId: 'local' }] }, - explicitlySleptChatsByWorkspace: { - 'local|wt-1': [{ tabId: structuredTab.id, sessionId: structuredTab.entityId }] - } - }) - render() - await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) - act(() => feed().emit({ type: 'snapshot', sessions: [summary()] })) - expect(mocks.store?.getState().explicitlySleptChatsByWorkspace['local|wt-1']).toBeUndefined() - }) - it('maps each host status onto the sidebar agent state', async () => { render() await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) @@ -664,7 +651,7 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(mocks.setAgentStatus).toHaveBeenCalledTimes(calls) }) - it('keeps the host-fed chat row through Sleep and offers Sleep until the chat is recorded slept', async () => { + it('keeps the host-fed chat row through Sleep and offers Sleep until its host marks the chat slept', async () => { const state = (): AppState => useAppStore.getState() const sleepable = (): boolean => hasSleepableWorkspaceActivity('wt-1', state()) mocks.store?.setState({ @@ -691,16 +678,19 @@ describe('StructuredAgentSessionStatusBridge', () => { expect(statuses()).toEqual([expect.objectContaining({ state: 'done' })]) expect(statuses()[0].structuredHostOwned).toBeUndefined() expect(state().acknowledgedAgentsByPaneKey).toEqual({ [paneKey]: 3 }) - // The host let go; only the explicit Sleep's record stops offering Sleep, and new work clears it. + // The host let go: only its Sleep mark stops offering Sleep, and the chat running again wakes it. expect(sleepable()).toBe(true) - const chat = { tabId: structuredTab.id, sessionId: structuredTab.entityId } - act(() => state().markWorkspaceChatsSlept('wt-1', 'local', [chat])) + const slept = summary({ + status: 'idle', + updatedAt: 2, + hostExecutionOwned: undefined, + sleptAt: 5 + }) + act(() => feed().emit({ type: 'status', session: slept })) + expect(state().structuredChatSleptAtByPaneKey).toEqual({ [paneKey]: 5 }) expect(sleepable()).toBe(false) - act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 3 }) })) - expect(state().explicitlySleptChatsByWorkspace['local|wt-1']).toBeUndefined() - // Work reported before the Sleep flow records the chat still wakes it. - act(() => state().markWorkspaceChatsSlept('wt-1', 'local', [chat])) - expect(state().explicitlySleptChatsByWorkspace['local|wt-1']).toBeUndefined() + act(() => feed().emit({ type: 'status', session: summary({ updatedAt: 3, sleptAt: 5 }) })) + expect(state().structuredChatSleptAtByPaneKey).toEqual({}) }) it('drops the status and the feed when the last structured tab closes', async () => { diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 8e9d844a864..7e764a069b9 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -23,7 +23,6 @@ import { } from '../../../../shared/agent-status-types' import { structuredChildWorkLegacySubagents } from '../../../../shared/structured-agent-session-child-work-legacy' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' -import { isChatExplicitlySlept } from '../../../../shared/workspace-chat-sleep' import { structuredAgentSessionAgentStatus } from '../../../../shared/structured-agent-session-agent-status' import { structuredAgentSessionDatedMainAgent, @@ -329,23 +328,14 @@ function StructuredAgentSessionOwnedStatusProjection({ target: RuntimeClientTarget }): null { const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target) - const { id: tabId, entityId: sessionId, executionHostId, worktreeId } = tab - // Why: a send after Sleep's host stop can report working before the flow records the chat slept. - const slept = useAppStore((state) => - isChatExplicitlySlept(state.explicitlySleptChatsByWorkspace, tab) - ) + const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) + // A chat its host runs again is awake whatever its mark says. + const sleptAt = summary?.hostExecutionOwned ? undefined : summary?.sleptAt useEffect(() => { - if (!slept || observation !== 'live' || summary?.status !== 'working') { - return - } - const store = useAppStore.getState() - if (executionHostId) { - store.clearWorkspaceChatsSlept(worktreeId, { - hostId: executionHostId, - chats: [{ tabId, sessionId }] - }) - } - }, [slept, summary, observation, tabId, sessionId, executionHostId, worktreeId]) + const { setStructuredChatSleptAt } = useAppStore.getState() + setStructuredChatSleptAt(paneKey, sleptAt) + return () => setStructuredChatSleptAt(paneKey, undefined) + }, [paneKey, sleptAt]) const launchFailed = useStructuredAgentSessionLaunchLifecycle(tab.worktreeId, tab.entityId) === 'failed' const failedAt = useStructuredAgentSessionLaunchFailedAt(tab.entityId) diff --git a/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts b/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts index e2fb53e477a..d317c74e664 100644 --- a/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts +++ b/src/renderer/src/components/sidebar/sleep-workspace-chat-stop.ts @@ -1,4 +1,7 @@ -import type { AgentSessionWorkspaceSleepResult } from '../../../../shared/agent-session-workspace-sleep' +import { + AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY, + type AgentSessionWorkspaceSleepResult +} from '../../../../shared/agent-session-workspace-sleep' import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId, @@ -8,6 +11,7 @@ import { authorityKey, SELF_LOCAL_AUTHORITY } from '../../../../shared/host-auth import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' import { resolveOwner } from '@/lib/resolve-owner' import { runtimeRpcTargetForAuthorityKey } from '@/runtime/runtime-client-target' +import { readCachedRuntimeEnvironmentCapability } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { workspacePublishingHosts } from '@/store/slices/worktrees/teardown/host-twin-worktree-purge' import type { AppState } from '@/store/types' @@ -47,17 +51,32 @@ function chatHostTargets( return targets } -/** Each host stops only the chats it runs, so every copy's host is asked. Never rejects. */ +/** Whether the host keeps Sleep's mark on its chats; the local host is this build. Read from the + * last probe, because the stop must be sent in the same step that admitted it; an unprobed host + * marks nothing and its workspace stays awake. */ +function hostMarksSleep(target: RuntimeClientTarget): boolean { + return ( + target.kind === 'local' || + readCachedRuntimeEnvironmentCapability( + target.environmentId, + AGENT_SESSION_WORKSPACE_SLEEP_MARK_RUNTIME_CAPABILITY + ) === true + ) +} + +/** Each host stops only the chats it runs, so every copy's host is asked. Never rejects. + * `markAsleep` asks each host that can to record the chats it stopped as put to sleep. */ export async function stopWorkspaceChats( state: AppState, - workspaceId: string + workspaceId: string, + markAsleep = false ): Promise { const settled = await Promise.all( [...chatHostTargets(state, workspaceId)].map(([executionHostId, target]) => callStructuredAgentSession( target, 'agentSession.sleepWorkspace', - { workspaceId } + markAsleep && hostMarksSleep(target) ? { workspaceId, markAsleep } : { workspaceId } ).then( (result) => ({ executionHostId, result, error: undefined }), (error: unknown) => ({ executionHostId, result: null, error }) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts index be9b6260241..d5f0a8d6d56 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-activation-race.test.ts @@ -15,7 +15,6 @@ const mocks = vi.hoisted(() => { worktreesByRepo: {}, detectedWorktreesByRepo: {}, tabsByWorktree: {} as Record, - unifiedTabsByWorktree: {}, ptyIdsByTabId: {} as Record } const activateAndRevealWorktree = vi.fn() diff --git a/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts index e237e70d720..d6f28753d44 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-admission.test.ts @@ -30,7 +30,6 @@ vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ })) vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) import { runSleepWorktrees } from './sleep-worktree-flow' -import type { Tab } from '../../../../shared/tab-types' const ids = ['repo1::/one', 'repo1::/two'] beforeEach(() => { @@ -50,7 +49,7 @@ beforeEach(() => { mocks.terminal.mockResolvedValue(undefined) mocks.suspend.mockResolvedValue(null) vi.stubGlobal('window', { - api: { ephemeralVm: { suspendWorkspace: mocks.suspend }, pty: { kill: vi.fn() }, gh: {} } + api: { ephemeralVm: { suspendWorkspace: mocks.suspend }, pty: { kill: vi.fn() } } }) }) @@ -151,37 +150,3 @@ it('keeps the real chat-only twin refusal after synchronous browser cleanup', as expect(mocks.terminal).not.toHaveBeenCalled() expect(mocks.toast).toHaveBeenCalledOnce() }) - -it.each([ - { wake: false, completed: true }, - { wake: true, completed: false } -])('an open during Sleep teardown keeps the chat awake: %o', async ({ wake, completed }) => { - const chat: Tab = { - id: 'chat-tab', - entityId: 'chat', - groupId: 'g', - worktreeId: ids[0], - contentType: 'agent-session', - agentSessionAgent: 'claude', - executionHostId: 'runtime:env-a', - label: 'chat', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 - } - store.setState({ unifiedTabsByWorktree: { [ids[0]]: [chat] } }) - const terminal = Promise.withResolvers() - mocks.terminal.mockReturnValue(terminal.promise) - const sleeping = runSleepWorktrees([ids[0]]) - if (wake) { - store.getState().setActiveWorktree(ids[0]) - store.getState().setActiveWorktree(ids[1]) - // The terminal reconnect barrier still holds until its teardown settles. - expect(hasWorktreeSleepIntent(ids[0])).toBe(true) - } - terminal.resolve() - const results = await sleeping - expect(Object.keys(store.getState().explicitlySleptChatsByWorkspace).length).toBe(wake ? 0 : 1) - expect(results).toEqual([expect.objectContaining({ stoppedSessionIds: ['chat'], completed })]) -}) diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts index 2a17802c403..73309523c98 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.test.ts @@ -13,9 +13,6 @@ const mocks = vi.hoisted(() => { ptyIdsByTabId: Record worktreesByRepo: Record detectedWorktreesByRepo: Record - unifiedTabsByWorktree: Record - markWorkspaceChatsSlept: ReturnType - clearWorkspaceChatsSlept: ReturnType } = { activeWorktreeId: null, setActiveWorktree: vi.fn((worktreeId: string | null) => { @@ -28,20 +25,17 @@ const mocks = vi.hoisted(() => { tabsByWorktree: {}, ptyIdsByTabId: {}, worktreesByRepo: {}, - detectedWorktreesByRepo: {}, - unifiedTabsByWorktree: {}, - markWorkspaceChatsSlept: vi.fn(), - clearWorkspaceChatsSlept: vi.fn() + detectedWorktreesByRepo: {} } const suspendWorkspace = vi.fn().mockResolvedValue(null) const toastError = vi.fn() const markWorktreeSleepIntent = vi.fn() const clearWorktreeSleepIntent = vi.fn() const callChat = vi.fn() - const hasWorktreeSleepIntent = vi.fn(() => true) + const hostMarksSleep = vi.fn() return { callChat, - hasWorktreeSleepIntent, + hostMarksSleep, clearWorktreeSleepIntent, markWorktreeSleepIntent, state, @@ -60,15 +54,18 @@ vi.mock('sonner', () => ({ toast: { error: mocks.toastError } })) vi.mock('@/lib/worktree-sleep-intent', () => ({ clearWorktreeSleepIntent: mocks.clearWorktreeSleepIntent, markWorktreeSleepIntent: mocks.markWorktreeSleepIntent, - hasWorktreeSleepIntent: mocks.hasWorktreeSleepIntent, + hasWorktreeSleepIntent: () => true, withWorktreeSleepTeardown: (_worktreeId: string, teardown: () => Promise) => teardown() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.callChat })) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal>()), + readCachedRuntimeEnvironmentCapability: mocks.hostMarksSleep +})) import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' -import type { Tab } from '../../../../shared/tab-types' import { runSleepWorktree, runSleepWorktrees, @@ -106,25 +103,7 @@ describe('runSleepWorktree', () => { mocks.state.ptyIdsByTabId = {} mocks.state.worktreesByRepo = {} mocks.callChat.mockReset().mockResolvedValue({ stoppedSessionIds: [] }) - mocks.hasWorktreeSleepIntent.mockReset().mockReturnValue(true) - mocks.state.unifiedTabsByWorktree = {} - mocks.state.markWorkspaceChatsSlept.mockClear() - mocks.state.clearWorkspaceChatsSlept.mockClear() - }) - - const chat = (id: string, worktreeId: string, executionHostId: Tab['executionHostId']): Tab => ({ - id, - entityId: `session-${id}`, - groupId: 'group-1', - worktreeId, - contentType: 'agent-session', - agentSessionAgent: 'claude', - executionHostId, - label: id, - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 + mocks.hostMarksSleep.mockReset().mockReturnValue(true) }) it('tears down browsers before terminals on the sleep path', async () => { @@ -333,17 +312,15 @@ describe('runSleepWorktree', () => { } }) - mocks.state.unifiedTabsByWorktree = { 'wt-2': [chat('a', 'wt-2', 'local')] } - mocks.callChat.mockResolvedValue({ stoppedSessionIds: ['session-a'] }) - const outcome = await sleepWorktreesReportingFailures(['wt-1', 'wt-2']) expect(outcome.failedWorktreeIds).toEqual(['wt-1']) expect(outcome.errors).toHaveLength(1) expect(mocks.toastError).not.toHaveBeenCalled() - // A project move pauses through this core; only the user's Sleep records chats asleep. - expect(mocks.state.markWorkspaceChatsSlept).not.toHaveBeenCalled() - expect(mocks.state.clearWorkspaceChatsSlept).not.toHaveBeenCalled() + // A project move pauses through this core; only the user's Sleep asks hosts to mark chats. + expect(mocks.callChat.mock.calls.map(([, , params]) => params)).toEqual([ + { workspaceId: 'wt-2' } + ]) }) it('sleeps multiple worktrees and clears active only once when included', async () => { @@ -379,43 +356,31 @@ describe('runSleepWorktree', () => { mocks.state.worktreesByRepo = twinRows mocks.state.tabsByWorktree = { [twinId]: [{ id: 'tab-1' }] } mocks.state.ptyIdsByTabId = { 'tab-1': [toRemoteRuntimePtyId('pty-1', 'env-a')] } - mocks.state.unifiedTabsByWorktree = { - [twinId]: [ - chat('local', twinId, 'local'), - chat('environment', twinId, 'runtime:env-a'), - chat('unconfirmed', twinId, 'runtime:env-a') - ] - } mocks.callChat.mockImplementation(async (target: { kind: string }) => ({ - stoppedSessionIds: [`session-${target.kind}`] + stoppedSessionIds: [`chat-${target.kind}`] })) + mocks.hostMarksSleep.mockReturnValue(null) const results = await runSleepWorktrees([twinId]) + // Only a host known to keep the mark is asked to; an older one would refuse the whole stop. + expect(mocks.callChat.mock.calls.map(([target, , params]) => [target.kind, params])).toEqual([ + ['local', { workspaceId: twinId, markAsleep: true }], + ['environment', { workspaceId: twinId }] + ]) + expect(mocks.hostMarksSleep).toHaveBeenCalledWith( + 'env-a', + 'agent-session.workspace-sleep-mark.v1' + ) + expect(results).toEqual([ - expect.objectContaining({ executionHostId: 'local', stoppedSessionIds: ['session-local'] }), + expect.objectContaining({ executionHostId: 'local', stoppedSessionIds: ['chat-local'] }), expect.objectContaining({ executionHostId: 'runtime:env-a', - stoppedSessionIds: ['session-environment'], + stoppedSessionIds: ['chat-environment'], completed: true }) ]) - // Only chats a host confirmed stopped are recorded, each under its own host. - expect(mocks.state.markWorkspaceChatsSlept.mock.calls).toEqual([ - [twinId, 'local', [{ tabId: 'local', sessionId: 'session-local' }]], - [twinId, 'runtime:env-a', [{ tabId: 'environment', sessionId: 'session-environment' }]] - ]) - }) - - it('records nothing when the workspace wakes during the chat stop', async () => { - mocks.state.unifiedTabsByWorktree = { 'wt-1': [chat('a', 'wt-1', 'local')] } - mocks.callChat.mockResolvedValue({ stoppedSessionIds: ['session-a'] }) - mocks.hasWorktreeSleepIntent.mockReturnValue(false) - - const results = await runSleepWorktree('wt-1') - - expect(results).toEqual([expect.objectContaining({ completed: false })]) - expect(mocks.state.markWorkspaceChatsSlept).not.toHaveBeenCalled() }) it('stops no chat when it cannot tell which copy runs the terminals', async () => { @@ -440,9 +405,6 @@ describe('runSleepWorktree', () => { expect(mocks.clearWorktreeSleepIntent).not.toHaveBeenCalled() expect(mocks.state.setActiveWorktree.mock.calls).toEqual([[null]]) expect(results).toEqual([]) - // A Sleep that can't confirm its chats leaves the workspace awake on every host. - expect(mocks.state.clearWorkspaceChatsSlept).toHaveBeenCalledWith('wt-1') - expect(mocks.state.markWorkspaceChatsSlept).not.toHaveBeenCalled() expect(mocks.toastError).toHaveBeenCalledOnce() expect(mocks.toastError).toHaveBeenCalledWith('Failed to sleep workspace', { description: diff --git a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts index c9e2efd5428..0942cfbe6b0 100644 --- a/src/renderer/src/components/sidebar/sleep-worktree-flow.ts +++ b/src/renderer/src/components/sidebar/sleep-worktree-flow.ts @@ -8,19 +8,8 @@ import { } from '@/lib/worktree-sleep-intent' import { translate } from '@/i18n/i18n' import type { ExecutionHostId } from '../../../../shared/execution-host' -import type { Tab } from '../../../../shared/tab-types' import { preserveSidebarWorktreePosition } from './sleep-worktree-sidebar-position' -import { - stopWorkspaceChats, - type WorkspaceChatStop, - type WorkspaceChatStopReceipt -} from './sleep-workspace-chat-stop' -import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' -import { - beginChatSleepAttempt, - endChatSleepAttempt, - isCurrentChatSleepAttempt -} from '@/store/slices/worktrees/session/workspace-chat-sleep-attempt' +import { stopWorkspaceChats, type WorkspaceChatStop } from './sleep-workspace-chat-stop' /** One per host asked to stop the workspace's chats. */ export type WorkspaceSleepResult = { @@ -91,7 +80,7 @@ export async function runSleepWorktrees( worktreeIds: readonly string[] ): Promise { const { errors, results } = await sleepWorktreesReportingFailures(worktreeIds, { - recordExplicitChatSleep: true + markChatsAsleep: true }) if (errors.length > 0) { // Why: callers are fire-and-forget; surface actionable teardown failures after restoring retryable UI state. @@ -113,31 +102,6 @@ export async function runSleepWorktrees( return results } -/** Records the chats each host confirmed stopped; a failed chat stop leaves the workspace awake. */ -function recordExplicitChatSleep( - worktreeId: string, - receipts: readonly WorkspaceChatStopReceipt[] | null, - capturedChats: readonly Tab[] -): void { - const store = useAppStore.getState() - if (!receipts) { - store.clearWorkspaceChatsSlept(worktreeId) - return - } - // Why by session id: a copy reached through a paired runtime stamps its tabs with its own host. - const stopped = new Set(receipts.flatMap((receipt) => receipt.stoppedSessionIds)) - const chatsByHost = new Map() - for (const tab of capturedChats) { - if (tab.executionHostId && stopped.has(tab.entityId)) { - const chats = chatsByHost.get(tab.executionHostId) ?? [] - chatsByHost.set(tab.executionHostId, [...chats, { tabId: tab.id, sessionId: tab.entityId }]) - } - } - for (const [hostId, chats] of chatsByHost) { - store.markWorkspaceChatsSlept(worktreeId, hostId, chats) - } -} - export type WorktreeSleepOutcome = { /** Every workspace not verifiably slept, including one whose chat stop went unconfirmed. */ failedWorktreeIds: string[] @@ -149,7 +113,7 @@ export type WorktreeSleepOutcome = { export async function sleepWorktreesReportingFailures( requestedWorktreeIds: readonly string[], // Why: only the user's Sleep hides its chats; a project move pauses through this core too. - options: { recordExplicitChatSleep?: boolean } = {} + options: { markChatsAsleep?: boolean } = {} ): Promise { // Why unique: both copies of a twin share one id, and sleeping it sleeps the whole workspace. const worktreeIds = [...new Set(requestedWorktreeIds)] @@ -178,23 +142,8 @@ export async function sleepWorktreesReportingFailures( const failedWorktreeIds = new Set() // Why apart from failedWorktreeIds: stopped terminals stay stopped when only a chat stop fails. const chatFailedIds = new Set() - const chatSleepAttempts = new Map() - // Why not the marker alone: it outlives an open during teardown, and that open wakes the chats. - const isStillAsleep = (worktreeId: string): boolean => { - const attempt = chatSleepAttempts.get(worktreeId) - return ( - hasWorktreeSleepIntent(worktreeId) && - (!attempt || isCurrentChatSleepAttempt(worktreeId, attempt)) - ) - } const attempts = worktreeIds.map((worktreeId) => { markWorktreeSleepIntent(worktreeId) - if (options.recordExplicitChatSleep) { - chatSleepAttempts.set(worktreeId, beginChatSleepAttempt(worktreeId)) - } - const capturedChats = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).filter( - isStructuredTab - ) let chats: Promise = Promise.resolve({ receipts: [], failed: false }) // Browser cleanup and the authoritative terminal admission run before any batch wait. const panels = withWorktreeSleepTeardown(worktreeId, async () => { @@ -202,7 +151,11 @@ export async function sleepWorktreesReportingFailures( await shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true, onAdmitted: () => { - chats = stopWorkspaceChats(useAppStore.getState(), worktreeId).catch((error: unknown) => { + chats = stopWorkspaceChats( + useAppStore.getState(), + worktreeId, + options.markChatsAsleep + ).catch((error: unknown) => { console.error('[sleep-worktree] native chat stop failed', { worktreeId, error }) return { receipts: [], failed: true } }) @@ -220,13 +173,13 @@ export async function sleepWorktreesReportingFailures( errors.push(describeSleepFailure(error)) } ) - return { worktreeId, capturedChats, settled: panels.then(() => chats) } + return { worktreeId, settled: panels.then(() => chats) } }) const results: WorkspaceSleepResult[] = [] try { // A VM may carry another workspace's host: settle the entire batch before suspending any. const chatStops = await Promise.all(attempts.map(({ settled }) => settled)) - for (const [index, { worktreeId, capturedChats }] of attempts.entries()) { + for (const [index, { worktreeId }] of attempts.entries()) { const { receipts, failed } = chatStops[index] if (failed) { chatFailedIds.add(worktreeId) @@ -251,24 +204,6 @@ export async function sleepWorktreesReportingFailures( results.push( ...receipts.map((receipt) => ({ workspaceId: worktreeId, ...receipt, completed: false })) ) - const completed = !failed && !failedWorktreeIds.has(worktreeId) && isStillAsleep(worktreeId) - const attempt = chatSleepAttempts.get(worktreeId) - // Why current only: a later open or Sleep owns the saved chats, even after a failed stop. - if (attempt && isCurrentChatSleepAttempt(worktreeId, attempt) && (completed || failed)) { - recordExplicitChatSleep(worktreeId, completed ? receipts : null, capturedChats) - } - } - // Why inside try: the finally below releases the attempts this result is judged against. - return { - failedWorktreeIds: [...new Set([...failedWorktreeIds, ...chatFailedIds])], - errors: [...new Set(errors)], - results: results.map((result) => ({ - ...result, - completed: - !failedWorktreeIds.has(result.workspaceId) && - !chatFailedIds.has(result.workspaceId) && - isStillAsleep(result.workspaceId) - })) } } finally { // Why: a failed sleep leaves the workspace awake and retryable. @@ -278,8 +213,16 @@ export async function sleepWorktreesReportingFailures( if (sleptActiveWorktreeId && failedWorktreeIds.has(sleptActiveWorktreeId)) { setActiveWorktree(sleptActiveWorktreeId) } - for (const [worktreeId, attempt] of chatSleepAttempts) { - endChatSleepAttempt(worktreeId, attempt) - } + } + return { + failedWorktreeIds: [...new Set([...failedWorktreeIds, ...chatFailedIds])], + errors: [...new Set(errors)], + results: results.map((result) => ({ + ...result, + completed: + !failedWorktreeIds.has(result.workspaceId) && + !chatFailedIds.has(result.workspaceId) && + hasWorktreeSleepIntent(result.workspaceId) + })) } } diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 36cd9aa7d0c..f9f1acae5c7 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -56,7 +56,7 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ getStructuredChatWorktreeIds( showSleepingWorkspaces, s.unifiedTabsByWorktree, - s.explicitlySleptChatsByWorkspace + s.structuredChatSleptAtByPaneKey ) ) const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) diff --git a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx index ae93319237f..b30b3a3dbc8 100644 --- a/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-context-menu-model.tsx @@ -19,7 +19,7 @@ import { EMPTY_BROWSER_TABS_BY_WORKTREE, EMPTY_CYCLIC_LINEAGE_IDS, EMPTY_DELETE_STATE_BY_WORKTREE_ID, - EMPTY_EXPLICITLY_SLEPT_CHATS, + EMPTY_STRUCTURED_CHAT_SLEPT_AT, EMPTY_PTY_IDS_BY_TAB_ID, EMPTY_TABS_BY_WORKTREE, EMPTY_UNIFIED_TABS_BY_WORKTREE, @@ -130,8 +130,8 @@ export function useWorktreeContextMenuModel({ const agentStatusByPaneKey = useAppStore((s) => selectMenuScopedMap(menuOpen, s.agentStatusByPaneKey, EMPTY_AGENT_STATUS_BY_PANE_KEY) ) - const explicitlySleptChatsByWorkspace = useAppStore((s) => - selectMenuScopedMap(menuOpen, s.explicitlySleptChatsByWorkspace, EMPTY_EXPLICITLY_SLEPT_CHATS) + const structuredChatSleptAtByPaneKey = useAppStore((s) => + selectMenuScopedMap(menuOpen, s.structuredChatSleptAtByPaneKey, EMPTY_STRUCTURED_CHAT_SLEPT_AT) ) const deleteStateByWorktreeId = useAppStore((s) => selectMenuScopedMap(menuOpen, s.deleteStateByWorktreeId, EMPTY_DELETE_STATE_BY_WORKTREE_ID) @@ -150,12 +150,12 @@ export function useWorktreeContextMenuModel({ browserTabsByWorktree, unifiedTabsByWorktree, agentStatusByPaneKey, - explicitlySleptChatsByWorkspace + structuredChatSleptAtByPaneKey }), [ agentStatusByPaneKey, browserTabsByWorktree, - explicitlySleptChatsByWorkspace, + structuredChatSleptAtByPaneKey, ptyIdsByTabId, tabsByWorktree, unifiedTabsByWorktree diff --git a/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx b/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx index 9ff1acefb99..b59bbe5d347 100644 --- a/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-sleep-state.test.tsx @@ -9,6 +9,7 @@ import { useIsSleepingWorktree, resetWorktreeSleepStateCacheForTests } from './use-worktree-sleep-state' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -16,7 +17,7 @@ type MockState = { tabsByWorktree: Record browserTabsByWorktree: Record unifiedTabsByWorktree: Record - explicitlySleptChatsByWorkspace: Record + structuredChatSleptAtByPaneKey: Record ptyIdsByTabId: Record agentStatusEpoch: number agentStatusByPaneKey: Record @@ -97,7 +98,7 @@ describe('useIsSleepingWorktree', () => { tabsByWorktree: {}, browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, - explicitlySleptChatsByWorkspace: {}, + structuredChatSleptAtByPaneKey: {}, ptyIdsByTabId: {}, agentStatusEpoch: 0, agentStatusByPaneKey: {}, @@ -161,34 +162,24 @@ describe('useIsSleepingWorktree', () => { expect(renderToStaticMarkup()).toBe('false') }) - it('reads a slept chat as asleep until its agent runs again', () => { + it('reads a chat its host marked slept as asleep, and awake once the mark goes', () => { const worktreeId = 'repo1::/path/wt1' - const paneKey = makePaneKey('chat-1', LEAF_ID) const chat = makeUnifiedTab({ id: 'chat-1', worktreeId, contentType: 'agent-session', agentSessionAgent: 'claude' }) - chat.executionHostId = 'local' - const doneRow = makeAgentStatusEntry({ paneKey, state: 'done', worktreeId }) + const chatKey = structuredAgentSessionPaneKey(chat.id, chat.entityId) mockState = { ...mockState, unifiedTabsByWorktree: { [worktreeId]: [chat] }, - explicitlySleptChatsByWorkspace: { - [`local|${worktreeId}`]: [{ tabId: chat.id, sessionId: chat.entityId }] - }, - agentStatusByPaneKey: { [paneKey]: doneRow } + structuredChatSleptAtByPaneKey: { [chatKey]: 1 } } expect(renderToStaticMarkup()).toBe('true') - // A send from another client starts the agent; its turn ends but the process lives on. - mockState = { - ...mockState, - agentStatusByPaneKey: { - [paneKey]: { ...doneRow, tabId: 'chat-1', structuredHostOwned: true } - } - } + // A send from any client clears the host's mark, and the bridge drops its copy. + mockState = { ...mockState, structuredChatSleptAtByPaneKey: {} } expect(renderToStaticMarkup()).toBe('false') }) diff --git a/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts b/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts index 6be8ba5bff3..64243fe97ce 100644 --- a/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts +++ b/src/renderer/src/components/sidebar/use-worktree-sleep-state.ts @@ -4,7 +4,7 @@ import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from '@/lib/worktree import { getWorktreeIdsWithStructuredChat } from './visible-worktree-activity-inputs' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { Tab } from '../../../../shared/tab-types' -import type { ExplicitlySleptChatsByWorkspace } from '../../../../shared/workspace-chat-sleep' +import type { StructuredChatSleptAtByPaneKey } from '@/store/slices/structured-chat-slept-at' type TabLike = { id: string } @@ -17,7 +17,7 @@ type SleepStateInput = { ptyIdsByTabId?: Record | null browserTabsByWorktree?: Record | null unifiedTabsByWorktree?: Record | null - explicitlySleptChatsByWorkspace?: ExplicitlySleptChatsByWorkspace + structuredChatSleptAtByPaneKey?: StructuredChatSleptAtByPaneKey } type LiveAgentGeneration = { @@ -78,7 +78,7 @@ export function selectIsSleepingWorktree(state: SleepStateInput, worktreeId: str selectWorktreeIdsWithLiveAgent(state), getWorktreeIdsWithStructuredChat( state.unifiedTabsByWorktree, - state.explicitlySleptChatsByWorkspace + state.structuredChatSleptAtByPaneKey ) ) } diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts index 5cac1cf13e8..e2978408dd7 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -4,9 +4,9 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { getStructuredAgentSessionTabs } from '@/components/native-chat/structured-agent-session-tabs' import { createWorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' import { - isChatExplicitlySlept, - type ExplicitlySleptChatsByWorkspace -} from '../../../../shared/workspace-chat-sleep' + isStructuredChatSlept, + type StructuredChatSleptAtByPaneKey +} from '@/store/slices/structured-chat-slept-at' export type TerminalActivityTab = Pick export type BrowserActivityTab = Pick @@ -41,23 +41,22 @@ export function getVisibleWorktreeBrowserActivityTabs( } export const EMPTY_STRUCTURED_CHAT_WORKTREE_IDS: ReadonlySet = new Set() -const EMPTY_SLEPT_CHATS: ExplicitlySleptChatsByWorkspace = {} +const EMPTY_SLEPT_CHATS: StructuredChatSleptAtByPaneKey = {} const structuredChatWorktreeIds = new WeakMap< Record, - WeakMap> + WeakMap> >() /** * Workspace ids holding a structured chat that Sleep has not put to rest. * - * Why the chat rather than its provider child: the child starts only on a send and the host's idle - * sweep stops it, so keying on it would put every quiet chat workspace to sleep after a restart or a - * pause. Only Sleep does that; activating the workspace clears the mark, and a child running anyway - * keeps it awake through the live-agent term. + * Why the host's Sleep mark rather than the provider child: the child starts only on a send and the + * host's idle sweep stops it, so keying on it would put every quiet chat workspace to sleep after a + * restart or a pause. Only the user's Sleep marks a chat, and the next send it hands over clears it. */ export function getWorktreeIdsWithStructuredChat( unifiedTabsByWorktree: Record | null | undefined, - explicitlySleptChatsByWorkspace: ExplicitlySleptChatsByWorkspace = EMPTY_SLEPT_CHATS + structuredChatSleptAtByPaneKey: StructuredChatSleptAtByPaneKey = EMPTY_SLEPT_CHATS ): ReadonlySet { if (!unifiedTabsByWorktree) { return EMPTY_STRUCTURED_CHAT_WORKTREE_IDS @@ -66,27 +65,27 @@ export function getWorktreeIdsWithStructuredChat( // selector on each store write, and this is a whole-store scan. const cached = structuredChatWorktreeIds .get(unifiedTabsByWorktree) - ?.get(explicitlySleptChatsByWorkspace) + ?.get(structuredChatSleptAtByPaneKey) if (cached) { return cached } const worktreeIds = new Set( getStructuredAgentSessionTabs(unifiedTabsByWorktree) - .filter((tab) => !isChatExplicitlySlept(explicitlySleptChatsByWorkspace, tab)) + .filter((tab) => !isStructuredChatSlept(structuredChatSleptAtByPaneKey, tab)) .map((tab) => tab.worktreeId) ) - const byIntent = structuredChatWorktreeIds.get(unifiedTabsByWorktree) ?? new WeakMap() - byIntent.set(explicitlySleptChatsByWorkspace, worktreeIds) - structuredChatWorktreeIds.set(unifiedTabsByWorktree, byIntent) + const bySleptAt = structuredChatWorktreeIds.get(unifiedTabsByWorktree) ?? new WeakMap() + bySleptAt.set(structuredChatSleptAtByPaneKey, worktreeIds) + structuredChatWorktreeIds.set(unifiedTabsByWorktree, bySleptAt) return worktreeIds } export function getStructuredChatWorktreeIds( showSleepingWorkspaces: boolean, unifiedTabsByWorktree: Record | null | undefined, - explicitlySleptChatsByWorkspace: ExplicitlySleptChatsByWorkspace = EMPTY_SLEPT_CHATS + structuredChatSleptAtByPaneKey: StructuredChatSleptAtByPaneKey = EMPTY_SLEPT_CHATS ): ReadonlySet { return showSleepingWorkspaces ? EMPTY_STRUCTURED_CHAT_WORKTREE_IDS - : getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree, explicitlySleptChatsByWorkspace) + : getWorktreeIdsWithStructuredChat(unifiedTabsByWorktree, structuredChatSleptAtByPaneKey) } diff --git a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts index 43d153186cc..75651b1b6f9 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-options-from-state.ts @@ -33,7 +33,7 @@ export function buildVisibleWorktreeOptionsFromState( ), worktreeIdsWithStructuredChat: getWorktreeIdsWithStructuredChat( state.unifiedTabsByWorktree, - state.explicitlySleptChatsByWorkspace + state.structuredChatSleptAtByPaneKey ), hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace, hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces, diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index c794b1bb658..1c67d1bb737 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -71,7 +71,7 @@ describe('workspace lineage menu actions', () => { }, unifiedTabsByWorktree: {}, agentStatusByPaneKey: {}, - explicitlySleptChatsByWorkspace: {} + structuredChatSleptAtByPaneKey: {} } }) @@ -99,7 +99,7 @@ describe('workspace lineage menu actions', () => { browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, agentStatusByPaneKey: {}, - explicitlySleptChatsByWorkspace: {} + structuredChatSleptAtByPaneKey: {} } }) @@ -121,7 +121,7 @@ describe('hasSleepableWorkspaceActivity', () => { browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, agentStatusByPaneKey: {}, - explicitlySleptChatsByWorkspace: {} + structuredChatSleptAtByPaneKey: {} }) ).toBe(false) }) @@ -134,7 +134,7 @@ describe('hasSleepableWorkspaceActivity', () => { browserTabsByWorktree: {}, unifiedTabsByWorktree: {}, agentStatusByPaneKey: {}, - explicitlySleptChatsByWorkspace: {} + structuredChatSleptAtByPaneKey: {} }) ).toBe(true) expect( @@ -144,7 +144,7 @@ describe('hasSleepableWorkspaceActivity', () => { browserTabsByWorktree: { 'wt-1': [{ id: 'browser-1' }] }, unifiedTabsByWorktree: {}, agentStatusByPaneKey: {}, - explicitlySleptChatsByWorkspace: {} + structuredChatSleptAtByPaneKey: {} }) ).toBe(true) }) @@ -166,7 +166,7 @@ describe('hasSleepableWorkspaceActivity', () => { const activity = ( agentStatusByPaneKey: Record, tab: Tab = chat, - slept: { tabId: string; sessionId: string }[] = [] + sleptAt: Record = {} ) => hasSleepableWorkspaceActivity('wt-1', { tabsByWorktree: {}, @@ -174,14 +174,15 @@ describe('hasSleepableWorkspaceActivity', () => { browserTabsByWorktree: {}, unifiedTabsByWorktree: { 'wt-1': [tab] }, agentStatusByPaneKey, - explicitlySleptChatsByWorkspace: { 'local|wt-1': slept } + structuredChatSleptAtByPaneKey: sleptAt }) expect(activity(hostHeld(chat))).toBe(true) expect(activity({})).toBe(false) - // An ended chat stays sleepable until an explicit Sleep records that exact session. + // An ended chat stays sleepable until its host marks that exact tab's session slept. const ended = { ...chat, executionHostId: 'local' as const } expect(activity({}, ended)).toBe(true) - expect(activity({}, ended, [{ tabId: 'chat-1', sessionId: 'session-1' }])).toBe(false) - expect(activity({}, ended, [{ tabId: 'chat-1', sessionId: 'old' }])).toBe(true) + expect(activity({}, ended, { [structuredAgentSessionPaneKey('chat-1', 'session-1')]: 1 })).toBe( + false + ) }) }) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index 8dd25106200..f5fc53864b3 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -6,9 +6,9 @@ import type { Tab } from '../../../../shared/tab-types' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { - isChatExplicitlySlept, - type ExplicitlySleptChatsByWorkspace -} from '../../../../shared/workspace-chat-sleep' + isStructuredChatSlept, + type StructuredChatSleptAtByPaneKey +} from '@/store/slices/structured-chat-slept-at' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' type WorkspaceActivityMaps = { @@ -17,7 +17,7 @@ type WorkspaceActivityMaps = { browserTabsByWorktree: Record unifiedTabsByWorktree: Record agentStatusByPaneKey: Record - explicitlySleptChatsByWorkspace: ExplicitlySleptChatsByWorkspace + structuredChatSleptAtByPaneKey: StructuredChatSleptAtByPaneKey } export type WorkspaceLineageMenuActions = { @@ -34,15 +34,15 @@ export function hasSleepableWorkspaceActivity( return ( tabs.some((tab) => tabHasLivePty(activity.ptyIdsByTabId, tab.id)) || (activity.browserTabsByWorktree[worktreeId] ?? []).length > 0 || - // A chat counts while its host holds its process, or until an explicit Sleep records it - // stopped, so an idle-ended chat can still be slept (and hidden) on purpose. + // A chat counts while its host holds its process, or until its host marks it slept, so an + // idle-ended chat can still be slept (and hidden) on purpose. (activity.unifiedTabsByWorktree[worktreeId] ?? []).some( (tab) => isStructuredTab(tab) && (activity.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] ?.structuredHostOwned === true || (!!tab.executionHostId && - !isChatExplicitlySlept(activity.explicitlySleptChatsByWorkspace, tab))) + !isStructuredChatSlept(activity.structuredChatSleptAtByPaneKey, tab))) ) ) } @@ -80,7 +80,7 @@ export function useWorkspaceLineageMenuActions( browserTabsByWorktree, unifiedTabsByWorktree, agentStatusByPaneKey, - explicitlySleptChatsByWorkspace + structuredChatSleptAtByPaneKey } = activity return useMemo( () => @@ -95,7 +95,7 @@ export function useWorkspaceLineageMenuActions( browserTabsByWorktree, unifiedTabsByWorktree, agentStatusByPaneKey, - explicitlySleptChatsByWorkspace + structuredChatSleptAtByPaneKey } }) : EMPTY_LINEAGE_MENU_ACTIONS, @@ -103,7 +103,7 @@ export function useWorkspaceLineageMenuActions( agentStatusByPaneKey, browserTabsByWorktree, enabled, - explicitlySleptChatsByWorkspace, + structuredChatSleptAtByPaneKey, lineageById, parent, ptyIdsByTabId, diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts index ad84272be15..1159112ca34 100644 --- a/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/worktree-context-menu-policy.ts @@ -29,7 +29,7 @@ export const EMPTY_PTY_IDS_BY_TAB_ID: AppState['ptyIdsByTabId'] = {} export const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} export const EMPTY_UNIFIED_TABS_BY_WORKTREE: AppState['unifiedTabsByWorktree'] = {} export const EMPTY_AGENT_STATUS_BY_PANE_KEY: AppState['agentStatusByPaneKey'] = {} -export const EMPTY_EXPLICITLY_SLEPT_CHATS: AppState['explicitlySleptChatsByWorkspace'] = {} +export const EMPTY_STRUCTURED_CHAT_SLEPT_AT: AppState['structuredChatSleptAtByPaneKey'] = {} export const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {} export const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {} export const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts index e36cd7f10dd..ef0b895c95a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts @@ -116,7 +116,7 @@ const REVEAL_WORKSPACE: Record state.browserTabsByWorktree) const browserPagesByWorkspace = useAppStore((state) => state.browserPagesByWorkspace) const unifiedTabsByWorktree = useAppStore((state) => state.unifiedTabsByWorktree) - const explicitlySleptChatsByWorkspace = useAppStore( - (state) => state.explicitlySleptChatsByWorkspace + const structuredChatSleptAtByPaneKey = useAppStore( + (state) => state.structuredChatSleptAtByPaneKey ) const paletteIndexStatus = useMemo( () => selectPaletteIndexStatusSnapshot(useAppStore.getState(), paletteStatusInputsActive), @@ -164,7 +164,7 @@ export function useWorktreeJumpPaletteStoreState({ browserTabsByWorktree, browserPagesByWorkspace, unifiedTabsByWorktree, - explicitlySleptChatsByWorkspace, + structuredChatSleptAtByPaneKey, agentStatusByPaneKey, runtimePaneTitlesByTabId, unreadTerminalTabs, diff --git a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts index a044d00a0ee..9d138e680d4 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-worktrees.ts @@ -4,7 +4,7 @@ import { filterWorkspaceRows } from '../../../shared/workspace-view/workspace-ro import { buildDesktopWorkspaceRowFactReader } from '@/components/sidebar/desktop-workspace-row-facts' import { sortWorktreesSmart } from '../../../shared/sidebar-list/smart-sort' import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-checks-review-index' -import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' +import { getLiveAgentStatusByWorktreeId } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' import { getWorktreePaletteSearchScope, @@ -40,7 +40,7 @@ export function useWorktreeJumpPaletteWorktrees({ ptyIdsByTabId, browserTabsByWorktree, unifiedTabsByWorktree, - explicitlySleptChatsByWorkspace, + structuredChatSleptAtByPaneKey, activeWorktreeId, activeWorkspaceExecutionHostId, runtimeEnvironments, @@ -64,9 +64,13 @@ export function useWorktreeJumpPaletteWorktrees({ const hasQuery = paletteSearchQuery.length > 0 const isLoading = repos.length > 0 && Object.keys(worktreesByRepo).length === 0 const worktreeIdsWithLiveAgent = useMemo( - // The clock reflects the render producing this snapshot. - // oxlint-disable-next-line react/purity - () => getWorktreeIdsWithLiveAgent(agentStatusByPaneKey, tabsByWorktree, Date.now()), + () => + new Set( + // The palette recomputes this snapshot when status inputs change; the + // clock intentionally reflects the render that performs that snapshot. + // oxlint-disable-next-line react/purity + getLiveAgentStatusByWorktreeId(agentStatusByPaneKey, tabsByWorktree, Date.now()).keys() + ), [agentStatusByPaneKey, tabsByWorktree] ) const pairedDeviceIdsByEnvironment = useMemo( @@ -79,7 +83,7 @@ export function useWorktreeJumpPaletteWorktrees({ const worktreeIdsWithStructuredChat = getStructuredChatWorktreeIds( showSleepingWorkspaces, unifiedTabsByWorktree, - explicitlySleptChatsByWorkspace + structuredChatSleptAtByPaneKey ) const emptyQueryVisibleWorktrees = useMemo(() => { // Why no project or host scope: the palette ignores the sidebar's scope; filterPredicate scopes it. diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts index 8d46e01ba5e..e2f090df121 100644 --- a/src/renderer/src/lib/workspace-session-host-split.test.ts +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -114,9 +114,6 @@ describe('splitWorkspaceSessionByHost', () => { visitCount: 1 } ], - explicitlySleptChatsByWorkspace: { - 'runtime:env-a|a-wt': [{ tabId: 'chat-a', sessionId: 'session-a' }] - }, tabsByWorktree: { 'local-wt': [makeTab('t-local', 'local-wt')], 'a-wt': [makeTab('t-a', 'a-wt')], diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index a50e604c0fb..d1a16cf25e9 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -192,9 +192,6 @@ export function buildWorkspaceSessionPatch( if (changed.has('lastVisitedAtByWorktreeId')) { patch.lastVisitedAtByWorktreeId = buildLastVisitedAtByWorktreeId(snapshot) } - if (changed.has('explicitlySleptChatsByWorkspace')) { - patch.explicitlySleptChatsByWorkspace = snapshot.explicitlySleptChatsByWorkspace ?? {} - } if (changed.has('defaultTerminalTabsAppliedByWorktreeId')) { patch.defaultTerminalTabsAppliedByWorktreeId = snapshot.defaultTerminalTabsAppliedByWorktreeId && diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 048005b2704..9aadf057964 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -35,7 +35,6 @@ describe('SESSION_RELEVANT_FIELDS', () => { worktreesByRepo: true, lastKnownRelayPtyIdByTabId: true, lastVisitedAtByWorktreeId: true, - explicitlySleptChatsByWorkspace: true, defaultTerminalTabsAppliedByWorktreeId: true, sleepingAgentSessionsByPaneKey: true, clientHostedBrowserCloseIntentsByEnvironment: true, diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 308599713b5..c519fb05de6 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -59,7 +59,6 @@ export type WorkspaceSessionSnapshot = Pick< | 'defaultTerminalTabsAppliedByWorktreeId' > & { activeWorkspaceExecutionHostId?: AppState['activeWorkspaceExecutionHostId'] - explicitlySleptChatsByWorkspace?: AppState['explicitlySleptChatsByWorkspace'] sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] clientHostedBrowserCloseIntentsByEnvironment?: AppState['clientHostedBrowserCloseIntentsByEnvironment'] /** Optional so the many partial snapshot fixtures keep type-checking; see buildTerminalSessionData. */ @@ -99,7 +98,6 @@ export const SESSION_RELEVANT_FIELDS = [ 'worktreesByRepo', 'lastKnownRelayPtyIdByTabId', 'lastVisitedAtByWorktreeId', - 'explicitlySleptChatsByWorkspace', 'defaultTerminalTabsAppliedByWorktreeId', 'sleepingAgentSessionsByPaneKey', 'clientHostedBrowserCloseIntentsByEnvironment', @@ -266,7 +264,7 @@ export function buildTerminalSessionData( // Why: derive here to avoid a fragile sync IPC round-trip during beforeunload (Chromium can drop it under shutdown pressure). // Why: pre-indexed above so large workspaces don't rescan every repo/worktree per terminal tab while the renderer is quitting. - const remoteSessions: Record = {} + const remoteSessionIdsByTabId: Record = {} for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { const worktree = worktreeById.get(worktreeId) const repo = worktree ? repoById.get(worktree.repoId) : null @@ -279,14 +277,15 @@ export function buildTerminalSessionData( } const sessionId = tab.ptyId || restoredSessionId(tab.id) if (sessionId) { - remoteSessions[tab.id] = sessionId + remoteSessionIdsByTabId[tab.id] = sessionId } } } return { activeWorktreeIdsOnShutdown, - remoteSessionIdsByTabId: Object.keys(remoteSessions).length ? remoteSessions : undefined + remoteSessionIdsByTabId: + Object.keys(remoteSessionIdsByTabId).length > 0 ? remoteSessionIdsByTabId : undefined } } @@ -333,9 +332,9 @@ export function buildWorkspaceSessionPayload( remoteSessionIdsByTabId: terminalSessionData.remoteSessionIdsByTabId, // Why: omit when empty so builds that never stamped focus-recency don't bloat the payload. See docs/cmd-j-empty-query-ordering.md. lastVisitedAtByWorktreeId: buildLastVisitedAtByWorktreeId(snapshot), - explicitlySleptChatsByWorkspace: snapshot.explicitlySleptChatsByWorkspace ?? {}, defaultTerminalTabsAppliedByWorktreeId: - Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId ?? {}).length > 0 + snapshot.defaultTerminalTabsAppliedByWorktreeId && + Object.keys(snapshot.defaultTerminalTabsAppliedByWorktreeId).length > 0 ? snapshot.defaultTerminalTabsAppliedByWorktreeId : undefined, ...buildSleepingAgentSessionData(snapshot), diff --git a/src/renderer/src/lib/worktree-activity-state.ts b/src/renderer/src/lib/worktree-activity-state.ts index ce056f4f81a..d8440a044ef 100644 --- a/src/renderer/src/lib/worktree-activity-state.ts +++ b/src/renderer/src/lib/worktree-activity-state.ts @@ -25,27 +25,15 @@ const EMPTY_WORKTREE_IDS: ReadonlySet = new Set() * Worktree ids that currently have a live agent session, derived from the * live `agentStatusByPaneKey` map. * - * Why only fresh in-progress rows, plus chats whose host still runs their - * agent: disconnected SSH and completed headless agents can retain status - * entries without an open session. + * Why only fresh in-progress rows: disconnected SSH and completed headless + * agents can retain status entries without an open session. */ export function getWorktreeIdsWithLiveAgent( agentStatusByPaneKey: Record | null | undefined, tabsByWorktree: TabsByWorktree | null | undefined, now: number ): Set { - const rows = Object.values(agentStatusByPaneKey ?? {}) - const otherRows = Object.fromEntries( - rows.filter((entry) => !entry.structuredHostOwned).map((entry) => [entry.paneKey, entry]) - ) - const worktreeIds = new Set(getLiveAgentStatusByWorktreeId(otherRows, tabsByWorktree, now).keys()) - // A chat's agent outlives its turn, so a done row can still have a running process. - for (const entry of rows) { - if (entry.structuredHostOwned && entry.worktreeId) { - worktreeIds.add(entry.worktreeId) - } - } - return worktreeIds + return new Set(getLiveAgentStatusByWorktreeId(agentStatusByPaneKey, tabsByWorktree, now).keys()) } export function getLiveAgentStatusByWorktreeId( diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts index a8c664698d5..1b5b4892181 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts @@ -7,7 +7,6 @@ import { buildRetractedMirroredTabSweepPatch } from './agent-status-primitives' import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence' -import { withoutReplacedSleptChats } from '../../../../shared/workspace-chat-sleep' type FinalPatchContext = ReturnType @@ -91,11 +90,6 @@ export function buildWebSessionTabsFinalPatch( state.recentlyClosedAgentStatusTabIds, mirroredTerminalIds ) - const nextSleptChats = withoutReplacedSleptChats( - state.explicitlySleptChatsByWorkspace ?? {}, - nextUnifiedTabsByWorktree, - worktreeId - ) const patch: Partial = { ...agentStatusPatch, @@ -109,10 +103,6 @@ export function buildWebSessionTabsFinalPatch( ...(nextUnifiedTabsByWorktree !== state.unifiedTabsByWorktree ? { unifiedTabsByWorktree: nextUnifiedTabsByWorktree } : {}), - ...(state.explicitlySleptChatsByWorkspace && - nextSleptChats !== state.explicitlySleptChatsByWorkspace - ? { explicitlySleptChatsByWorkspace: nextSleptChats } - : {}), ...(nextGroupsByWorktree !== state.groupsByWorktree ? { groupsByWorktree: nextGroupsByWorktree } : {}), diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index 189337cc284..aa0297c250f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -240,7 +240,6 @@ export type WebSessionTabsSyncState = Pick< // Why: a client draft is the evidence that a mirrored file's dirty flag is the client's // own and must survive a host republish (#21392); absent here, the host flag wins. | 'editorDrafts' - | 'explicitlySleptChatsByWorkspace' | 'localOnlyScrollbackByTabId' | 'migrationUnsupportedByPtyId' | 'manuallyUnreadTurnsByPaneKey' diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index a41e03c1c81..f5141456ae0 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -49,6 +49,7 @@ import { createTaskCreationDraftsSlice } from './slices/task-creation-drafts' import { createRemoteServerUpdatesSlice } from './slices/remote-server-updates' import { createTerminalQuickCommandHostsSlice } from './slices/terminal-quick-command-hosts' import { createStructuredSessionLaunchDirectorySlice } from './slices/structured-session-launch-directories' +import { createStructuredChatSleptAtSlice } from './slices/structured-chat-slept-at' import { createWorkspaceAttentionSlice } from './slices/workspace-attention/workspace-attention-slice' import { e2eConfig } from '@/lib/e2e-config' import type { createWebRuntimeSessionTerminal } from '@/runtime/web-runtime-terminal-creation' @@ -134,6 +135,7 @@ export const useAppStore = create()( ...createRemoteServerUpdatesSlice(...a), ...createTerminalQuickCommandHostsSlice(...a), ...createStructuredSessionLaunchDirectorySlice(...a), + ...createStructuredChatSleptAtSlice(...a), ...createWorkspaceAttentionSlice(...a) } }) diff --git a/src/renderer/src/store/slices/agent-status-live-facts.ts b/src/renderer/src/store/slices/agent-status-live-facts.ts index ccdb9ef2810..ffbb3d8396f 100644 --- a/src/renderer/src/store/slices/agent-status-live-facts.ts +++ b/src/renderer/src/store/slices/agent-status-live-facts.ts @@ -89,8 +89,6 @@ export function deriveAgentStatusLiveFacts(args: AgentStatusLiveFactsArgs): Agen sortRelevantChange || attributionChanged || existing?.workingMode !== entry.workingMode || - // A chat's agent starting or stopping on a done row wakes or sleeps its workspace. - existing?.structuredHostOwned !== entry.structuredHostOwned || verdictChanged || doneRetentionFieldsChanged const existingSleepingRecord = state.sleepingAgentSessionsByPaneKey[paneKey] diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index 22a7cfcfa20..71cde0bac72 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -195,22 +195,6 @@ describe('purgeStaleRuntimeHostState', () => { expect(s.projectHostSetups).toBe(setupsRef) }) - it('drops removed-host Sleep intent even before its workspace catalog loads', () => { - const store = createTestStore() - store.setState({ - explicitlySleptChatsByWorkspace: { - 'runtime:env-a|repo1::/wt': [{ tabId: 'a', sessionId: 'a' }], - 'local|repo1::/wt': [{ tabId: 'local', sessionId: 'local' }] - } - }) - - store.getState().purgeStaleRuntimeHostState(['env-a']) - - expect(store.getState().explicitlySleptChatsByWorkspace).toEqual({ - 'local|repo1::/wt': [{ tabId: 'local', sessionId: 'local' }] - }) - }) - it('same-repoId-on-two-hosts: keeps an ambiguous unhosted row and the repo key', () => { const store = createTestStore() // repoId 'shared' exists under both local and runtime:env-a in worktreesByRepo. diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 62ad3bb48ce..33b0add002d 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -50,6 +50,7 @@ import { createTaskCreationDraftsSlice } from './task-creation-drafts' import { createRemoteServerUpdatesSlice } from './remote-server-updates' import { createTerminalQuickCommandHostsSlice } from './terminal-quick-command-hosts' import { createStructuredSessionLaunchDirectorySlice } from './structured-session-launch-directories' +import { createStructuredChatSleptAtSlice } from './structured-chat-slept-at' import { createWorkspaceAttentionSlice } from './workspace-attention/workspace-attention-slice' import '@/i18n/i18n' import { makeWorktree, TEST_REPO } from './worktrees-slice-test-fixtures' @@ -111,6 +112,7 @@ export function createTestStore() { ...createRemoteServerUpdatesSlice(...a), ...createTerminalQuickCommandHostsSlice(...a), ...createStructuredSessionLaunchDirectorySlice(...a), + ...createStructuredChatSleptAtSlice(...a), ...createWorkspaceAttentionSlice(...a) })) } diff --git a/src/renderer/src/store/slices/structured-chat-slept-at.ts b/src/renderer/src/store/slices/structured-chat-slept-at.ts new file mode 100644 index 00000000000..639b2f94470 --- /dev/null +++ b/src/renderer/src/store/slices/structured-chat-slept-at.ts @@ -0,0 +1,40 @@ +import type { StateCreator } from 'zustand' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import type { AppState } from '../types' + +/** When the host says Sleep stopped each chat tab's agent, by chat pane key. A cache of the host + * status feed: written only by the status bridge, dropped with its projection, never saved. */ +export type StructuredChatSleptAtByPaneKey = Readonly> + +export type StructuredChatSleptAtSlice = { + structuredChatSleptAtByPaneKey: StructuredChatSleptAtByPaneKey + setStructuredChatSleptAt: (paneKey: string, sleptAt: number | undefined) => void +} + +export function isStructuredChatSlept( + sleptAtByPaneKey: StructuredChatSleptAtByPaneKey, + tab: { id: string; entityId: string } +): boolean { + return sleptAtByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] !== undefined +} + +export const createStructuredChatSleptAtSlice: StateCreator< + AppState, + [], + [], + StructuredChatSleptAtSlice +> = (set) => ({ + structuredChatSleptAtByPaneKey: {}, + setStructuredChatSleptAt: (paneKey, sleptAt) => + set((state) => { + const current = state.structuredChatSleptAtByPaneKey + if (current[paneKey] === sleptAt) { + return state + } + const { [paneKey]: _previous, ...rest } = current + return { + structuredChatSleptAtByPaneKey: + sleptAt === undefined ? rest : { ...rest, [paneKey]: sleptAt } + } + }) +}) diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index 3aa885e0296..0391b354ce5 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -16,7 +16,6 @@ import { import { structuredAgentSessionTabId } from '../../../../../shared/structured-agent-session-projection' import { clearWebSessionFocusIntentIfMatches } from '@/runtime/web-session-focus-intent' import { ownsGlobalSelection } from '../../global-selection-owner' -import { sleptChatWorkspaceKey } from '../../../../../shared/workspace-chat-sleep' import { structuredAgentSessionFocusOwner, structuredAgentSessionTargetForTab @@ -108,25 +107,6 @@ export function createTabsCloseActions( const nextTabs = (current.unifiedTabsByWorktree[worktreeId] ?? []).filter( (item) => item.id !== tabId ) - const sleptKey = - tab.contentType === 'agent-session' && tab.executionHostId - ? sleptChatWorkspaceKey(worktreeId, tab.executionHostId) - : null - const slept = sleptKey ? current.explicitlySleptChatsByWorkspace[sleptKey] : undefined - const retainedSlept = slept?.filter( - (entry) => entry.tabId !== tabId || entry.sessionId !== tab.entityId - ) - const nextSlept = - retainedSlept && retainedSlept.length !== slept?.length - ? { ...current.explicitlySleptChatsByWorkspace } - : current.explicitlySleptChatsByWorkspace - if (nextSlept !== current.explicitlySleptChatsByWorkspace && sleptKey) { - if (retainedSlept?.length) { - nextSlept[sleptKey] = retainedSlept - } else { - delete nextSlept[sleptKey] - } - } // Why: close-to-right/others bypass terminals.closeTab, so clear the entityId-keyed unread flag here or a stale dot leaks. const nextUnreadTerminalTabs = terminalEntityId ? omitRecordKeys(current.unreadTerminalTabs, [terminalEntityId]) @@ -174,9 +154,6 @@ export function createTabsCloseActions( !current.openFiles.some((file) => file.worktreeId === worktreeId) return { unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, - ...(nextSlept !== current.explicitlySleptChatsByWorkspace - ? { explicitlySleptChatsByWorkspace: nextSlept } - : {}), groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: nextGroups diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts index 0f221be5a20..6fb187f53aa 100644 --- a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -11,7 +11,6 @@ import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation' import { createWorktreeTabModelReconciliationBatch } from './tabs-reconciliation-batch' import type { AppState } from '../../types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { withoutReplacedSleptChats } from '../../../../../shared/workspace-chat-sleep' function replaceWorkspaceRecordKeys( current: Record, @@ -94,46 +93,32 @@ export function createTabsSessionActions( addAdditionalValidWorkspaceKeys(validWorktreeIds, options) const hydrated = buildHydratedTabState(session, validWorktreeIds) if (!options?.replaceWorkspaceKeys) { - set((current) => { - let intents = current.explicitlySleptChatsByWorkspace - for (const worktreeId of Object.keys(hydrated.unifiedTabsByWorktree)) { - intents = withoutReplacedSleptChats(intents, hydrated.unifiedTabsByWorktree, worktreeId) - } - return { ...hydrated, explicitlySleptChatsByWorkspace: intents } - }) + set(hydrated) return } const replaceWorkspaceKeys = new Set(options.replaceWorkspaceKeys) - set((current) => { - const unifiedTabsByWorktree = replaceWorkspaceRecordKeys( + set((current) => ({ + unifiedTabsByWorktree: replaceWorkspaceRecordKeys( current.unifiedTabsByWorktree, hydrated.unifiedTabsByWorktree, replaceWorkspaceKeys + ), + groupsByWorktree: replaceWorkspaceRecordKeys( + current.groupsByWorktree, + hydrated.groupsByWorktree, + replaceWorkspaceKeys + ), + activeGroupIdByWorktree: replaceWorkspaceRecordKeys( + current.activeGroupIdByWorktree, + hydrated.activeGroupIdByWorktree, + replaceWorkspaceKeys + ), + layoutByWorktree: replaceWorkspaceRecordKeys( + current.layoutByWorktree, + hydrated.layoutByWorktree, + replaceWorkspaceKeys ) - let intents = current.explicitlySleptChatsByWorkspace - for (const worktreeId of replaceWorkspaceKeys) { - intents = withoutReplacedSleptChats(intents, unifiedTabsByWorktree, worktreeId) - } - return { - unifiedTabsByWorktree, - explicitlySleptChatsByWorkspace: intents, - groupsByWorktree: replaceWorkspaceRecordKeys( - current.groupsByWorktree, - hydrated.groupsByWorktree, - replaceWorkspaceKeys - ), - activeGroupIdByWorktree: replaceWorkspaceRecordKeys( - current.activeGroupIdByWorktree, - hydrated.activeGroupIdByWorktree, - replaceWorkspaceKeys - ), - layoutByWorktree: replaceWorkspaceRecordKeys( - current.layoutByWorktree, - hydrated.layoutByWorktree, - replaceWorkspaceKeys - ) - } - }) + })) } } } diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index ff63fe4e25f..a3aa6173b76 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -44,10 +44,6 @@ import type { WorktreeCreationPhase } from '@/lib/pending-worktree-creation' import type { AppState } from '../types' -import type { - ExplicitlySleptChatsByWorkspace, - SleptChatIdentity -} from '../../../../shared/workspace-chat-sleep' import type { WorktreeRefreshAllOptions } from './worktree-refresh-options' export type { WorktreePurgeTarget, WorktreePurgeTargets } from './worktree-purge-target' import type { WorktreePurgeTargets } from './worktree-purge-target' @@ -110,17 +106,6 @@ export type WorktreeSlice = { activeWorktreeId: string | null activeWorkspaceKey: WorkspaceKey | null activeWorkspaceExecutionHostId: ExecutionHostId | null - explicitlySleptChatsByWorkspace: ExplicitlySleptChatsByWorkspace - markWorkspaceChatsSlept: ( - worktreeId: string, - hostId: ExecutionHostId, - chats: readonly SleptChatIdentity[] - ) => void - /** Without a scope, clears the workspace's record on every host. */ - clearWorkspaceChatsSlept: ( - worktreeId: string, - scope?: { hostId: ExecutionHostId; chats: readonly SleptChatIdentity[] } - ) => void /** * In-flight / failed background worktree creations, keyed by a renderer * `creationId`. Kept separate from `worktreesByRepo` on purpose — a real diff --git a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts index 29fe1640afa..b845b76e594 100644 --- a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts +++ b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' -import type { Tab } from '../../../../shared/tab-types' import { registerPersistentWebview, unregisterPersistentWebview @@ -57,44 +56,6 @@ describe('folder workspace lookups', () => { }) describe('setActiveWorktree focus handling', () => { - it('wakes every host copy of a shared workspace id when either copy opens', () => { - const store = createTestStore() - const worktreeId = 'repo1::/same' - const tab = (id: string, host: 'ssh:one' | 'ssh:two'): Tab => ({ - id, - entityId: id, - groupId: 'group', - worktreeId, - executionHostId: host, - contentType: 'agent-session', - agentSessionAgent: 'claude', - label: id, - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 - }) - store.setState({ - worktreesByRepo: { - repo1: [ - makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'ssh:one' }), - makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'ssh:two' }) - ] - }, - unifiedTabsByWorktree: { [worktreeId]: [tab('one', 'ssh:one'), tab('two', 'ssh:two')] } - }) - store - .getState() - .markWorkspaceChatsSlept(worktreeId, 'ssh:one', [{ tabId: 'one', sessionId: 'one' }]) - store - .getState() - .markWorkspaceChatsSlept(worktreeId, 'ssh:two', [{ tabId: 'two', sessionId: 'two' }]) - - store.getState().setActiveWorktree(worktreeId, 'ssh:two') - - expect(store.getState().explicitlySleptChatsByWorkspace).toEqual({}) - }) - beforeEach(() => { vi.clearAllMocks() resetRemoteRuntimeMocks() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index e967c331a70..423a1466a09 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -3,7 +3,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { WorktreeSlice } from './worktree-helpers' import { worktreeSliceInitialState } from './worktrees/session/worktree-slice-initial-state' -import { createWorkspaceChatSleepActions } from './worktrees/session/workspace-chat-sleep' import { createFetchDetectedWorktrees } from './worktrees/listing/fetch-detected-worktrees' import { createFetchWorktrees } from './worktrees/listing/fetch-worktrees' import { createFetchAllWorktrees } from './worktrees/listing/fetch-all-worktrees' @@ -71,7 +70,6 @@ export { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees/l export const createWorktreeSlice: StateCreator = (set, get) => ({ ...worktreeSliceInitialState, - ...createWorkspaceChatSleepActions(set), fetchDetectedWorktrees: createFetchDetectedWorktrees(set, get), fetchWorktrees: createFetchWorktrees(set, get), fetchAllWorktrees: createFetchAllWorktrees(set, get), diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts index 75ce554b4c4..2b18b7bb21e 100644 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts @@ -18,8 +18,6 @@ import { import { findKnownWorktreeById } from '../listing/detected-worktree-meta' import { resolveActivatedWorktreeSurface } from './active-worktree-surface' import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' -import { withoutSleptChatsForWorkspaces } from '../../../../../../shared/workspace-chat-sleep' -import { supersedeChatSleepAttempt } from './workspace-chat-sleep-attempt' import { pendingActivationTerminalPrepCancels, shouldDeferActivationTerminalPrep @@ -118,10 +116,6 @@ export function createSetActiveWorktree( allDead && worktreeId != null ? markWorktreeTabsForActivationSpawn(s, worktreeId, { isWakeable, bumpGeneration: true }) : {} - // Why every host: opening either copy wakes the whole workspace, as it does its terminals. - const nextSleptChats = withoutSleptChatsForWorkspaces(s.explicitlySleptChatsByWorkspace, [ - { id: worktreeId } - ]) const nextActiveTabTypeByWorktree = s.activeTabTypeByWorktree[worktreeId] === activeTabType @@ -140,7 +134,6 @@ export function createSetActiveWorktree( nextActiveTabTypeByWorktree !== s.activeTabTypeByWorktree || nextEverActivated !== s.everActivatedWorktreeIds || nextActiveRepoId !== s.activeRepoId || - nextSleptChats !== s.explicitlySleptChatsByWorkspace || allDead || reconciliationChanged || stateTransition !== undefined @@ -166,19 +159,13 @@ export function createSetActiveWorktree( rightSidebarExplorerView: restoredRightSidebarExplorerView, activeTabId, everActivatedWorktreeIds: nextEverActivated, - ...tabsByWorktreeUpdate, - ...(nextSleptChats !== s.explicitlySleptChatsByWorkspace - ? { explicitlySleptChatsByWorkspace: nextSleptChats } - : {}) + ...tabsByWorktreeUpdate } }) // Why: any activation is an explicit wake (null is the sleep flow clearing selection). // Cleared after the set() above so a pane still waiting on the marker connects once, // in the remounted generation, instead of connecting and then being remounted. - if (worktreeId) { - supersedeChatSleepAttempt(worktreeId) - } clearWorktreeSleepIntent(worktreeId) if (worktreeId && shouldPrepareTerminalTabs) { diff --git a/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep-attempt.ts b/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep-attempt.ts deleted file mode 100644 index 5433d0c3b54..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep-attempt.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Why apart from the terminal sleep marker: that marker ignores wakes while teardown runs, -// but the user's latest open or Sleep must decide whether an earlier Sleep may hide chats. -const currentAttemptByWorkspaceId = new Map() - -/** A newer explicit Sleep of the same workspace replaces the older one. */ -export function beginChatSleepAttempt(workspaceId: string): symbol { - const attempt = Symbol(workspaceId) - currentAttemptByWorkspaceId.set(workspaceId, attempt) - return attempt -} - -export function supersedeChatSleepAttempt(workspaceId: string): void { - currentAttemptByWorkspaceId.delete(workspaceId) -} - -export function isCurrentChatSleepAttempt(workspaceId: string, attempt: symbol): boolean { - return currentAttemptByWorkspaceId.get(workspaceId) === attempt -} - -export function endChatSleepAttempt(workspaceId: string, attempt: symbol): void { - if (isCurrentChatSleepAttempt(workspaceId, attempt)) { - currentAttemptByWorkspaceId.delete(workspaceId) - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep.ts b/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep.ts deleted file mode 100644 index a762f500347..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/workspace-chat-sleep.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceSet } from '../listing/worktree-slice-types' -import { isStructuredTab } from '../../../../../../shared/structured-agent-session-tab' -import { - sleptChatWorkspaceKey, - withoutSleptChatsForWorkspaces, - type SleptChatIdentity -} from '../../../../../../shared/workspace-chat-sleep' - -export function createWorkspaceChatSleepActions( - set: WorktreeSliceSet -): Pick { - return { - markWorkspaceChatsSlept: (worktreeId, hostId, chats) => - set((state) => { - const key = sleptChatWorkspaceKey(worktreeId, hostId) - const current = state.explicitlySleptChatsByWorkspace[key] ?? [] - const accepted: SleptChatIdentity[] = [] - for (const chat of chats) { - const tab = state.unifiedTabsByWorktree[worktreeId]?.find( - (candidate) => candidate.id === chat.tabId - ) - if ( - !tab || - !isStructuredTab(tab) || - tab.entityId !== chat.sessionId || - tab.executionHostId !== hostId - ) { - continue - } - if ( - !current.some( - (entry) => entry.tabId === chat.tabId && entry.sessionId === chat.sessionId - ) - ) { - accepted.push({ tabId: chat.tabId, sessionId: chat.sessionId }) - } - } - if (accepted.length === 0) { - return state - } - return { - explicitlySleptChatsByWorkspace: { - ...state.explicitlySleptChatsByWorkspace, - [key]: [...current, ...accepted] - } - } - }), - clearWorkspaceChatsSlept: (worktreeId, scope) => - set((state) => { - if (!scope) { - const next = withoutSleptChatsForWorkspaces(state.explicitlySleptChatsByWorkspace, [ - { id: worktreeId } - ]) - return next === state.explicitlySleptChatsByWorkspace - ? state - : { explicitlySleptChatsByWorkspace: next } - } - const { hostId, chats } = scope - const key = sleptChatWorkspaceKey(worktreeId, hostId) - const current = state.explicitlySleptChatsByWorkspace[key] - if (!current) { - return state - } - const retained = current.filter( - (entry) => - !chats.some((chat) => chat.tabId === entry.tabId && chat.sessionId === entry.sessionId) - ) - if (retained.length === current.length) { - return state - } - const next = { ...state.explicitlySleptChatsByWorkspace } - if (retained.length > 0) { - next[key] = retained - } else { - delete next[key] - } - return { explicitlySleptChatsByWorkspace: next } - }) - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts index 3fec0370705..0fa6af89996 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts @@ -7,7 +7,6 @@ import { remapBrowserPageDocLocation } from '../../../../../../shared/browser-pa import { splitWorktreeIdForFilesystem } from '../../../../../../shared/worktree/id' import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency' -import { withRenamedSleptChatWorkspace } from '../../../../../../shared/workspace-chat-sleep' import { remapClosedTerminalTabSnapshotCwds, type ClosedTerminalTabSnapshot @@ -224,18 +223,9 @@ export function buildWorktreeRenameState( ) : s.sleepingAgentSessionsByPaneKey - const explicitlySleptChatsByWorkspace = withRenamedSleptChatWorkspace( - s.explicitlySleptChatsByWorkspace, - oldWorktreeId, - newWorktreeId - ) - return { ...renameWorktreeLayoutKeys(s, oldWorktreeId, newWorktreeId, withNewBrowserWorktreeId), ...(renamed as Partial), - ...(explicitlySleptChatsByWorkspace !== s.explicitlySleptChatsByWorkspace - ? { explicitlySleptChatsByWorkspace } - : {}), ...(browserPagesByWorkspace !== s.browserPagesByWorkspace ? { browserPagesByWorkspace } : {}), ...(recentlyClosedBrowserPagesByWorkspace !== s.recentlyClosedBrowserPagesByWorkspace ? { recentlyClosedBrowserPagesByWorkspace } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts index 0db147b909c..19f760ae738 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts @@ -10,7 +10,6 @@ export const worktreeSliceInitialState: Pick< | 'activeWorktreeId' | 'activeWorkspaceKey' | 'activeWorkspaceExecutionHostId' - | 'explicitlySleptChatsByWorkspace' | 'pendingWorktreeCreations' | 'activePendingCreationId' | 'renamingWorktreeId' @@ -33,7 +32,6 @@ export const worktreeSliceInitialState: Pick< activeWorktreeId: null, activeWorkspaceKey: null, activeWorkspaceExecutionHostId: null, - explicitlySleptChatsByWorkspace: {}, pendingWorktreeCreations: {}, activePendingCreationId: null, renamingWorktreeId: null, diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index 1742083769f..bdd36325980 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -1,5 +1,4 @@ import type { WorktreePurgeTarget, WorktreeSlice } from '../../worktree-helpers' -import type { AppState } from '../../../types' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import type { ProjectHostSetup } from '../../../../../../shared/project-types' import type { Repo } from '../../../../../../shared/repo-types' @@ -19,10 +18,6 @@ import { import { buildFullWorktreePurgeState } from './worktree-purge-state' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { settleAttentionRemovedBy } from '../../workspace-attention/attention-removal-follow-up' -import { - withoutSleptChatsForHosts, - withoutSleptChatsForWorkspaces -} from '../../../../../../shared/workspace-chat-sleep' export function createPurgeStaleRuntimeHostState( set: WorktreeSliceSet, @@ -217,18 +212,8 @@ export function createPurgeStaleRuntimeHostState( return targets.length > 0 ? targets : [{ id: worktreeId }] }) // Why full: survivor ids were dropped above unless the session partition proves the bare-id state is the removed host's. - const purgeState: Partial = + const purgeState = purgeTargets.length > 0 ? buildFullWorktreePurgeState(s, purgeTargets) : {} - const nextSleptChats = withoutSleptChatsForHosts( - withoutSleptChatsForWorkspaces( - purgeState.explicitlySleptChatsByWorkspace ?? s.explicitlySleptChatsByWorkspace, - removedWorktreeTargets - ), - new Set([...removed].map(toRuntimeExecutionHostId)) - ) - if (nextSleptChats !== s.explicitlySleptChatsByWorkspace) { - purgeState.explicitlySleptChatsByWorkspace = nextSleptChats - } const visitPurgeTargets = [ ...removedWorktreeTargets, ...purgeTargets.filter( @@ -273,7 +258,6 @@ export function createPurgeStaleRuntimeHostState( !visibilityDefaultsChanged && !visibilitySupportChanged && !visibilitySourceSupportChanged && - nextSleptChats === s.explicitlySleptChatsByWorkspace && removedWorktreeIds.size === 0 ) { return s diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts index 85bf7bce901..673d2760bd2 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-map-identity.test.ts @@ -25,7 +25,6 @@ function buildState(): AppState { layoutByWorktree: { [SURVIVING_ID]: 'grid' }, groupsByWorktree: {}, unifiedTabsByWorktree: {}, - explicitlySleptChatsByWorkspace: {}, // Tab-keyed maps with no entry for the removed worktree's tabs. terminalLayoutsByTabId: { 'other-tab': 'single' }, ptyIdsByTabId: {}, @@ -66,7 +65,6 @@ describe('removeWorktree map identity', () => { 'layoutByWorktree', 'groupsByWorktree', 'unifiedTabsByWorktree', - 'explicitlySleptChatsByWorkspace', 'terminalLayoutsByTabId', 'ptyIdsByTabId', 'expandedPaneByTabId', @@ -81,11 +79,7 @@ describe('removeWorktree map identity', () => { const before = buildState() Object.assign(before, { gitStatusByWorktree: { [REMOVED_ID]: 'dirty', [SURVIVING_ID]: 'clean' }, - terminalLayoutsByTabId: { 'removed-tab': 'single', 'other-tab': 'single' }, - explicitlySleptChatsByWorkspace: { - [`local|${REMOVED_ID}`]: [{ tabId: 'removed-tab', sessionId: 's' }], - [`local|${SURVIVING_ID}`]: [{ tabId: 'other-tab', sessionId: 's' }] - } + terminalLayoutsByTabId: { 'removed-tab': 'single', 'other-tab': 'single' } }) const after = removeWorktree(before) @@ -94,9 +88,6 @@ describe('removeWorktree map identity', () => { expect(after.gitStatusByWorktree).toEqual({ [SURVIVING_ID]: 'clean' }) expect(after.terminalLayoutsByTabId).toEqual({ 'other-tab': 'single' }) expect(after.tabsByWorktree).toEqual({ [SURVIVING_ID]: [] }) - expect(after.explicitlySleptChatsByWorkspace).toEqual({ - [`local|${SURVIVING_ID}`]: [{ tabId: 'other-tab', sessionId: 's' }] - }) }) it('drops sleeping records written for the removed worktree after its terminals stopped (#24784)', () => { diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index 783e52b10da..e180780a6b1 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -7,7 +7,6 @@ import { forgetAmbiguousOwnerWarnings } from '@/lib/ambiguous-owner-warning' import { omitRecordKeys } from './record-key-omission' import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle' import { omitSleepingAgentSessionsForWorktrees } from './sleeping-agent-session-omission' -import { withoutSleptChatsForWorkspaces } from '../../../../../../shared/workspace-chat-sleep' /** Clears worktree-owned renderer state after successful removal so a reused identity cannot inherit stale preferences. */ export function applyRemoveWorktreeSuccessState( @@ -89,10 +88,6 @@ export function applyRemoveWorktreeSuccessState( recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree), rightSidebarExplorerViewByWorktree: omitByWorktree(s.rightSidebarExplorerViewByWorktree), pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree), - explicitlySleptChatsByWorkspace: withoutSleptChatsForWorkspaces( - s.explicitlySleptChatsByWorkspace, - [{ id: worktreeId, hostId: executionHostId }] - ), editorDrafts: omitByFileId(s.editorDrafts), markdownViewMode: omitByFileId(s.markdownViewMode), markdownRichModeSizeOverride: omitByFileId(s.markdownRichModeSizeOverride), diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index d18d86f2740..3a73578c0b3 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -11,7 +11,6 @@ import { omitRecordKeys } from './record-key-omission' import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency' import { forgetAmbiguousOwnerWarnings } from '@/lib/ambiguous-owner-warning' import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' -import { withoutSleptChatsForWorkspaces } from '../../../../../../shared/workspace-chat-sleep' import { removeWorktreeLayout } from '../../tabs/worktree-layout-lifecycle' import { cancelStructuredLaunchesForWorktrees, @@ -60,10 +59,6 @@ export function buildWorktreePurgeState( deleteStateByWorktreeId: omitRecordKeys( next.deleteStateByWorktreeId, hostKeys.map((target) => composeWorktreeHostIdentity(target.hostId, target.id)) - ), - explicitlySleptChatsByWorkspace: withoutSleptChatsForWorkspaces( - next.explicitlySleptChatsByWorkspace, - hostKeys ) } } @@ -157,10 +152,6 @@ export function buildFullWorktreePurgeState( pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree), rightSidebarTabByWorktree: pruneRightSidebarTabByWorktree(), rightSidebarExplorerViewByWorktree: omitByWorktree(s.rightSidebarExplorerViewByWorktree ?? {}), - explicitlySleptChatsByWorkspace: withoutSleptChatsForWorkspaces( - s.explicitlySleptChatsByWorkspace, - [...worktreeIdSet].map((id) => ({ id })) - ), // Git status caches gitStatusByWorktree: omitByWorktree(s.gitStatusByWorktree), // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (upstream-status entry). diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index 6a042a268ce..06eb392f30c 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -18,7 +18,6 @@ export type WorkspaceHydrationPatch = Pick< | 'tabsByWorktree' | 'worktreesByRepo' | 'lastVisitedAtByWorktreeId' - | 'explicitlySleptChatsByWorkspace' | 'defaultTerminalTabsAppliedByWorktreeId' | 'closedTerminalTabTombstonesByTabId' | 'automaticAgentResumeClaimsByTabId' @@ -166,8 +165,6 @@ export function targetScopedWorkspaceHydrationPatch( hydrated.lastVisitedAtByWorktreeId, workspaceKeys ), - // Client-only global intent is loaded once from the local desktop, never from a host pull. - explicitlySleptChatsByWorkspace: state.explicitlySleptChatsByWorkspace, // Why local marks win: the applied mark is write-once. defaultTerminalTabsAppliedByWorktreeId: { ...replaceHydratedRecordKeys( diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 60cd5c928ce..00856998b6d 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -168,8 +168,6 @@ export function createWorkspaceTerminalHydrationActions( worktreesByRepo, // Why: restore the focus-recency map; pruning is deferred to App.tsx (post-hydration) because SSH worktrees may still be appearing in worktreesByRepo. lastVisitedAtByWorktreeId: session.lastVisitedAtByWorktreeId ?? {}, - explicitlySleptChatsByWorkspace: - session.explicitlySleptChatsByWorkspace ?? s.explicitlySleptChatsByWorkspace, // Why union: a persist snapshot that omits the write-once marker is a writer that never // stamped it, not a report that default tabs were never applied (#18117). defaultTerminalTabsAppliedByWorktreeId: { diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index de94bcb5167..3fb33c3e496 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -47,6 +47,7 @@ import type { TaskCreationDraftsSlice } from './slices/task-creation-drafts' import type { RemoteServerUpdatesSlice } from './slices/remote-server-updates' import type { TerminalQuickCommandHostsSlice } from './slices/terminal-quick-command-hosts' import type { StructuredSessionLaunchDirectorySlice } from './slices/structured-session-launch-directories' +import type { StructuredChatSleptAtSlice } from './slices/structured-chat-slept-at' import type { WorkspaceAttentionSlice } from './slices/workspace-attention/workspace-attention-contract' export type AppState = RepoSlice & @@ -96,4 +97,5 @@ export type AppState = RepoSlice & RemoteServerUpdatesSlice & TerminalQuickCommandHostsSlice & StructuredSessionLaunchDirectorySlice & + StructuredChatSleptAtSlice & WorkspaceAttentionSlice diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index 8af3fa4ca5d..a788f246bab 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -70,7 +70,6 @@ export type Tab = { isPinned?: boolean // pinned tabs survive "close others" /** Provider backing a structured agent-session tab. */ agentSessionAgent?: AgentType - /** When Sleep stopped this chat's agent; activating its workspace clears it. Client-only. */ /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the * native chat view as an overlay while the live terminal stays mounted * underneath; `'terminal'` (the default for legacy/missing) shows the raw diff --git a/src/shared/workspace-chat-sleep-schema.ts b/src/shared/workspace-chat-sleep-schema.ts deleted file mode 100644 index 8f97290292c..00000000000 --- a/src/shared/workspace-chat-sleep-schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { z } from 'zod' -import { salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' - -export const explicitlySleptChatsByWorkspaceSchema = salvagingRecord( - z.string(), - salvagingArray(z.object({ tabId: z.string().min(1), sessionId: z.string().min(1) })) -) - -export const workspaceChatSleepSessionFieldSchema = { - explicitlySleptChatsByWorkspace: salvagedOptional( - 'explicitlySleptChatsByWorkspace', - explicitlySleptChatsByWorkspaceSchema - ) -} diff --git a/src/shared/workspace-chat-sleep.test.ts b/src/shared/workspace-chat-sleep.test.ts deleted file mode 100644 index b3a76d63ba3..00000000000 --- a/src/shared/workspace-chat-sleep.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from 'vitest' -import type { Tab } from './tab-types' -import { withoutReplacedSleptChats } from './workspace-chat-sleep' - -it('retires only a replaced host chat and keeps absent disconnected chats', () => { - const workspace = 'repo::/same' - const intents = { - [`runtime:a|${workspace}`]: [{ tabId: 'same', sessionId: 'old' }], - [`runtime:b|${workspace}`]: [{ tabId: 'same', sessionId: 'other' }], - [`runtime:c|${workspace}`]: [{ tabId: 'absent', sessionId: 'offline' }] - } - const replacement: Tab = { - id: 'same', - entityId: 'new', - executionHostId: 'runtime:a', - worktreeId: workspace, - groupId: 'group', - contentType: 'agent-session', - agentSessionAgent: 'claude', - label: 'Chat', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 0 - } - - expect(withoutReplacedSleptChats(intents, { [workspace]: [replacement] }, workspace)).toEqual({ - [`runtime:b|${workspace}`]: intents[`runtime:b|${workspace}`], - [`runtime:c|${workspace}`]: intents[`runtime:c|${workspace}`] - }) -}) diff --git a/src/shared/workspace-chat-sleep.ts b/src/shared/workspace-chat-sleep.ts deleted file mode 100644 index 956c7191167..00000000000 --- a/src/shared/workspace-chat-sleep.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { ExecutionHostId } from './execution-host' -import type { Tab } from './tab-types' -import type { WorkspaceSessionState } from './workspace-session-state-types' -import { - composeWorktreeHostIdentity, - getExecutionHostIdFromWorktreeHostIdentity, - getWorktreeIdFromHostIdentity -} from './worktree/host-qualified-identity' - -export type SleptChatIdentity = { tabId: string; sessionId: string } -export type ExplicitlySleptChatsByWorkspace = Record - -export function sleptChatWorkspaceKey(worktreeId: string, hostId: ExecutionHostId): string { - return composeWorktreeHostIdentity(hostId, worktreeId) -} - -export function isChatExplicitlySlept(intents: ExplicitlySleptChatsByWorkspace, tab: Tab): boolean { - if (!tab.executionHostId || tab.contentType !== 'agent-session') { - return false - } - return ( - intents[sleptChatWorkspaceKey(tab.worktreeId, tab.executionHostId)]?.some( - (entry) => entry.tabId === tab.id && entry.sessionId === tab.entityId - ) ?? false - ) -} - -export function withoutSleptChatsForWorkspaces( - intents: ExplicitlySleptChatsByWorkspace, - targets: readonly { id: string; hostId?: ExecutionHostId }[] -): ExplicitlySleptChatsByWorkspace { - const next = { ...intents } - let changed = false - for (const key of Object.keys(next)) { - if ( - targets.some( - (target) => - target.id === getWorktreeIdFromHostIdentity(key) && - (!target.hostId || key === sleptChatWorkspaceKey(target.id, target.hostId)) - ) - ) { - delete next[key] - changed = true - } - } - return changed ? next : intents -} - -export function withoutSleptChatsForHosts( - intents: ExplicitlySleptChatsByWorkspace, - hosts: ReadonlySet -): ExplicitlySleptChatsByWorkspace { - let next = intents - for (const key of Object.keys(intents)) { - const hostId = getExecutionHostIdFromWorktreeHostIdentity(key) - if (!hostId || !hosts.has(hostId)) { - continue - } - next = next === intents ? { ...intents } : next - delete next[key] - } - return next -} - -export function withoutReplacedSleptChats( - intents: ExplicitlySleptChatsByWorkspace, - tabsByWorktree: Record, - worktreeId: string -): ExplicitlySleptChatsByWorkspace { - const tabs = tabsByWorktree[worktreeId] - if (!tabs) { - return intents - } - let next = intents - for (const [key, entries] of Object.entries(intents)) { - if (getWorktreeIdFromHostIdentity(key) !== worktreeId) { - continue - } - const hostId = getExecutionHostIdFromWorktreeHostIdentity(key) - const retained = entries.filter((entry) => { - const tab = tabs.find( - (candidate) => candidate.id === entry.tabId && candidate.executionHostId === hostId - ) - if (!tab) { - return true - } - return tab.entityId === entry.sessionId - }) - if (retained.length === entries.length) { - continue - } - next = next === intents ? { ...intents } : next - if (retained.length) { - next[key] = retained - } else { - delete next[key] - } - } - return next -} - -export function withRenamedSleptChatWorkspace( - intents: ExplicitlySleptChatsByWorkspace, - oldId: string, - newId: string -): ExplicitlySleptChatsByWorkspace { - let next = intents - for (const [key, entries] of Object.entries(intents)) { - if (getWorktreeIdFromHostIdentity(key) !== oldId) { - continue - } - next = next === intents ? { ...intents } : next - next[`${key.slice(0, key.length - oldId.length)}${newId}`] = entries - delete next[key] - } - return next -} - -export function renameSleptChatWorkspaceInSession( - session: WorkspaceSessionState, - oldId: string, - newId: string -): boolean { - const current = session.explicitlySleptChatsByWorkspace - if (!current) { - return false - } - const renamed = withRenamedSleptChatWorkspace(current, oldId, newId) - session.explicitlySleptChatsByWorkspace = renamed - return renamed !== current -} diff --git a/src/shared/workspace-layout/workspace-layout-beside.ts b/src/shared/workspace-layout/workspace-layout-beside.ts index 20ba3511723..0fa036ddd2b 100644 --- a/src/shared/workspace-layout/workspace-layout-beside.ts +++ b/src/shared/workspace-layout/workspace-layout-beside.ts @@ -43,7 +43,6 @@ export const SESSION_FIELD_OWNERS = { lastVisitedAtByWorktreeId: 'view', defaultTerminalTabsAppliedByWorktreeId: 'layout', sleepingAgentSessionsByPaneKey: 'layout', - explicitlySleptChatsByWorkspace: 'view', terminalPtyIncarnationsByPaneKey: 'layout', terminalTopologyRevisionByRepoId: 'layout', // Applied and cleared by the Loader; never written again. diff --git a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts index 6bf8d5332ba..ce8523166aa 100644 --- a/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts +++ b/src/shared/workspace-layout/workspace-layout-fact-locations.test.ts @@ -149,9 +149,6 @@ function populate(loaded: LoadedWorkspaceLayout): LoadedWorkspaceLayout { fill(loaded, 'tab.agentSessionAgent', 'claude', terminal) fill(loaded, 'view.lastFocusedAt', { [GIT_KEY]: { 'tab-agent': 1, 'browser-1': 2 } }) fill(loaded, 'view.markdownFrontmatterVisible', { '/doc.md': true }) - fill(loaded, 'view.explicitlySleptChatsByWorkspace', { - [`local|${GIT_KEY}`]: [{ tabId: 'tab-agent', sessionId: 's' }] - }) fill(loaded, 'carried.activeConnectionIdsAtShutdown', ['target-1', 'target-2']) fill(loaded, 'carried.browserUrlHistory', [{ url: 'a' }, { url: 'b' }]) fill(loaded, 'carried.workspaceDocHistory', [{ path: 'a' }, { path: 'b' }]) diff --git a/src/shared/workspace-layout/workspace-layout-round-trip.test.ts b/src/shared/workspace-layout/workspace-layout-round-trip.test.ts index 5f6500741ff..96567a9fe64 100644 --- a/src/shared/workspace-layout/workspace-layout-round-trip.test.ts +++ b/src/shared/workspace-layout/workspace-layout-round-trip.test.ts @@ -37,18 +37,6 @@ const PROFILES = WORKSPACE_LAYOUT_PROFILES.map( ) describe('workspace layout Loader and Serializer', () => { - it('round trips explicit chat Sleep as desktop view state', () => { - const stored = localDesktopSession() - stored.explicitlySleptChatsByWorkspace = { - 'local|repo-1::/Users/dev/orca': [{ tabId: 'agent-session-1', sessionId: 'agent-session-1' }] - } - const { loaded, saved } = roundTrip(LOCAL_EXECUTION_HOST_ID, stored) - expect(loaded.desktopView.explicitlySleptChatsByWorkspace).toEqual( - stored.explicitlySleptChatsByWorkspace - ) - expect(JSON.stringify(loaded.layout)).not.toContain('explicitlySleptChatsByWorkspace') - expect(saved.explicitlySleptChatsByWorkspace).toEqual(stored.explicitlySleptChatsByWorkspace) - }) it.each(PROFILES)( '%s: save(load(stored)) writes exactly what was stored', (_name, hostId, build) => { diff --git a/src/shared/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts index acf1ecc32e1..ce8ce0f75b3 100644 --- a/src/shared/workspace-session-host-field-ownership.ts +++ b/src/shared/workspace-session-host-field-ownership.ts @@ -51,7 +51,6 @@ export const WORKSPACE_SESSION_FIELD_OWNERSHIP = { browserPagesByWorkspace: 'browserWorkspaceKeyed', markdownFrontmatterVisible: 'fileKeyed', sleepingAgentSessionsByPaneKey: 'sleepingAgentKeyed', - explicitlySleptChatsByWorkspace: 'global', terminalPtyIncarnationsByPaneKey: 'paneKeyed', // Why: this host-issued fence must never collide while unified renderer state merges equal repo ids across hosts. terminalTopologyRevisionByRepoId: 'hostPrivate', @@ -103,8 +102,7 @@ export const GLOBAL_WORKSPACE_SESSION_FIELDS = ( */ export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [ 'browserUrlHistory', - 'workspaceDocHistory', - 'explicitlySleptChatsByWorkspace' + 'workspaceDocHistory' ] as const satisfies readonly (keyof WorkspaceSessionState)[] /** Serialize-side sweep over every non-local partition. The load path re-seeds these fields at diff --git a/src/shared/workspace-session-schema-field-coverage.test.ts b/src/shared/workspace-session-schema-field-coverage.test.ts index 8b8e9ae88ad..c7001060b7b 100644 --- a/src/shared/workspace-session-schema-field-coverage.test.ts +++ b/src/shared/workspace-session-schema-field-coverage.test.ts @@ -51,7 +51,6 @@ const PERSISTED_WORKSPACE_SESSION_FIELDS = { lastVisitedAtByWorktreeId: true, defaultTerminalTabsAppliedByWorktreeId: true, sleepingAgentSessionsByPaneKey: true, - explicitlySleptChatsByWorkspace: true, terminalPtyIncarnationsByPaneKey: true, terminalTopologyRevisionByRepoId: true, terminalSurfaceTombstonesByPaneKey: true, @@ -105,20 +104,6 @@ describe('workspaceSessionStateSchema field coverage', () => { }) }) - it('loads explicit chat sleep intent without a tab layout field', () => { - const parsed = parseWorkspaceSession({ - ...MINIMAL_SESSION, - explicitlySleptChatsByWorkspace: { - 'local|repo:wt-1': [{ tabId: 'chat-tab', sessionId: 'session-1' }] - } - }) - - expect(parsed.ok).toBe(true) - expect(parsed.ok && parsed.value.explicitlySleptChatsByWorkspace).toEqual({ - 'local|repo:wt-1': [{ tabId: 'chat-tab', sessionId: 'session-1' }] - }) - }) - it('salvages a malformed tombstone entry rather than dropping the whole map', () => { const parsed = parseWorkspaceSession({ ...MINIMAL_SESSION, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 55551fcd30d..73fc668fd40 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -33,7 +33,6 @@ import { clientHostedBrowserCloseIntentSchema } from './client-hosted-browser-cl import { persistedClientHostedBrowserPageSchema } from './client-hosted-browser-page-record' import { persistedOpenFileSchema } from './workspace-session-editor-schema' import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents' -import { workspaceChatSleepSessionFieldSchema } from './workspace-chat-sleep-schema' import { tabContentTypeSchema, workspaceVisibleTabTypeSchema @@ -310,7 +309,6 @@ export const workspaceSessionStateSchema: z.ZodType = z.o 'sleepingAgentSessionsByPaneKey', sleepingAgentSessionsByPaneKeySchema ), - ...workspaceChatSleepSessionFieldSchema, terminalPtyIncarnationsByPaneKey: salvagedOptional( 'terminalPtyIncarnationsByPaneKey', salvagingRecord(z.string(), z.string().min(1).max(128)) diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index c255524f1f1..bd2bb3fb38a 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -8,7 +8,6 @@ import type { WorkspaceDocHistoryEntry } from './workspace-doc-history' import type { ClientHostedBrowserCloseIntent } from './client-hosted-browser-close-intent' import type { PersistedClientHostedBrowserPage } from './client-hosted-browser-page-record' import type { ClosedTerminalTabTombstonesByTabId } from './closed-terminal-tab-tombstones' -import type { ExplicitlySleptChatsByWorkspace } from './workspace-chat-sleep' /** Minimal subset of OpenFile persisted across restarts. * Only edit-mode files are saved — diffs, conflict reviews, and other @@ -133,8 +132,6 @@ export type WorkspaceSessionState = { defaultTerminalTabsAppliedByWorktreeId?: Record /** Provider-session resume records captured when workspaces sleep. */ sleepingAgentSessionsByPaneKey?: Record - /** Client view intent from explicit Sleep, keyed by execution host and workspace. */ - explicitlySleptChatsByWorkspace?: ExplicitlySleptChatsByWorkspace /** Host-issued process incarnation for each durable terminal surface. */ terminalPtyIncarnationsByPaneKey?: Record /** Monotonic host authority watermark for terminal membership in each repo. */ diff --git a/src/shared/wsl-move/wsl-move-dispositions.ts b/src/shared/wsl-move/wsl-move-dispositions.ts index 9027d895ef9..09a9ef64533 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.ts @@ -135,10 +135,6 @@ export const WORKSPACE_SESSION_DISPOSITIONS = { browserPagesByWorkspace: moveDisposition(29), markdownFrontmatterVisible: moveDisposition(28), sleepingAgentSessionsByPaneKey: moveDisposition(33), - explicitlySleptChatsByWorkspace: notApplicableDisposition( - 32, - 'Explicit Sleep intent belongs to the old execution host.' - ), terminalPtyIncarnationsByPaneKey: { disposition: 'drop-pty-binding', item: 36 From 34530d0f856f70acc233d9e3c32f525f13d71718 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:48:10 -0700 Subject: [PATCH 06/19] fix(native-chat): preserve host-owned Sleep across fallback and legacy tabs --- ...ured-agent-session-workspace-sleep.test.ts | 30 +++++++++++++++++++ .../rpc/methods/structured-agent-session.ts | 3 ++ .../workspace-lineage-menu-actions.test.ts | 2 +- .../sidebar/workspace-lineage-menu-actions.ts | 6 ++-- .../workspace-activity-shadow.ts | 8 +++-- .../agent-session-record-stored-form.test.ts | 11 +++++++ .../agent-session-record-stored-form.ts | 12 ++++++-- src/shared/agent-session-record.ts | 3 +- 8 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-workspace-sleep.test.ts diff --git a/src/main/runtime/rpc/methods/structured-agent-session-workspace-sleep.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-workspace-sleep.test.ts new file mode 100644 index 00000000000..8f2a984647a --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-workspace-sleep.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + call, + clearStructuredHostStub, + hostCalls, + installStructuredHostStub, + STRUCTURED_CLIENT +} from './structured-agent-session-rpc.test-fixture' + +beforeEach(installStructuredHostStub) +afterEach(clearStructuredHostStub) + +describe('workspace Sleep routing', () => { + it('builds the host for a marked Sleep instead of returning an unmarked fallback receipt', async () => { + clearStructuredHostStub() + const ensureHost = vi.fn(async () => installStructuredHostStub()) + expect( + await call( + 'agentSession.sleepWorkspace', + { workspaceId: 'workspace-1', markAsleep: true }, + STRUCTURED_CLIENT, + { + ensureStructuredAgentSessionHost: ensureHost + } + ) + ).toMatchObject({ ok: true }) + expect(ensureHost).toHaveBeenCalledOnce() + expect(hostCalls.sleepWorkspace).toHaveBeenCalledWith('workspace-1', true) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 0c58549cb15..d61f62e03d4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -173,6 +173,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ params: SleepWorkspaceParams, handler: async (params, ctx) => { requireStructuredCapability(ctx) + if (params.markAsleep) { + return (await requireInstalledHost(ctx)).sleepWorkspace(params.workspaceId, true) + } const installed = getStructuredAgentSessionHost() if (installed) { return installed.sleepWorkspace(params.workspaceId, params.markAsleep) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts index 1c67d1bb737..6c8e8373c12 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.test.ts @@ -177,7 +177,7 @@ describe('hasSleepableWorkspaceActivity', () => { structuredChatSleptAtByPaneKey: sleptAt }) expect(activity(hostHeld(chat))).toBe(true) - expect(activity({})).toBe(false) + expect(activity({})).toBe(true) // An ended chat stays sleepable until its host marks that exact tab's session slept. const ended = { ...chat, executionHostId: 'local' as const } expect(activity({}, ended)).toBe(true) diff --git a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts index f5fc53864b3..93cf58b8dc9 100644 --- a/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts +++ b/src/renderer/src/components/sidebar/workspace-lineage-menu-actions.ts @@ -34,15 +34,13 @@ export function hasSleepableWorkspaceActivity( return ( tabs.some((tab) => tabHasLivePty(activity.ptyIdsByTabId, tab.id)) || (activity.browserTabsByWorktree[worktreeId] ?? []).length > 0 || - // A chat counts while its host holds its process, or until its host marks it slept, so an - // idle-ended chat can still be slept (and hidden) on purpose. + // An idle-ended chat still needs Sleep to hide it, even when its legacy tab has no host stamp. (activity.unifiedTabsByWorktree[worktreeId] ?? []).some( (tab) => isStructuredTab(tab) && (activity.agentStatusByPaneKey[structuredAgentSessionPaneKey(tab.id, tab.entityId)] ?.structuredHostOwned === true || - (!!tab.executionHostId && - !isStructuredChatSlept(activity.structuredChatSleptAtByPaneKey, tab))) + !isStructuredChatSlept(activity.structuredChatSleptAtByPaneKey, tab)) ) ) } diff --git a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts index 052e18047b9..e1bd2767f1f 100644 --- a/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts +++ b/src/renderer/src/lib/sidebar-shadow/workspace-activity-shadow.ts @@ -23,10 +23,14 @@ export function readWindowWorkspaceActivityFacts( | 'browserTabsByWorktree' | 'unifiedTabsByWorktree' | 'agentStatusByPaneKey' - >, + > & + Partial>, now: number ): Map { - const structuredChatIds = getWorktreeIdsWithStructuredChat(state.unifiedTabsByWorktree) + const structuredChatIds = getWorktreeIdsWithStructuredChat( + state.unifiedTabsByWorktree, + state.structuredChatSleptAtByPaneKey + ) const agentById = getLiveAgentStatusByWorktreeId( state.agentStatusByPaneKey, state.tabsByWorktree, diff --git a/src/shared/agent-session-record-stored-form.test.ts b/src/shared/agent-session-record-stored-form.test.ts index af43b89cd16..37f1a873597 100644 --- a/src/shared/agent-session-record-stored-form.test.ts +++ b/src/shared/agent-session-record-stored-form.test.ts @@ -98,6 +98,17 @@ const STORED_CODEX_ROW = JSON.stringify({ }) describe('a record row older builds wrote', () => { + it('drops an invalid Sleep timestamp without losing the conversation', () => { + const stored: unknown = { ...JSON.parse(STORED_CLAUDE_ROW), sleptAt: 'invalid' } + if (!isPersistedAgentSessionRecord(stored)) { + throw new Error('fixture row must be readable') + } + const decoded = decodePersistedAgentSessionRecord(stored) + expect(decoded.normalized).toBe(true) + expect(decoded.record.sleptAt).toBeUndefined() + expect(decoded.record.sessionId).toBe('session-alpha-1') + }) + it.each([ ['Claude', STORED_CLAUDE_ROW], ['Codex', STORED_CODEX_ROW] diff --git a/src/shared/agent-session-record-stored-form.ts b/src/shared/agent-session-record-stored-form.ts index ca6d242d24a..31aff050e65 100644 --- a/src/shared/agent-session-record-stored-form.ts +++ b/src/shared/agent-session-record-stored-form.ts @@ -26,9 +26,17 @@ export function decodePersistedAgentSessionRecord(record: PersistedAgentSessionR if (!providerHandleChain) { throw new Error('agent_session_provider_handle_invalid') } + const { sleptAt, ...rest } = record + const validSleep = typeof sleptAt === 'number' && Number.isFinite(sleptAt) && sleptAt > 0 return { - record: { ...record, providerHandleChain, lease: normalizeLegacyHandoffLease(record.lease) }, - normalized: leaseCarriesLegacyHandoffValues(record.lease) + record: { + ...rest, + ...(validSleep ? { sleptAt } : {}), + providerHandleChain, + lease: normalizeLegacyHandoffLease(record.lease) + }, + normalized: + leaseCarriesLegacyHandoffValues(record.lease) || (sleptAt !== undefined && !validSleep) } } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index e9163079929..dabd4195527 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -163,8 +163,7 @@ export type AgentSessionRecord = { conversationCommand?: AgentSessionConversationCommandRecord /** The name Orca gave this conversation, so a later acquisition need not name it again. */ conversationName?: string - /** When the user's Sleep stopped this chat; the next send it hands over clears it. Not - * validated on load: a bad value only goes unpublished, and must never cost the chat. */ + /** When the user's Sleep stopped this chat; the next send it hands over clears it. */ sleptAt?: number launchArgs?: AgentSessionLaunchArgs lease: AgentSessionLease From 2fac7c21b428e877f40074a2fad45fe596c0825d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:55:27 -0700 Subject: [PATCH 07/19] fix(native-chat): clear Sleep when a new send is admitted --- .../structured-agent-session-host-delivery.ts | 6 ------ ...structured-agent-session-mutation-turn-context.ts | 3 +++ .../structured-agent-session-turns.ts | 2 ++ ...ed-agent-session-workspace-sleep-lifetime.test.ts | 12 +++++++----- .../structured-agent-session-workspace-sleep.ts | 3 +-- .../sidebar/visible-worktree-activity-inputs.ts | 2 +- src/shared/agent-session-record.ts | 2 +- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 6c88ec240e1..dba7c8ca96a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -28,7 +28,6 @@ import { structuredAgentSessionFailureWordsContext } from './structured-agent-se import { recoverStructuredRewind } from './structured-rewind-recovery' import { retireOutgrownStructuredAgentSessionChild } from './structured-agent-session-outgrown-child' import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission' -import { clearStructuredAgentSessionSleepMark } from './structured-agent-session-workspace-sleep' import { acceptedNoLaterThan } from './structured-agent-session-start-failure-row' import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts' @@ -112,11 +111,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { stopAgent: input.stopOutgrownAgent, logger: deps.logger }) - await clearStructuredAgentSessionSleepMark( - deps, - sessionId, - input.clientDelivery.publishStatus - ) }, readiness: input.readiness, deliveryOrder: input.deliveryOrder, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts index e09cf467256..257ad9cac4c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-turn-context.ts @@ -4,6 +4,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' import type { AgentSessionTurnContext } from './structured-agent-session-turns' +import { clearStructuredAgentSessionSleepMark } from './structured-agent-session-workspace-sleep' export function mutationTurnContext( request: Omit, 'plan'>, @@ -34,6 +35,8 @@ export function mutationTurnContext( .then(() => undefined), resolvedBy: request.callerKey, publish: () => request.publish(journal), + onSendAdmitted: () => + clearStructuredAgentSessionSleepMark(request, sessionId, () => request.publish(journal)), now: () => request.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index e6bf58e0730..e2a9f8778db 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -56,6 +56,7 @@ export type AgentSessionTurnContext = { /** Republishes state kept outside the journal, such as the record's options or rewind phase. * Journal appends reach readers on their own. */ publish: () => void + onSendAdmitted?: () => Promise /** Who a Stop's refusal row names. */ failureTextContext?: AgentSessionFailureWordsContext /** The operation's success, committed with the row that accepts it (`settlesWithWrite` or `commandReceipt`). */ @@ -166,6 +167,7 @@ export async function performSend( } return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') } + await ctx.onSendAdmitted?.() return { ok: true, value: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts index f53177c723d..ee4ffc402ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep-lifetime.test.ts @@ -178,14 +178,14 @@ it('marks only the chats a Sleep that asked proved stopped, until the next send' await attach() const send = async (text: string) => { const body = hostTestMessage(text) - expect( - await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - ).toMatchObject({ ok: true }) + const params = { envelope: envelope('agentSession.send', { body }), body } + expect(await host.send(CALLER, params)).toMatchObject({ ok: true }) + return params } // A stop that did not ask (a project move) marks nothing. expect(await host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: ['session-alpha'] }) expect(store.getRecord('session-alpha')?.sleptAt).toBeUndefined() - await send('after the move') + const previous = await send('after the move') await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(2)) expect(await host.sleepWorkspace('workspace-1', true)).toEqual({ @@ -194,11 +194,13 @@ it('marks only the chats a Sleep that asked proved stopped, until the next send' const sleptAt = store.getRecord('session-alpha')?.sleptAt expect(sleptAt).toEqual(expect.any(Number)) expect(host.readStatusSummary('session-alpha')?.sleptAt).toBe(sleptAt) + expect(await host.send(CALLER, previous)).toMatchObject({ ok: true, replayed: true }) + expect(store.getRecord('session-alpha')?.sleptAt).toBe(sleptAt) await send('wake up') - await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(3)) expect(store.getRecord('session-alpha')?.sleptAt).toBeUndefined() expect(host.readStatusSummary('session-alpha')?.sleptAt).toBeUndefined() + await vi.waitFor(() => expect(acquire).toHaveBeenCalledTimes(3)) // A send admitted before the final receipt step leaves the chat awake. const close = host['lifetime'].closeForWorkspaceSleep diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts index 6918cbcb842..663cfd3850d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-workspace-sleep.ts @@ -134,8 +134,7 @@ async function settleWorkspaceSleepChat( return marked ? { stopped: true } : null } -/** A send the host hands over wakes a chat Sleep put to rest, whichever client sent it. Never - * holds the send back: a failed write is only logged. */ +/** A newly recorded send wakes the chat; replay and held drafts never reach this point. */ export async function clearStructuredAgentSessionSleepMark( deps: Pick, sessionId: string, diff --git a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts index e2978408dd7..5fc46ce6db2 100644 --- a/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts +++ b/src/renderer/src/components/sidebar/visible-worktree-activity-inputs.ts @@ -52,7 +52,7 @@ const structuredChatWorktreeIds = new WeakMap< * * Why the host's Sleep mark rather than the provider child: the child starts only on a send and the * host's idle sweep stops it, so keying on it would put every quiet chat workspace to sleep after a - * restart or a pause. Only the user's Sleep marks a chat, and the next send it hands over clears it. + * restart or a pause. Only the user's Sleep marks a chat, and the next admitted send clears it. */ export function getWorktreeIdsWithStructuredChat( unifiedTabsByWorktree: Record | null | undefined, diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index dabd4195527..3bdc2ef0040 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -163,7 +163,7 @@ export type AgentSessionRecord = { conversationCommand?: AgentSessionConversationCommandRecord /** The name Orca gave this conversation, so a later acquisition need not name it again. */ conversationName?: string - /** When the user's Sleep stopped this chat; the next send it hands over clears it. */ + /** When the user's Sleep stopped this chat; the next admitted send clears it. */ sleptAt?: number launchArgs?: AgentSessionLaunchArgs lease: AgentSessionLease From 67a8f65d345c647e54f72c9fb26ba67e8c53a140 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:58:06 -0700 Subject: [PATCH 08/19] chore: restore incoming documentation after merge hook formatting --- .../agent-browser-focus-isolation.md | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/reference/agent-browser-focus-isolation.md b/docs/reference/agent-browser-focus-isolation.md index c969c258efb..07299d98e42 100644 --- a/docs/reference/agent-browser-focus-isolation.md +++ b/docs/reference/agent-browser-focus-isolation.md @@ -17,18 +17,18 @@ A `` guest is an inner WebContents in the window's frame tree. Chromium that tree's focus, not by which debugger the command arrived on. A hidden-window probe (host `