Add activity clearance tracking and synced agent view settings

Agent view filters and presentation settings now sync across paired clients.
Preserves per-pane activity clearance cutoffs in persistent state. Improves
activity thread row accessibility with proper ARIA roles, and preserves
terminal host ownership after pane teardown via retained terminal handle.
This commit is contained in:
Jinjing
2026-09-02 10:10:10 -07:00
parent 1ac4ce31af
commit cd78fdff03
23 changed files with 393 additions and 121 deletions
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agents tab introduction — Orca mockup</title>
<style>
:root{font-family:Geist,ui-sans-serif,system-ui,-apple-system,sans-serif;color:#f4f4f5;background:#09090b;color-scheme:dark;--sidebar:#18181b;--panel:#1f1f23;--border:#3f3f46;--muted:#a1a1aa;--accent:#27272a;--primary:#fafafa;--primary-fg:#18181b}
*{box-sizing:border-box}body{margin:0;min-height:100vh;padding:32px;background:radial-gradient(circle at 50% -10%,#27272a 0,#09090b 48%)}
.page{max-width:1120px;margin:auto}.eyebrow{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:#a1a1aa;font-weight:650}.title{margin:8px 0 5px;font-size:24px;letter-spacing:-.03em}.lede{margin:0 0 24px;color:var(--muted);font-size:13px}.switcher{display:flex;gap:6px;margin-bottom:16px}.switcher button{border:1px solid var(--border);border-radius:7px;background:transparent;color:var(--muted);padding:8px 12px;font:inherit;font-size:12px;cursor:pointer}.switcher button[aria-selected=true]{background:#3f3f46;color:#fff;border-color:#52525b}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.state{border:1px solid #27272a;border-radius:12px;overflow:hidden;background:#111113;box-shadow:0 10px 24px rgba(0,0,0,.18)}.state h2{font-size:13px;margin:0;padding:13px 15px;border-bottom:1px solid #27272a}.state small{display:block;color:var(--muted);font-size:11px;font-weight:400;margin-top:4px}
.shell{height:440px;display:flex;background:#0b0b0c}.sidebar{width:220px;background:var(--sidebar);padding:14px 10px;position:relative}.brand{font-weight:650;font-size:13px;padding:0 9px 17px}.nav{display:flex;align-items:center;gap:9px;color:#a1a1aa;font-size:13px;padding:8px 10px;border-radius:6px}.nav.muted{opacity:.55}.nav.active{background:#3f3f46;color:#fff}.nav .icon{width:15px;text-align:center;color:#d4d4d8}.tip{position:absolute;z-index:2;left:184px;top:136px;width:276px;padding:15px;border:1px solid #52525b;border-radius:10px;background:#27272a;color:#f4f4f5;box-shadow:0 10px 24px rgba(0,0,0,.34)}.tip:before{content:"";position:absolute;left:-7px;top:21px;width:12px;height:12px;background:#27272a;border-left:1px solid #52525b;border-bottom:1px solid #52525b;transform:rotate(45deg)}.tip h3{font-size:14px;margin:0 0 6px;letter-spacing:-.01em}.tip p{font-size:12px;line-height:1.5;color:#d4d4d8;margin:0 0 13px}.tip .actions{display:flex;justify-content:flex-end;gap:7px}.tip button{font:inherit;font-size:12px;border-radius:6px;padding:6px 9px;cursor:pointer}.later{background:transparent;color:#d4d4d8;border:1px solid #52525b}.try{background:var(--primary);color:var(--primary-fg);border:1px solid var(--primary)}.main{flex:1;padding:22px;background:#0b0b0c}.fake-line{height:11px;border-radius:4px;background:#18181b;margin-bottom:12px;width:70%}.fake-line.short{width:42%}.note{position:absolute;bottom:12px;left:10px;right:10px;font-size:10px;color:#71717a;text-align:center}.badge{display:inline-flex;align-items:center;margin-left:auto;border-radius:99px;background:#3f3f46;color:#d4d4d8;padding:1px 6px;font-size:10px}.foot{font-size:11px;color:#71717a;margin-top:12px;line-height:1.45}
@media(max-width:820px){body{padding:18px}.grid{grid-template-columns:1fr}.shell{height:380px}.tip{left:172px;width:250px}}
</style>
</head>
<body>
<main class="page">
<div class="eyebrow">Orca · proposed onboarding</div>
<h1 class="title">A quiet introduction to the Agents tab</h1>
<p class="lede">An anchored, one-shot popover keeps the user in context. “Try Agents” opens the tab; “Maybe later” dismisses it without changing settings.</p>
<div class="switcher" role="tablist" aria-label="Preview state">
<button role="tab" aria-selected="true" data-state="new">Never enabled</button>
<button role="tab" aria-selected="false" data-state="legacy">Previously enabled</button>
</div>
<div class="grid">
<section class="state" data-panel="new">
<h2>First-time user <small>Agents view has never been enabled</small></h2>
<div class="shell">
<aside class="sidebar"><div class="brand">Orca</div><div class="nav active"><span class="icon">▦</span>Projects</div><div class="nav"><span class="icon">✦</span>Tasks</div><div class="nav"><span class="icon">◌</span>Automations</div><div class="nav" style="margin-top:8px"><span class="icon">◉</span>Agents <span class="badge">NEW</span></div>
<div class="tip"><h3>Meet your Agents tab</h3><p>See every agent in one place—what’s working, what’s done, and where you need to step in. You can always change this in Settings → Appearance.</p><div class="actions"><button class="later">Maybe later</button><button class="try">Try Agents</button></div></div>
<div class="note">Popover appears once after the tab is available.</div>
</aside><div class="main"><div class="fake-line"></div><div class="fake-line short"></div></div>
</div>
<div class="foot">Recommended emphasis: explain the value, then offer a direct action. No “experimental” language for a graduated feature.</div>
</section>
<section class="state" data-panel="legacy">
<h2>Returning user <small>Previously used the experimental Agents view</small></h2>
<div class="shell">
<aside class="sidebar"><div class="brand">Orca</div><div class="nav"><span class="icon">▦</span>Projects</div><div class="nav active"><span class="icon">◉</span>Agents</div><div class="nav"><span class="icon">✦</span>Tasks</div>
<div class="tip"><h3>Agents are easier to find</h3><p>Your Agents view is now a dedicated tab in the sidebar. Your filters and agent activity are kept—just click here whenever you want to check in.</p><div class="actions"><button class="later">Got it</button><button class="try">Open Agents</button></div></div>
<div class="note">Shown once for users migrated from the experimental view.</div>
</aside><div class="main"><div class="fake-line"></div><div class="fake-line short"></div></div>
</div>
<div class="foot">Migration copy reassures returning users that their existing view and preferences are preserved.</div>
</section>
</div>
</main>
<script>
const buttons = [...document.querySelectorAll('[data-state]')];
buttons.forEach(button => button.addEventListener('click', () => {
buttons.forEach(item => item.setAttribute('aria-selected', String(item === button)));
document.body.dataset.preview = button.dataset.state;
}));
</script>
</body>
</html>
@@ -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) {
@@ -0,0 +1,67 @@
import type { PersistedState } from '../../../shared/persisted-state-types'
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id'
type PaneLeafRemap = Map<string, Map<string, string>>
function remapPaneKeys<T extends number>(
values: Record<string, T> | undefined,
leafIdByInputLeafIdByTabId: PaneLeafRemap
): { values: Record<string, T> | undefined; changed: boolean } {
if (!values || Object.keys(values).length === 0) {
return { values, changed: false }
}
let changed = false
const next: Record<string, T> = {}
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 }
}
@@ -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<string, Map<string, string>>
): { acknowledgements: PersistedState['ui']['acknowledgedAgentsByPaneKey']; changed: boolean } {
if (!acknowledgements || Object.keys(acknowledgements).length === 0) {
return { acknowledgements, changed: false }
}
let changed = false
const next: NonNullable<PersistedState['ui']['acknowledgedAgentsByPaneKey']> = {}
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 }
}
@@ -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))(
@@ -125,7 +125,7 @@ async function mountActivityPage(): Promise<void> {
}
async function selectSeededThread(): Promise<void> {
const row = Array.from(seededContainer.querySelectorAll<HTMLElement>('[role="button"]')).find(
const row = Array.from(seededContainer.querySelectorAll<HTMLElement>('[role="listitem"]')).find(
(element) => element.textContent?.includes(PROMPT)
)
expect(row).toBeDefined()
@@ -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
@@ -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)
})
})
@@ -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)
@@ -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<string, Worktree>()
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
}
@@ -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')
})
@@ -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({
<ThreadAgentStateIndicator thread={thread} />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div
{/* Keep the activation target separate from markdown links and row actions. */}
<button
type="button"
aria-label={taskTitle}
aria-keyshortcuts="Enter Space"
onClick={(event) => {
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}
</div>
</button>
{statusLine ? (
showMarkdownStatus ? (
@@ -37,6 +37,10 @@ type TabWorktreeIndexCache = {
tabIdToWorktreeId: Map<string, string>
}
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<string, string> {
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
}
@@ -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 ?? {},
@@ -199,9 +199,11 @@ export type WebSessionTabsSyncState = Pick<
Pick<
AppState,
| 'acknowledgedAgentsByPaneKey'
| 'activityClearedAtByPaneKey'
| 'agentLaunchConfigByPaneKey'
| 'automaticAgentResumeClaimsByTabId'
| 'migrationUnsupportedByPtyId'
| 'manuallyUnreadTurnsByPaneKey'
| 'paneForegroundAgentByPaneKey'
| 'pendingStartupByTabId'
| 'recentlyClosedAgentStatusTabIds'
@@ -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'))
@@ -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: {
@@ -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<T>(
record: Record<string, T>,
prefix: string,
extraKeys: ReadonlySet<string>
): Record<string, T> {
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,
@@ -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),
@@ -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<PairingLocalUiField, unknown> & Partial<PersistedUIState>
return { ...mergeWebUIState(local, incoming), ...pinned }
}
@@ -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<PairingLocalUiField, unknown> = {
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))(
+6 -1
View File
@@ -9,7 +9,12 @@ describe('pairing-local UI fields', () => {
'automationHostFilter',
'hideWorkspacesFromOtherDevices',
'manualRepoOrder',
'workspaceHostOrder'
'workspaceHostOrder',
'agentsVisibleHostIds',
'agentsFilterRepoIds',
'agentsShowChildAgents',
'agentsCompactMode',
'activityClearedAtByPaneKey'
])
})
+7 -1
View File
@@ -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]