diff --git a/agents-tab-popover-mockup.html b/agents-tab-popover-mockup.html new file mode 100644 index 00000000000..ea16bb8ce32 --- /dev/null +++ b/agents-tab-popover-mockup.html @@ -0,0 +1,56 @@ + + + + + + Agents tab introduction — Orca mockup + + + +
+
Orca · proposed onboarding
+

A quiet introduction to the Agents tab

+

An anchored, one-shot popover keeps the user in context. “Try Agents” opens the tab; “Maybe later” dismisses it without changing settings.

+
+ + +
+
+
+

First-time user Agents view has never been enabled

+
+
+
+
Recommended emphasis: explain the value, then offer a direct action. No “experimental” language for a graduated feature.
+
+
+

Returning user Previously used the experimental Agents view

+
+
+
+
Migration copy reassures returning users that their existing view and preferences are preserved.
+
+
+
+ + + diff --git a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts index 97cd3b2b544..51c6befec30 100644 --- a/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts +++ b/src/main/persistence/loading-store/workspace-session-snapshot-publication.ts @@ -15,6 +15,7 @@ import { registerPersistedPaneKeyAlias } from '../restoring-sessions/pane-alias- import { normalizeWorkspaceSessionPaneIdentities, remapAcknowledgedAgentPaneKeys, + remapActivityClearedAtPaneKeys, remapSshRemotePtyLeaseLeafIds, type WorkspaceSessionPaneIdentityRemap } from '../restoring-sessions/workspace-pane-normalization' @@ -50,10 +51,19 @@ export function setLocalWorkspaceSession( context.runtime.state.ui?.acknowledgedAgentsByPaneKey, normalized.leafIdByInputLeafIdByTabId ) - if (remappedAcknowledgements.changed) { + const remappedActivityCutoffs = remapActivityClearedAtPaneKeys( + context.runtime.state.ui?.activityClearedAtByPaneKey, + normalized.leafIdByInputLeafIdByTabId + ) + if (remappedAcknowledgements.changed || remappedActivityCutoffs.changed) { context.runtime.state.ui = { ...context.runtime.state.ui, - acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + ...(remappedAcknowledgements.changed + ? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements } + : {}), + ...(remappedActivityCutoffs.changed + ? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs } + : {}) } } for (const entry of normalized.legacyPaneKeyAliasEntries) { diff --git a/src/main/persistence/restoring-sessions/pane-key-remapping.ts b/src/main/persistence/restoring-sessions/pane-key-remapping.ts new file mode 100644 index 00000000000..ea863e79acc --- /dev/null +++ b/src/main/persistence/restoring-sessions/pane-key-remapping.ts @@ -0,0 +1,67 @@ +import type { PersistedState } from '../../../shared/persisted-state-types' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' + +type PaneLeafRemap = Map> + +function remapPaneKeys( + values: Record | undefined, + leafIdByInputLeafIdByTabId: PaneLeafRemap +): { values: Record | undefined; changed: boolean } { + if (!values || Object.keys(values).length === 0) { + return { values, changed: false } + } + + let changed = false + const next: Record = {} + const setValue = (paneKey: string, value: T): void => { + const existing = next[paneKey] + next[paneKey] = existing === undefined ? value : (Math.max(existing, value) as T) + } + for (const [paneKey, value] of Object.entries(values)) { + const parsed = parsePaneKey(paneKey) + if (parsed) { + setValue(paneKey, value) + continue + } + + const delimiter = paneKey.indexOf(':') + if (delimiter <= 0 || delimiter === paneKey.length - 1) { + setValue(paneKey, value) + continue + } + + const tabId = paneKey.slice(0, delimiter) + const legacyLeafId = paneKey.slice(delimiter + 1) + const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId) + if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) { + setValue(paneKey, value) + continue + } + + try { + // Carry values over when a legacy leaf is promoted to a UUID. + setValue(makePaneKey(tabId, remappedLeafId), value) + changed = true + } catch { + setValue(paneKey, value) + } + } + + return { values: next, changed } +} + +export function remapAcknowledgedAgentPaneKeys( + acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'], + leafIdByInputLeafIdByTabId: PaneLeafRemap +): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } { + const result = remapPaneKeys(acknowledgements, leafIdByInputLeafIdByTabId) + return { acknowledgements: result.values, changed: result.changed } +} + +export function remapActivityClearedAtPaneKeys( + cutoffs: PersistedState['ui']['activityClearedAtByPaneKey'], + leafIdByInputLeafIdByTabId: PaneLeafRemap +): { cutoffs: PersistedState['ui']['activityClearedAtByPaneKey']; changed: boolean } { + const result = remapPaneKeys(cutoffs, leafIdByInputLeafIdByTabId) + return { cutoffs: result.values, changed: result.changed } +} diff --git a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts index 2bfafebae9f..9e30cb7fd2d 100644 --- a/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts +++ b/src/main/persistence/restoring-sessions/workspace-pane-normalization.ts @@ -8,7 +8,7 @@ import { type ExecutionHostId } from '../../../shared/execution-host' import type { SshRemotePtyLease } from '../../../shared/ssh-types' -import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import { isTerminalLeafId, parsePaneKey } from '../../../shared/stable-pane-id' import { findCrossHostPaneTabIds, withoutPaneTabIds } from './cross-host-pane-tab-ids' import { createLazyTerminalTabLookup, @@ -22,6 +22,15 @@ import { migrationUnsupportedEntriesEqual, normalizeLegacyPaneKeyAliasEntries } from './pane-alias-normalization' +import { + remapAcknowledgedAgentPaneKeys, + remapActivityClearedAtPaneKeys +} from './pane-key-remapping' + +export { + remapAcknowledgedAgentPaneKeys, + remapActivityClearedAtPaneKeys +} from './pane-key-remapping' export function normalizeWorkspaceSessionPaneIdentities( session: WorkspaceSessionState, @@ -220,6 +229,10 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { state.ui?.acknowledgedAgentsByPaneKey, withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds) ) + const remappedActivityCutoffs = remapActivityClearedAtPaneKeys( + state.ui?.activityClearedAtByPaneKey, + withoutPaneTabIds(acknowledgementLeafIdByInputLeafIdByTabId, crossHostTabIds) + ) const migrationUnsupportedChanged = !migrationUnsupportedEntriesEqual( state.migrationUnsupportedPtyEntries ?? [], mergedMigrationUnsupportedEntries @@ -234,7 +247,8 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { !remappedLeases.changed && !migrationUnsupportedChanged && !legacyAliasesChanged && - !remappedAcknowledgements.changed + !remappedAcknowledgements.changed && + !remappedActivityCutoffs.changed ) { return { state, @@ -251,11 +265,16 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { sshRemotePtyLeases: remappedLeases.leases, migrationUnsupportedPtyEntries: mergedMigrationUnsupportedEntries, legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries, - ...(remappedAcknowledgements.changed + ...(remappedAcknowledgements.changed || remappedActivityCutoffs.changed ? { ui: { ...state.ui, - acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements + ...(remappedAcknowledgements.changed + ? { acknowledgedAgentsByPaneKey: remappedAcknowledgements.acknowledgements } + : {}), + ...(remappedActivityCutoffs.changed + ? { activityClearedAtByPaneKey: remappedActivityCutoffs.cutoffs } + : {}) } } : {}) @@ -265,50 +284,3 @@ export function normalizePersistedPaneIdentityState(state: PersistedState): { legacyPaneKeyAliasEntries: mergedLegacyPaneKeyAliasEntries } } - -export function remapAcknowledgedAgentPaneKeys( - acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey'], - leafIdByInputLeafIdByTabId: Map> -): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } { - if (!acknowledgements || Object.keys(acknowledgements).length === 0) { - return { acknowledgements, changed: false } - } - - let changed = false - const next: NonNullable = {} - const setAcknowledgement = (paneKey: string, acknowledgedAt: number): void => { - const existing = next[paneKey] - next[paneKey] = existing === undefined ? acknowledgedAt : Math.max(existing, acknowledgedAt) - } - for (const [paneKey, acknowledgedAt] of Object.entries(acknowledgements)) { - const parsed = parsePaneKey(paneKey) - if (parsed) { - setAcknowledgement(paneKey, acknowledgedAt) - continue - } - - const delimiter = paneKey.indexOf(':') - if (delimiter <= 0 || delimiter === paneKey.length - 1) { - setAcknowledgement(paneKey, acknowledgedAt) - continue - } - - const tabId = paneKey.slice(0, delimiter) - const legacyLeafId = paneKey.slice(delimiter + 1) - const remappedLeafId = leafIdByInputLeafIdByTabId.get(tabId)?.get(legacyLeafId) - if (!remappedLeafId || !isTerminalLeafId(remappedLeafId)) { - setAcknowledgement(paneKey, acknowledgedAt) - continue - } - - try { - // Why: when a legacy leaf is promoted to a UUID, carry the read marker over so seen rows don't come back unread. - setAcknowledgement(makePaneKey(tabId, remappedLeafId), acknowledgedAt) - changed = true - } catch { - setAcknowledgement(paneKey, acknowledgedAt) - } - } - - return { acknowledgements: next, changed } -} diff --git a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts index 02c93c14f6b..b3e87fe6992 100644 --- a/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-pairing-local-fields.test.ts @@ -44,7 +44,12 @@ describe('client UI RPC pairing-local field seams', () => { manualRepoOrder: [ { hostId: 'runtime:web-11111111-2222-3333-4444-555555555555', repoId: 'repo-a' } ], - workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'] + workspaceHostOrder: ['runtime:web-11111111-2222-3333-4444-555555555555', 'local'], + agentsVisibleHostIds: ['runtime:web-11111111-2222-3333-4444-555555555555'], + agentsFilterRepoIds: ['repo-a'], + agentsShowChildAgents: true, + agentsCompactMode: false, + activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 } } it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( diff --git a/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx b/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx index 9fdc4c0ecef..013afb58408 100644 --- a/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx +++ b/src/renderer/src/components/activity/activity-auto-mark-read-loop.react185.test.tsx @@ -125,7 +125,7 @@ async function mountActivityPage(): Promise { } async function selectSeededThread(): Promise { - const row = Array.from(seededContainer.querySelectorAll('[role="button"]')).find( + const row = Array.from(seededContainer.querySelectorAll('[role="listitem"]')).find( (element) => element.textContent?.includes(PROMPT) ) expect(row).toBeDefined() diff --git a/src/renderer/src/components/activity/activity-event-builder-sources.ts b/src/renderer/src/components/activity/activity-event-builder-sources.ts index 36e82b3442a..375c25e780b 100644 --- a/src/renderer/src/components/activity/activity-event-builder-sources.ts +++ b/src/renderer/src/components/activity/activity-event-builder-sources.ts @@ -75,7 +75,8 @@ export function appendUnsupportedAndRetainedEvents(context: { } const owner = resolveOwner( { worktreeId: retained.worktreeId, tab: retained.tab }, - retained.entry + retained.entry, + retained.tab.ptyId ?? retained.entry.terminalHandle ) if (!owner.knownWorktree) { continue diff --git a/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts b/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts index 5a0d5811d1c..0a3dc04c247 100644 --- a/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts +++ b/src/renderer/src/components/activity/activity-event-builder.host-ownership.test.ts @@ -141,4 +141,34 @@ describe('activity event host ownership', () => { expect(result.events[0]?.worktree).toBe(folderWorktree) expect(result.events[0]?.worktree.displayName).toBe('Docs folder') }) + + it('uses the retained terminal handle to preserve runtime host ownership after teardown', () => { + const localWorktree = makeWorktree() + const runtimeWorktree = { + ...makeWorktree(), + hostId: 'runtime:env-1' as const, + runtimeOwnerEnvironmentId: 'env-1', + displayName: 'Runtime worktree' + } + const tab = { ...makeTab(), ptyId: null } + const retained = makeRetainedDoneEntry(tab) + retained.entry = { ...doneEntry(null), terminalHandle: 'remote:env-1@@pty-1' } + const resolveWorktree = vi.fn((_worktreeId, executionHostId) => + executionHostId === 'runtime:env-1' ? runtimeWorktree : localWorktree + ) + + const result = buildActivityEvents({ + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: { [PANE_KEY]: retained }, + tabsByWorktree: {}, + worktreeMap: new Map([[localWorktree.id, localWorktree]]), + repoMap: new Map(), + resolveWorktree, + acknowledgedAgentsByPaneKey: {}, + now: 3_000 + }) + + expect(resolveWorktree).toHaveBeenCalledWith(localWorktree.id, 'runtime:env-1') + expect(result.events[0]?.worktree).toBe(runtimeWorktree) + }) }) diff --git a/src/renderer/src/components/activity/activity-event-builder.ts b/src/renderer/src/components/activity/activity-event-builder.ts index e6d46a23df6..d1e45650f45 100644 --- a/src/renderer/src/components/activity/activity-event-builder.ts +++ b/src/renderer/src/components/activity/activity-event-builder.ts @@ -150,7 +150,9 @@ function resolveActivityEventOwner( const resolvedWorktree = args.resolveWorktree?.(context.worktreeId, executionHostId) const mappedWorktree = args.worktreeMap.get(context.worktreeId) const worktree = - resolvedWorktree ?? mappedWorktree ?? standaloneActivityWorktree(context.worktreeId) + resolvedWorktree ?? + mappedWorktree ?? + standaloneActivityWorktree(context.worktreeId, executionHostId) let repo = executionHostId && args.repos ? findIndexedRepoOwnerForHost(args.repos, worktree.repoId, executionHostId) diff --git a/src/renderer/src/components/activity/activity-standalone-worktree.ts b/src/renderer/src/components/activity/activity-standalone-worktree.ts index 8e556849c48..1204c321ddf 100644 --- a/src/renderer/src/components/activity/activity-standalone-worktree.ts +++ b/src/renderer/src/components/activity/activity-standalone-worktree.ts @@ -1,15 +1,20 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { Worktree } from '../../../../shared/worktree/types' +import type { ExecutionHostId } from '../../../../shared/execution-host' const STANDALONE_ACTIVITY_WORKTREE_REPO_ID = '__activity_standalone__' const STANDALONE_ACTIVITY_WORKTREES_CAP = 200 const standaloneActivityWorktrees = new Map() -function buildStandaloneActivityWorktree(worktreeId: string): Worktree { +function buildStandaloneActivityWorktree( + worktreeId: string, + executionHostId?: ExecutionHostId +): Worktree { const displayName = worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? 'Floating terminal' : 'Standalone terminal' return { id: worktreeId, + ...(executionHostId ? { hostId: executionHostId } : {}), repoId: STANDALONE_ACTIVITY_WORKTREE_REPO_ID, path: '', head: '', @@ -30,14 +35,18 @@ function buildStandaloneActivityWorktree(worktreeId: string): Worktree { } /** Return a stable synthetic worktree for terminal-only activity. */ -export function standaloneActivityWorktree(worktreeId: string): Worktree { - let worktree = standaloneActivityWorktrees.get(worktreeId) +export function standaloneActivityWorktree( + worktreeId: string, + executionHostId?: ExecutionHostId +): Worktree { + const cacheKey = `${worktreeId}\0${executionHostId ?? ''}` + let worktree = standaloneActivityWorktrees.get(cacheKey) if (!worktree) { if (standaloneActivityWorktrees.size >= STANDALONE_ACTIVITY_WORKTREES_CAP) { standaloneActivityWorktrees.clear() } - worktree = buildStandaloneActivityWorktree(worktreeId) - standaloneActivityWorktrees.set(worktreeId, worktree) + worktree = buildStandaloneActivityWorktree(worktreeId, executionHostId) + standaloneActivityWorktrees.set(cacheKey, worktree) } return worktree } diff --git a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx index 24c8d60284c..0e604a16098 100644 --- a/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx +++ b/src/renderer/src/components/activity/activity-thread-hover-card.test.tsx @@ -118,6 +118,10 @@ describe('ActivityThreadHoverCard and ActivityThreadRow', () => { const card = container.querySelector('[data-worktree-card-surface="true"]') expect(card).not.toBeNull() + expect(card?.getAttribute('role')).toBe('listitem') + expect( + card?.querySelector('button[aria-label="Audit current HEAD on m4air environment"]') + ).not.toBeNull() expect(card?.textContent).toContain('Audit current HEAD on m4air environment') expect(card?.textContent).toContain('m4air-audit') }) diff --git a/src/renderer/src/components/activity/activity-thread-row.tsx b/src/renderer/src/components/activity/activity-thread-row.tsx index 0acb50b22f2..d6bb10f9c53 100644 --- a/src/renderer/src/components/activity/activity-thread-row.tsx +++ b/src/renderer/src/components/activity/activity-thread-row.tsx @@ -13,23 +13,6 @@ import { ActivityThreadHoverCard } from './activity-thread-hover-card' import { activityThreadRowCopy } from './activity-thread-presentation' import type { AgentPaneThread } from './activity-thread-types' -function isEventFromNestedInteractiveElement( - target: EventTarget | null, - currentTarget: HTMLElement -): boolean { - if (!(target instanceof HTMLElement)) { - return false - } - const interactiveTarget = target.closest( - 'a, button, input, select, textarea, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])' - ) - return ( - interactiveTarget instanceof HTMLElement && - interactiveTarget !== currentTarget && - currentTarget.contains(interactiveTarget) - ) -} - // Why React.memo: rows are pure functions of these props; thread identity is stable across // query/selection/group re-renders, so memo keeps a keystroke or selection change from // re-rendering every mounted row. Callbacks take the thread so parents can pass stable handlers. @@ -72,18 +55,9 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({ data-worktree-card-surface="true" data-worktree-card-active={selected ? 'primary' : undefined} onClick={() => onSelect(thread)} - role="button" - tabIndex={0} - onKeyDown={(event) => { - // Why: markdown responses can contain links; keyboard activation on a nested link follows the link instead of selecting the row. - if (isEventFromNestedInteractiveElement(event.target, event.currentTarget)) { - return - } - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - onSelect(thread) - } - }} + role="listitem" + aria-label={taskTitle} + aria-current={selected ? 'true' : undefined} className={cn( 'group relative flex w-full cursor-pointer flex-col gap-1.5 rounded-lg border border-transparent px-1.5 py-2.5 text-left transition-[background-color,border-color,opacity,box-shadow] duration-200 outline-none select-none worktree-sidebar-card-hover focus-visible:ring-1 focus-visible:ring-ring', selected && 'border-transparent' @@ -94,16 +68,24 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({
-
{ + event.stopPropagation() + onSelect(thread) + }} className={cn( - 'min-w-0 text-[13px] leading-5', + 'block min-w-0 w-full cursor-pointer text-left text-[13px] leading-5 outline-none focus-visible:ring-1 focus-visible:ring-ring', compactMode ? 'truncate' : 'line-clamp-2 break-words', thread.unread ? 'font-semibold text-foreground' : 'font-medium text-foreground' )} title={taskTitle} > {taskTitle} -
+ {statusLine ? ( showMarkdownStatus ? ( diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 4947fce433f..84588c4296c 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -37,6 +37,10 @@ type TabWorktreeIndexCache = { tabIdToWorktreeId: Map } +type LiveTabWorktreeIndexCache = TabWorktreeIndexCache & { + unifiedTabsByWorktree: WorktreeAgentRowsState['unifiedTabsByWorktree'] +} + type MigrationUnsupportedByWorktreeCache = { tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] migrationUnsupportedByPtyId: WorktreeAgentRowsState['migrationUnsupportedByPtyId'] @@ -49,6 +53,7 @@ type RetainedEntriesByWorktreeCache = { } let tabWorktreeIndexCache: TabWorktreeIndexCache | null = null +let liveTabWorktreeIndexCache: LiveTabWorktreeIndexCache | null = null let liveEntriesByWorktreeCache: LiveEntriesByWorktreeCache | null = null let migrationUnsupportedByWorktreeCache: MigrationUnsupportedByWorktreeCache | null = null let retainedEntriesByWorktreeCache: RetainedEntriesByWorktreeCache | null = null @@ -88,6 +93,12 @@ function getLiveTabIdToWorktreeId( tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'], unifiedTabsByWorktree: WorktreeAgentRowsState['unifiedTabsByWorktree'] ): Map { + if ( + liveTabWorktreeIndexCache?.tabsByWorktree === tabsByWorktree && + liveTabWorktreeIndexCache.unifiedTabsByWorktree === unifiedTabsByWorktree + ) { + return liveTabWorktreeIndexCache.tabIdToWorktreeId + } const tabIdToWorktreeId = new Map(getTabIdToWorktreeId(tabsByWorktree)) for (const [worktreeId, tabs] of Object.entries(unifiedTabsByWorktree ?? {})) { for (const tab of tabs) { @@ -96,6 +107,7 @@ function getLiveTabIdToWorktreeId( } } } + liveTabWorktreeIndexCache = { tabsByWorktree, unifiedTabsByWorktree, tabIdToWorktreeId } return tabIdToWorktreeId } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index 30cb1ac7632..8ab5263dc2f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -167,10 +167,12 @@ export function buildRetractedMirroredTabSweepPatch( } const sweepState: RetiredTerminalTabSweepState = { acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey ?? {}, + activityClearedAtByPaneKey: state.activityClearedAtByPaneKey ?? {}, agentLaunchConfigByPaneKey: state.agentLaunchConfigByPaneKey ?? {}, agentStatusByPaneKey: agentStatusPatch?.agentStatusByPaneKey ?? state.agentStatusByPaneKey, agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, + manuallyUnreadTurnsByPaneKey: state.manuallyUnreadTurnsByPaneKey ?? {}, paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {}, recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {}, recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {}, 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 d8be13bde50..f44d4a3d18c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -199,9 +199,11 @@ export type WebSessionTabsSyncState = Pick< Pick< AppState, | 'acknowledgedAgentsByPaneKey' + | 'activityClearedAtByPaneKey' | 'agentLaunchConfigByPaneKey' | 'automaticAgentResumeClaimsByTabId' | 'migrationUnsupportedByPtyId' + | 'manuallyUnreadTurnsByPaneKey' | 'paneForegroundAgentByPaneKey' | 'pendingStartupByTabId' | 'recentlyClosedAgentStatusTabIds' diff --git a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts index 4ca35b9bdc2..945c4028fe0 100644 --- a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts +++ b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts @@ -81,6 +81,34 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => { expect(ack['tab-3:1']).toBeUndefined() }) + it('dropAgentStatusByTabPrefix clears pane-keyed activity maps without live rows', () => { + vi.useFakeTimers() + const store = createTestStore() + store.setState({ + activityClearedAtByPaneKey: { 'tab-4:0': 100 }, + manuallyUnreadTurnsByPaneKey: { 'tab-4:0': 200 } + }) + + store.getState().dropAgentStatusByTabPrefix('tab-4') + + expect(store.getState().activityClearedAtByPaneKey['tab-4:0']).toBeUndefined() + expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-4:0']).toBeUndefined() + }) + + it('dropHibernatedAgentStatusPane clears pane-keyed activity maps without completion evidence', () => { + vi.useFakeTimers() + const store = createTestStore() + store.setState({ + activityClearedAtByPaneKey: { 'tab-5:0': 100 }, + manuallyUnreadTurnsByPaneKey: { 'tab-5:0': 200 } + }) + + store.getState().dropHibernatedAgentStatusPane('wt-1', 'tab-5:0') + + expect(store.getState().activityClearedAtByPaneKey['tab-5:0']).toBeUndefined() + expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-5:0']).toBeUndefined() + }) + it('a paneKey reused after teardown reads as unvisited (no leaked ack suppresses the signal)', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-04-29T12:00:00.000Z')) diff --git a/src/renderer/src/store/slices/agent-status-drop-actions.ts b/src/renderer/src/store/slices/agent-status-drop-actions.ts index 9b0dd401986..17ad12af6e1 100644 --- a/src/renderer/src/store/slices/agent-status-drop-actions.ts +++ b/src/renderer/src/store/slices/agent-status-drop-actions.ts @@ -44,6 +44,8 @@ export function createAgentStatusDropActions( (entry) => entry.paneKey === paneKey ) const nextAck = removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey) + const nextClearedAt = removeAcknowledgement(s.activityClearedAtByPaneKey, paneKey) + const nextManualUnread = removeAcknowledgement(s.manuallyUnreadTurnsByPaneKey, paneKey) const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey const nextLaunchConfigs = hasLaunchConfig ? { ...s.agentLaunchConfigByPaneKey } @@ -52,21 +54,19 @@ export function createAgentStatusDropActions( delete nextLaunchConfigs[paneKey] } if (!hasLive && !hasRetained && !migrationUnsupported.changed) { - if (hasLaunchConfig) { - return { - agentLaunchConfigByPaneKey: nextLaunchConfigs, - ...(nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : {}), - manuallyUnreadTurnsByPaneKey: removeAcknowledgement( - s.manuallyUnreadTurnsByPaneKey, - paneKey - ) - } + const cleanupPatch = { + ...(hasLaunchConfig ? { agentLaunchConfigByPaneKey: nextLaunchConfigs } : {}), + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}) } - return nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : s + return Object.keys(cleanupPatch).length > 0 ? cleanupPatch : s } const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey if (hasLive) { @@ -87,10 +87,8 @@ export function createAgentStatusDropActions( ...(nextAck !== s.acknowledgedAgentsByPaneKey ? { acknowledgedAgentsByPaneKey: nextAck } : {}), - manuallyUnreadTurnsByPaneKey: removeAcknowledgement( - s.manuallyUnreadTurnsByPaneKey, - paneKey - ), + activityClearedAtByPaneKey: nextClearedAt, + manuallyUnreadTurnsByPaneKey: nextManualUnread, ...(needsSuppressor ? { retentionSuppressedPaneKeys: { @@ -168,6 +166,12 @@ export function createAgentStatusDropActions( const nextAck = !keepsCompletionEvidence ? removeAcknowledgement(s.acknowledgedAgentsByPaneKey, paneKey) : s.acknowledgedAgentsByPaneKey + const nextClearedAt = !keepsCompletionEvidence + ? removeAcknowledgement(s.activityClearedAtByPaneKey, paneKey) + : s.activityClearedAtByPaneKey + const nextManualUnread = !keepsCompletionEvidence + ? removeAcknowledgement(s.manuallyUnreadTurnsByPaneKey, paneKey) + : s.manuallyUnreadTurnsByPaneKey if ( !hasLive && !hasRetained && @@ -175,9 +179,18 @@ export function createAgentStatusDropActions( !migrationUnsupported.changed && !keepsCompletionEvidence ) { - return nextAck !== s.acknowledgedAgentsByPaneKey - ? { acknowledgedAgentsByPaneKey: nextAck } - : s + const cleanupPatch = { + ...(nextAck !== s.acknowledgedAgentsByPaneKey + ? { acknowledgedAgentsByPaneKey: nextAck } + : {}), + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}) + } + return Object.keys(cleanupPatch).length > 0 ? cleanupPatch : s } hadLive = hasLive const nextLive = hasLive ? { ...s.agentStatusByPaneKey } : s.agentStatusByPaneKey @@ -212,6 +225,8 @@ export function createAgentStatusDropActions( ...(nextAck !== s.acknowledgedAgentsByPaneKey ? { acknowledgedAgentsByPaneKey: nextAck } : {}), + activityClearedAtByPaneKey: nextClearedAt, + manuallyUnreadTurnsByPaneKey: nextManualUnread, ...(needsSuppressor ? { retentionSuppressedPaneKeys: { diff --git a/src/renderer/src/store/slices/agent-status-drop-reducer.ts b/src/renderer/src/store/slices/agent-status-drop-reducer.ts index 5d8387e9bf0..0291b03b6e3 100644 --- a/src/renderer/src/store/slices/agent-status-drop-reducer.ts +++ b/src/renderer/src/store/slices/agent-status-drop-reducer.ts @@ -26,10 +26,12 @@ export function buildAgentStatusBatchPatch( export type AgentStatusTabPrefixDropState = Pick< AppState, | 'acknowledgedAgentsByPaneKey' + | 'activityClearedAtByPaneKey' | 'agentLaunchConfigByPaneKey' | 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'migrationUnsupportedByPtyId' + | 'manuallyUnreadTurnsByPaneKey' | 'recentlyClosedAgentStatusTabIds' | 'recentlyRetiredAgentStatusPaneKeys' | 'retainedAgentsByPaneKey' @@ -38,6 +40,24 @@ export type AgentStatusTabPrefixDropState = Pick< | 'tabsByWorktree' > +function removePaneKeyRecords( + record: Record, + prefix: string, + extraKeys: ReadonlySet +): Record { + const matchingKeys = Object.keys(record).filter( + (key) => key.startsWith(prefix) || extraKeys.has(key) + ) + if (matchingKeys.length === 0) { + return record + } + const next = { ...record } + for (const key of matchingKeys) { + delete next[key] + } + return next +} + /** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot * apply folds the same sweep into a patch it assembles itself, so the two * paths cannot drift. `retiredAliasPaneKeys` comes from the caller because @@ -86,6 +106,16 @@ export function buildAgentStatusTabPrefixDropPatch( s.recentlyRetiredAgentStatusPaneKeys, retiredAliasPaneKeys ) + const nextClearedAt = removePaneKeyRecords( + s.activityClearedAtByPaneKey, + prefix, + completedOrphanKeySet + ) + const nextManualUnread = removePaneKeyRecords( + s.manuallyUnreadTurnsByPaneKey, + prefix, + completedOrphanKeySet + ) if ( liveKeys.length === 0 && @@ -96,13 +126,25 @@ export function buildAgentStatusTabPrefixDropPatch( if (nextAck !== s.acknowledgedAgentsByPaneKey) { return { acknowledgedAgentsByPaneKey: nextAck, + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}), recentlyClosedAgentStatusTabIds: nextClosedTabs, recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys } } return { recentlyClosedAgentStatusTabIds: nextClosedTabs, - recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys + recentlyRetiredAgentStatusPaneKeys: nextRetiredPaneKeys, + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}) } } hadLive = liveKeys.length > 0 @@ -148,6 +190,12 @@ export function buildAgentStatusTabPrefixDropPatch( ...(nextAck !== s.acknowledgedAgentsByPaneKey ? { acknowledgedAgentsByPaneKey: nextAck } : {}), + ...(nextClearedAt !== s.activityClearedAtByPaneKey + ? { activityClearedAtByPaneKey: nextClearedAt } + : {}), + ...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey + ? { manuallyUnreadTurnsByPaneKey: nextManualUnread } + : {}), // Why: mirrors removeAgentStatusByTabPrefix — only bump epochs when the live map changed; retained-only sweeps don't affect sort/freshness. agentStatusEpoch: hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch, 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 11a416fed9b..618ae38815c 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 @@ -105,6 +105,7 @@ export function buildWorktreePurgeState( agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey), acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey), activityClearedAtByPaneKey: omitByPaneKeyTabPrefix(s.activityClearedAtByPaneKey), + manuallyUnreadTurnsByPaneKey: omitByPaneKeyTabPrefix(s.manuallyUnreadTurnsByPaneKey), paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey), sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey), unreadTerminalTabs: omitByTabId(s.unreadTerminalTabs), diff --git a/src/renderer/src/web/preload-api/web-preference-normalization.ts b/src/renderer/src/web/preload-api/web-preference-normalization.ts index a5e99c34788..ae3588356fc 100644 --- a/src/renderer/src/web/preload-api/web-preference-normalization.ts +++ b/src/renderer/src/web/preload-api/web-preference-normalization.ts @@ -68,7 +68,12 @@ export function mergeHostWebUIState( automationHostFilter: local.automationHostFilter, hideWorkspacesFromOtherDevices: local.hideWorkspacesFromOtherDevices === true, manualRepoOrder: local.manualRepoOrder, - workspaceHostOrder: local.workspaceHostOrder + workspaceHostOrder: local.workspaceHostOrder, + agentsVisibleHostIds: local.agentsVisibleHostIds, + agentsFilterRepoIds: local.agentsFilterRepoIds, + agentsShowChildAgents: local.agentsShowChildAgents, + agentsCompactMode: local.agentsCompactMode, + activityClearedAtByPaneKey: local.activityClearedAtByPaneKey } satisfies Record & Partial return { ...mergeWebUIState(local, incoming), ...pinned } } diff --git a/src/renderer/src/web/web-preload-api-ui.test.ts b/src/renderer/src/web/web-preload-api-ui.test.ts index 9222fdbc42a..a5fee4e391c 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -464,13 +464,23 @@ describe('web UI preload API', () => { automationHostFilter: { kind: 'host', hostKey: 'browser-local-host-key' }, hideWorkspacesFromOtherDevices: true, manualRepoOrder: [{ hostId: 'runtime:web-env-1', repoId: 'repo-b' }], - workspaceHostOrder: ['runtime:web-env-1', 'local'] + workspaceHostOrder: ['runtime:web-env-1', 'local'], + agentsVisibleHostIds: ['runtime:web-env-1'], + agentsFilterRepoIds: ['repo-b'], + agentsShowChildAgents: true, + agentsCompactMode: false, + activityClearedAtByPaneKey: { 'tab-1:leaf-1': 123 } } const hostUiSamples: Record = { automationHostFilter: { kind: 'all' }, hideWorkspacesFromOtherDevices: false, manualRepoOrder: [{ hostId: 'local', repoId: 'repo-a' }], - workspaceHostOrder: ['local', 'ssh:box'] + workspaceHostOrder: ['local', 'ssh:box'], + agentsVisibleHostIds: ['local'], + agentsFilterRepoIds: ['repo-a'], + agentsShowChildAgents: false, + agentsCompactMode: true, + activityClearedAtByPaneKey: { 'tab-2:leaf-2': 456 } } it.each(PAIRING_LOCAL_UI_FIELDS.map((field) => [field] as const))( diff --git a/src/shared/pairing-local-ui-fields.test.ts b/src/shared/pairing-local-ui-fields.test.ts index ab837782783..5bd7200ad50 100644 --- a/src/shared/pairing-local-ui-fields.test.ts +++ b/src/shared/pairing-local-ui-fields.test.ts @@ -9,7 +9,12 @@ describe('pairing-local UI fields', () => { 'automationHostFilter', 'hideWorkspacesFromOtherDevices', 'manualRepoOrder', - 'workspaceHostOrder' + 'workspaceHostOrder', + 'agentsVisibleHostIds', + 'agentsFilterRepoIds', + 'agentsShowChildAgents', + 'agentsCompactMode', + 'activityClearedAtByPaneKey' ]) }) diff --git a/src/shared/pairing-local-ui-fields.ts b/src/shared/pairing-local-ui-fields.ts index 55865416c01..f6daa5c29f4 100644 --- a/src/shared/pairing-local-ui-fields.ts +++ b/src/shared/pairing-local-ui-fields.ts @@ -11,7 +11,13 @@ export const PAIRING_LOCAL_UI_FIELDS = [ 'automationHostFilter', 'hideWorkspacesFromOtherDevices', 'manualRepoOrder', - 'workspaceHostOrder' + 'workspaceHostOrder', + // Agent View filters and presentation belong to each client's host catalog and viewport. + 'agentsVisibleHostIds', + 'agentsFilterRepoIds', + 'agentsShowChildAgents', + 'agentsCompactMode', + 'activityClearedAtByPaneKey' ] as const satisfies readonly (keyof PersistedUIState)[] export type PairingLocalUiField = (typeof PAIRING_LOCAL_UI_FIELDS)[number]