Graduate Agents from experimental and improve activity visibility

- Migrate `showAgentsSidebar` setting from legacy experimental flags; default new profiles to the agents sidebar
- Replace scoped-thread filtering with visible-thread filtering so bulk actions (mark all read, clear completed) only affect rendered rows
- Rewrite child agent classification as a set of visible pane keys to fix orphan promotion and parent-cycle handling
- Improve activity cleared-at cutoff lifecycle: preserve on row dismissal (pane may still be live) but clear on pane removal
- Add pagehide flush for pending clear-completed evictions so quit/reload cannot replay cleared activity
- Polish agents sidebar: unread count badge, expand button, onboarding intro for migrated/new users
- Extract shared time-ago formatting to a library module
- Fix scroll restoration to defer until content can contain the saved offset
- Improve stable message hold for compact agent rows using state instead of refs
- Add worktree filter-visibility check to distinguish collapsed-but-unfiltered from filtered-hidden
This commit is contained in:
Jinjing
2026-09-02 10:10:35 -07:00
parent 9fc042a978
commit 2fb4c76183
56 changed files with 1427 additions and 739 deletions
@@ -264,6 +264,49 @@ describe('AgentHookServer listener replay', () => {
})
})
it('evicts when the renderer identity was stamped after receipt but pins the same turn', () => {
// Runtime-sync and recovery entries stamp updatedAt with Date.now()/capturedAt, which is
// at or after main's receivedAt; the eviction must still land for those rows.
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
},
'conn-1'
)
const entry = server.getStatusSnapshot()[0]!
expect(
server.dropPersistedStatusEntry({
paneKey: entry.paneKey,
receivedAt: entry.receivedAt + 5_000,
stateStartedAt: entry.stateStartedAt
})
).toBe(true)
// A different turn never matches, whatever the receivedAt relationship.
const other = new AgentHookServer()
other.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'done', prompt: 'run', agentType: 'claude' }
},
'conn-1'
)
const otherEntry = other.getStatusSnapshot()[0]!
expect(
other.dropPersistedStatusEntry({
paneKey: otherEntry.paneKey,
receivedAt: otherEntry.receivedAt + 5_000,
stateStartedAt: otherEntry.stateStartedAt + 1
})
).toBe(false)
})
it('notifies pane-status-clear listener when pane teardown evicts a cached status', () => {
const server = new AgentHookServer()
const listener = vi.fn()
@@ -42,8 +42,14 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
const existing = this.state.lastStatusByPaneKey.get(resolvedPaneKey) as
| EnrichedAgentHookEventPayload
| undefined
// Why: renderer-enriched fields can diverge from the main cache; timing fields pin the exact event instance.
if (!existing || existing.receivedAt !== identity.receivedAt || existing.stateStartedAt !== identity.stateStartedAt) {
// Why: stateStartedAt pins the turn; the renderer's updatedAt is stamped at or after this
// receivedAt (runtime-sync and recovery paths use Date.now()/capturedAt), so a strictly
// newer cached event is the only replacement worth protecting.
if (
!existing ||
existing.stateStartedAt !== identity.stateStartedAt ||
existing.receivedAt > identity.receivedAt
) {
return false
}
this.dropStatusEntry(resolvedPaneKey)
@@ -0,0 +1,72 @@
import { homedir } from 'node:os'
import { describe, expect, it } from 'vitest'
import { getDefaultPersistedState } from '../../../shared/constants'
import { normalizeLoadedGlobalSettings } from './normalize-loaded-global-settings'
import { prepareLoadedTerminalSettings } from './prepare-loaded-terminal-settings'
import { prepareLoadedProfileSettings } from './prepare-loaded-profile-settings'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
// Simulates a pre-graduation profile: showAgentsSidebar was never persisted.
function normalizeLegacyProfile(overrides: Partial<GlobalSettings>): PersistedState['settings'] {
const defaults = getDefaultPersistedState(homedir())
const settings: Partial<GlobalSettings> = { ...defaults.settings }
delete settings.showAgentsSidebar
delete settings.experimentalActivity
delete settings.experimentalAgentDashboardPopout
Object.assign(settings, overrides)
const parsed: PersistedState = { ...defaults, settings: settings as GlobalSettings }
const noop = (): void => {}
const terminal = prepareLoadedTerminalSettings(parsed, noop)
const profile = prepareLoadedProfileSettings(parsed, defaults, noop)
return normalizeLoadedGlobalSettings(parsed, terminal, profile)
}
describe('showAgentsSidebar graduation migration', () => {
it('keeps the sidebar for Agents-view opt-ins regardless of the popout experiment', () => {
const normalized = normalizeLegacyProfile({
experimentalActivity: true,
experimentalAgentDashboardPopout: false
})
expect(normalized.showAgentsSidebar).toBe(true)
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true)
})
it('graduates either legacy opt-in to the sidebar', () => {
expect(normalizeLegacyProfile({ experimentalActivity: true }).showAgentsSidebar).toBe(true)
expect(
normalizeLegacyProfile({ experimentalAgentDashboardPopout: true }).showAgentsSidebar
).toBe(true)
})
it('defaults profiles with no legacy signal to the sidebar', () => {
const normalized = normalizeLegacyProfile({})
expect(normalized.showAgentsSidebar).toBe(true)
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false)
})
it('keeps an explicit popout opt-out hidden when the Agents view was never used', () => {
expect(
normalizeLegacyProfile({ experimentalAgentDashboardPopout: false }).showAgentsSidebar
).toBe(false)
expect(
normalizeLegacyProfile({
experimentalActivity: false,
experimentalAgentDashboardPopout: false
}).showAgentsSidebar
).toBe(false)
})
it('preserves a stored showAgentsSidebar choice over legacy flags', () => {
expect(
normalizeLegacyProfile({ showAgentsSidebar: false, experimentalActivity: true })
.showAgentsSidebar
).toBe(false)
expect(
normalizeLegacyProfile({
showAgentsSidebar: true,
experimentalAgentDashboardPopout: false
}).showAgentsSidebar
).toBe(true)
})
})
@@ -85,11 +85,13 @@ export function normalizeLoadedGlobalSettings(
...migratedTerminalTuiScrollSensitivity.settings,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
// The dashboard graduated from Experimental; keep explicit legacy opt-outs,
// while defaulting every profile to the new left-sidebar tab.
// The dashboard graduated from Experimental; default every profile to the new
// left-sidebar tab. An Agents-view opt-in always graduates; only an explicit
// popout opt-out (without that opt-in) stays hidden.
showAgentsSidebar:
parsed.settings?.showAgentsSidebar ??
parsed.settings?.experimentalAgentDashboardPopout !== false,
(parsed.settings?.experimentalActivity === true ||
parsed.settings?.experimentalAgentDashboardPopout !== false),
// Preserve the legacy opt-in before the experimental setting is normalized away. This
// drives the migration-specific introduction copy without changing runtime behavior.
agentsSidebarMigratedFromExperimental:
@@ -65,7 +65,6 @@ export default function ActivityPrototypePage(): React.JSX.Element {
const {
storeData,
allThreads,
scopedThreads,
selectedPaneKeyIsLive,
effectiveSelectedPaneKey,
visibleThreads,
@@ -244,33 +243,34 @@ export default function ActivityPrototypePage(): React.JSX.Element {
return () => window.removeEventListener('keydown', focusActivityFilter, { capture: true })
}, [activePortalTargetEl, inactivePortalTargetEl])
// Why useMemo: rows are React.memo'd on these handlers; a fresh closure per render
// would defeat the bail-out and re-render every mounted row on unrelated updates.
const {
hasUnreadThreads,
markThreadRead,
markThreadUnread,
selectThread,
jumpToWorkspace,
markAllThreadsRead
} = useMemo(
() =>
createActivityThreadActions({
allThreads: scopedThreads,
acknowledgeAgents: storeData.acknowledgeAgents,
unacknowledgeAgents: storeData.unacknowledgeAgents,
setSelectedPaneKey
}),
[scopedThreads, storeData.acknowledgeAgents, storeData.unacknowledgeAgents]
)
// Why a ref: rows are React.memo'd on these handlers; recreating them whenever the
// thread array identity changes (every status ping) would re-render every mounted row.
const visibleThreadsRef = useRef(visibleThreads)
useEffect(() => {
visibleThreadsRef.current = visibleThreads
}, [visibleThreads])
const { markThreadRead, markThreadUnread, selectThread, jumpToWorkspace, markAllThreadsRead } =
useMemo(
() =>
createActivityThreadActions({
getVisibleThreads: () => visibleThreadsRef.current,
acknowledgeAgents: storeData.acknowledgeAgents,
unacknowledgeAgents: storeData.unacknowledgeAgents,
setSelectedPaneKey
}),
[storeData.acknowledgeAgents, storeData.unacknowledgeAgents]
)
// Why visibleThreads: bulk actions and their enablement must match what the list
// shows — clearing/acking rows hidden by search, unread, or child filters would be silent.
const hasUnreadThreads = useMemo(() => visibleThreads.some((t) => t.unread), [visibleThreads])
const hasCompletedThreads = useMemo(
() => scopedThreads.some(isClearableActivityThread),
[scopedThreads]
() => visibleThreads.some(isClearableActivityThread),
[visibleThreads]
)
const handleClearCompleted = useCallback(() => {
clearCompletedActivity(scopedThreads)
}, [scopedThreads])
clearCompletedActivity(visibleThreadsRef.current)
}, [])
const canJumpToWorkspace = hasActivityThreadWorkspace
@@ -55,6 +55,7 @@ vi.mock('sonner', () => ({ toast: toastSpy }))
import {
clearCompletedActivity,
flushPendingClearCompletedEvictions,
isClearableActivityThread,
planClearCompletedActivity
} from './activity-clear-completed'
@@ -137,6 +138,8 @@ describe('clearCompletedActivity', () => {
})
afterEach(() => {
// Drain any eviction left pending by a test that never closed its toast.
flushPendingClearCompletedEvictions()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
@@ -270,4 +273,34 @@ describe('clearCompletedActivity', () => {
expect(toastSpy).not.toHaveBeenCalled()
expect(mockStore.applyActivityClearedAt).not.toHaveBeenCalled()
})
it('pagehide flush evicts a clear whose undo toast is still open', () => {
clearCompletedActivity([doneThread])
const drop = (
window as unknown as {
api: { agentStatus: { dropPersisted: ReturnType<typeof vi.fn> } }
}
).api.agentStatus.dropPersisted
expect(drop).not.toHaveBeenCalled()
// Quit/reload path: the toast's close callbacks never fire.
flushPendingClearCompletedEvictions()
expect(drop).toHaveBeenCalledTimes(1)
// The flushed eviction is consumed; later toast close must not double-drop.
lastToastOptions().onAutoClose()
expect(drop).toHaveBeenCalledTimes(1)
})
it('pagehide flush skips a clear that was undone', () => {
clearCompletedActivity([doneThread])
lastToastOptions().action.onClick()
flushPendingClearCompletedEvictions()
const drop = (
window as unknown as {
api: { agentStatus: { dropPersisted: ReturnType<typeof vi.fn> } }
}
).api.agentStatus.dropPersisted
expect(drop).not.toHaveBeenCalled()
})
})
@@ -61,6 +61,19 @@ export function planClearCompletedActivity(
return { cutoffPatch, restorePatch, retainedSnapshots, cacheIdentities, clearedThreadCount }
}
// Deferred evictions whose undo toast is still open; flushed on pagehide because the toast's
// close callbacks never fire on quit/reload, which would let cleared rows replay next launch.
const pendingDiskEvictions = new Set<() => void>()
export function flushPendingClearCompletedEvictions(): void {
// Set iteration tolerates the self-delete each evict() performs.
for (const evict of pendingDiskEvictions) {
evict()
}
}
if (typeof window !== 'undefined') {
window.addEventListener('pagehide', flushPendingClearCompletedEvictions)
}
/**
* Clear completed/interrupted activity threads with an undo window.
*
@@ -93,6 +106,7 @@ export function clearCompletedActivity(threads: readonly AgentPaneThread[]): boo
let undone = false
let dropped = false
const dropRetainedFromDiskCache = (): void => {
pendingDiskEvictions.delete(dropRetainedFromDiskCache)
if (undone || dropped) {
return
}
@@ -101,6 +115,7 @@ export function clearCompletedActivity(threads: readonly AgentPaneThread[]): boo
window.api?.agentStatus?.dropPersisted?.(identity)
}
}
pendingDiskEvictions.add(dropRetainedFromDiskCache)
toast(
plan.clearedThreadCount === 1
? translate('auto.components.activity.clearCompleted.clearedOne', 'Cleared 1 completed agent')
@@ -114,6 +129,7 @@ export function clearCompletedActivity(threads: readonly AgentPaneThread[]): boo
label: translate('auto.components.activity.clearCompleted.undo', 'Undo'),
onClick: () => {
undone = true
pendingDiskEvictions.delete(dropRetainedFromDiskCache)
const current = useAppStore.getState()
const retainedByPaneKey = new Map(
plan.retainedSnapshots.map((retained) => [retained.entry.paneKey, retained])
@@ -58,6 +58,7 @@ export function appendUnsupportedAndRetainedEvents(context: {
agentAlive: false,
acknowledgedAt: args.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0,
clearedAt: args.activityClearedAtByPaneKey?.[entry.paneKey] ?? 0,
migrationUnsupportedPtyId: unsupported.ptyId,
liveState: 'blocked'
},
cache,
@@ -142,6 +142,38 @@ describe('activity event host ownership', () => {
expect(result.events[0]?.worktree.displayName).toBe('Docs folder')
})
it('carries migrationUnsupportedPtyId on events built for un-migratable panes', () => {
const worktree = makeWorktree()
const repo = makeRepo()
const tab = makeTab()
const result = buildActivityEvents({
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {
'pty-1': {
ptyId: 'pty-1',
paneKey: PANE_KEY,
tabId: tab.id,
reason: 'legacy-numeric-pane-key',
source: 'local',
updatedAt: 1_000
}
},
tabsByWorktree: { [worktree.id]: [tab] },
worktreeMap: new Map([[worktree.id, worktree]]),
repoMap: new Map([[repo.id, repo]]),
resolveWorktree: () => worktree,
acknowledgedAgentsByPaneKey: {},
now: 3_000
})
expect(result.events.length).toBeGreaterThan(0)
for (const event of result.events) {
expect(event.migrationUnsupportedPtyId).toBe('pty-1')
}
})
it('uses the retained terminal handle to preserve runtime host ownership after teardown', () => {
const localWorktree = makeWorktree()
const runtimeWorktree = {
@@ -16,4 +16,4 @@ export {
} from './activity-filter-focus-shortcut'
export { ActivityThreadOptionsMenu, ThreadAgentStateIndicator } from './activity-thread-controls'
export { useActivityTerminalPortalStatus } from './activity-terminal-portal-status'
export { isChildAgentEntry, isChildAgentThread } from './activity-thread-child-agent'
export { collectChildAgentPaneKeys } from './activity-thread-child-agent'
@@ -83,7 +83,7 @@ function ScopeFilterChip({
onClear: () => void
}): React.JSX.Element {
return (
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/80 bg-muted/80 py-0.5 pl-2 pr-1 text-[11px] font-medium leading-none text-foreground/80 shadow-2xs">
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/80 bg-muted/80 py-0.5 pl-2 pr-1 text-[11px] font-medium leading-none text-foreground/80 shadow-xs">
<span className="min-w-0 truncate">{label}</span>
<button
type="button"
@@ -63,7 +63,7 @@ describe('activity thread host routing', () => {
it('selects the matching host when the same workspace id is active elsewhere', () => {
const actions = createActivityThreadActions({
allThreads: [thread],
getVisibleThreads: () => [thread],
acknowledgeAgents,
unacknowledgeAgents: vi.fn(),
setSelectedPaneKey
@@ -83,7 +83,7 @@ describe('activity thread host routing', () => {
it('jumps to and probes the matching host-qualified workspace', () => {
expect(hasActivityThreadWorkspace(thread)).toBe(true)
const actions = createActivityThreadActions({
allThreads: [thread],
getVisibleThreads: () => [thread],
acknowledgeAgents,
unacknowledgeAgents: vi.fn(),
setSelectedPaneKey
@@ -96,4 +96,23 @@ describe('activity thread host routing', () => {
executionHostId: REMOTE_HOST
})
})
it('marks all currently visible unread threads read, reading the set at call time', () => {
const readThread = { ...makeRemoteThread(), paneKey: 'tab-2:read', unread: false }
let visible = [readThread]
const actions = createActivityThreadActions({
getVisibleThreads: () => visible,
acknowledgeAgents,
unacknowledgeAgents: vi.fn(),
setSelectedPaneKey
})
actions.markAllThreadsRead()
expect(acknowledgeAgents).not.toHaveBeenCalled()
// The handler keeps one identity while the visible set changes underneath it.
visible = [thread, readThread]
actions.markAllThreadsRead()
expect(acknowledgeAgents).toHaveBeenCalledWith([thread.paneKey])
})
})
@@ -17,17 +17,18 @@ export function hasActivityThreadWorkspace(thread: AgentPaneThread): boolean {
}
export function createActivityThreadActions({
allThreads,
getVisibleThreads,
acknowledgeAgents,
unacknowledgeAgents,
setSelectedPaneKey
}: {
allThreads: AgentPaneThread[]
/** Getter (not a snapshot) so the handlers keep one identity for the row memo
* bail-outs while bulk actions still see the currently rendered thread set. */
getVisibleThreads: () => AgentPaneThread[]
acknowledgeAgents: (paneKeys: string[]) => void
unacknowledgeAgents: (paneKeys: string[]) => void
setSelectedPaneKey: (paneKey: string | null) => void
}): {
hasUnreadThreads: boolean
markThreadRead: (thread: AgentPaneThread) => void
markThreadUnread: (thread: AgentPaneThread) => void
selectThread: (thread: AgentPaneThread) => void
@@ -88,10 +89,10 @@ export function createActivityThreadActions({
})
}
const hasUnreadThreads = allThreads.some((thread) => thread.unread)
const markAllThreadsRead = (): void => {
const unreadKeys = allThreads.filter((t) => t.unread).map((t) => t.paneKey)
const unreadKeys = getVisibleThreads()
.filter((t) => t.unread)
.map((t) => t.paneKey)
if (unreadKeys.length === 0) {
return
}
@@ -99,7 +100,6 @@ export function createActivityThreadActions({
}
return {
hasUnreadThreads,
markThreadRead,
markThreadUnread,
selectThread,
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import { isChildAgentEntry, isChildAgentThread } from './activity-thread-child-agent'
import { collectChildAgentPaneKeys } from './activity-thread-child-agent'
import type { ActivityEvent, AgentPaneThread } from './activity-thread-types'
import {
makeRepo,
@@ -8,13 +8,17 @@ import {
makeWorkingEntryWithoutHistory,
makeWorktree,
PANE_KEY,
PANE_KEY_2
PANE_KEY_2,
PANE_KEY_3
} from './ActivityPrototypePage-test-fixtures'
function makeTestEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
function makeTestEntry(
paneKey: string,
overrides: Partial<AgentStatusEntry> = {}
): AgentStatusEntry {
return {
...makeWorkingEntryWithoutHistory(),
paneKey: PANE_KEY,
paneKey,
state: 'done',
prompt: 'test prompt',
stateHistory: [],
@@ -22,173 +26,146 @@ function makeTestEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEn
}
}
function makeTestThread(overrides: Partial<AgentPaneThread> = {}): AgentPaneThread {
const repo = makeRepo()
function makeTestThread(
paneKey: string,
overrides: Partial<AgentPaneThread> = {}
): AgentPaneThread {
const worktree = makeWorktree()
const tab = makeTabWithIds('tab-1', worktree.id)
return {
paneKey: PANE_KEY,
paneKey,
paneTitle: 'Test Agent',
agentType: 'claude',
worktree,
repo,
tab,
repo: makeRepo(),
tab: makeTabWithIds('tab-1', worktree.id),
events: [],
latestEvent: null,
latestTimestamp: 1000,
currentAgentState: 'working',
currentAgentEntry: null,
currentAgentEntry: makeTestEntry(paneKey),
unread: false,
responsePreview: '',
...overrides
}
}
describe('isChildAgentEntry', () => {
it('returns false for null, undefined, or entries without orchestration', () => {
expect(isChildAgentEntry(null)).toBe(false)
expect(isChildAgentEntry(undefined)).toBe(false)
expect(isChildAgentEntry(makeTestEntry())).toBe(false)
function makeEventFor(entry: AgentStatusEntry): ActivityEvent {
const worktree = makeWorktree()
return {
id: `event-${entry.paneKey}`,
state: 'done',
timestamp: 1000,
unread: false,
worktree,
repo: null,
tab: makeTabWithIds('tab-1', worktree.id),
agentType: 'claude',
agentAlive: true,
entry
}
}
describe('collectChildAgentPaneKeys', () => {
it('returns an empty set when no thread carries orchestration', () => {
const threads = [makeTestThread(PANE_KEY), makeTestThread(PANE_KEY_2)]
expect(collectChildAgentPaneKeys(threads).size).toBe(0)
})
it('returns true when parentPaneKey is different from entry paneKey', () => {
const entry = makeTestEntry({
orchestration: {
parentPaneKey: PANE_KEY_2,
taskId: 'task-1',
dispatchId: 'ctx-1'
}
it('classifies a thread whose parent pane is listed as a child', () => {
const parent = makeTestThread(PANE_KEY)
const child = makeTestThread(PANE_KEY_2, {
currentAgentEntry: makeTestEntry(PANE_KEY_2, {
orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
})
})
expect(isChildAgentEntry(entry)).toBe(true)
expect(collectChildAgentPaneKeys([parent, child])).toEqual(new Set([PANE_KEY_2]))
})
it('returns false when parentPaneKey matches entry paneKey', () => {
const entry = makeTestEntry({
orchestration: {
parentPaneKey: PANE_KEY,
taskId: 'task-1',
dispatchId: 'ctx-1'
}
it('promotes an orphan whose parent pane is no longer listed', () => {
const orphan = makeTestThread(PANE_KEY_2, {
currentAgentEntry: makeTestEntry(PANE_KEY_2, {
orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
})
})
expect(isChildAgentEntry(entry)).toBe(false)
expect(collectChildAgentPaneKeys([orphan]).size).toBe(0)
})
it('returns true when parentTerminalHandle is different from entry terminalHandle', () => {
const entry = makeTestEntry({
terminalHandle: 'terminal-child',
orchestration: {
parentTerminalHandle: 'terminal-parent',
taskId: 'task-1',
dispatchId: 'ctx-1'
}
it('ignores a self-referencing parentPaneKey', () => {
const thread = makeTestThread(PANE_KEY, {
currentAgentEntry: makeTestEntry(PANE_KEY, {
orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
})
})
expect(isChildAgentEntry(entry)).toBe(true)
expect(collectChildAgentPaneKeys([thread]).size).toBe(0)
})
it('returns true when coordinatorHandle is different from entry terminalHandle', () => {
const entry = makeTestEntry({
terminalHandle: 'terminal-child',
orchestration: {
coordinatorHandle: 'terminal-coordinator',
taskId: 'task-1',
dispatchId: 'ctx-1'
}
it('resolves coordinatorHandle through a listed thread terminal handle', () => {
const coordinator = makeTestThread(PANE_KEY, {
currentAgentEntry: makeTestEntry(PANE_KEY, { terminalHandle: 'terminal-coord' })
})
expect(isChildAgentEntry(entry)).toBe(true)
})
it('returns false when coordinator terminal handle matches coordinatorHandle', () => {
const entry = makeTestEntry({
terminalHandle: 'terminal-coordinator',
orchestration: {
coordinatorHandle: 'terminal-coordinator',
taskId: 'task-1',
dispatchId: 'ctx-1'
}
})
expect(isChildAgentEntry(entry)).toBe(false)
})
})
describe('isChildAgentThread', () => {
it('returns false for standalone threads', () => {
const thread = makeTestThread({
currentAgentEntry: makeTestEntry({ prompt: 'standalone task' })
})
expect(isChildAgentThread(thread)).toBe(false)
})
it('returns true when currentAgentEntry is a child agent', () => {
const thread = makeTestThread({
currentAgentEntry: makeTestEntry({
const worker = makeTestThread(PANE_KEY_2, {
currentAgentEntry: makeTestEntry(PANE_KEY_2, {
terminalHandle: 'terminal-worker',
orchestration: {
parentPaneKey: PANE_KEY_2,
coordinatorHandle: 'terminal-coord',
taskId: 'task-1',
dispatchId: 'ctx-1'
}
})
})
expect(isChildAgentThread(thread)).toBe(true)
expect(collectChildAgentPaneKeys([coordinator, worker])).toEqual(new Set([PANE_KEY_2]))
})
it('returns true when latestEvent entry is a child agent', () => {
const worktree = makeWorktree()
const tab = makeTabWithIds('tab-1', worktree.id)
const childEntry = makeTestEntry({
terminalHandle: 'terminal-child',
orchestration: {
coordinatorHandle: 'terminal-coord',
taskId: 'task-1',
dispatchId: 'ctx-1'
}
it('promotes a worker whose coordinator handle matches no listed thread', () => {
const worker = makeTestThread(PANE_KEY_2, {
currentAgentEntry: makeTestEntry(PANE_KEY_2, {
terminalHandle: 'terminal-worker',
orchestration: { coordinatorHandle: 'terminal-gone', taskId: 'task-1', dispatchId: 'ctx-1' }
})
})
const event: ActivityEvent = {
id: 'event-1',
state: 'done',
timestamp: 1000,
unread: false,
worktree,
repo: null,
tab,
agentType: 'claude',
agentAlive: true,
entry: childEntry
}
const thread = makeTestThread({
currentAgentEntry: null,
latestEvent: event,
events: [event]
})
expect(isChildAgentThread(thread)).toBe(true)
expect(collectChildAgentPaneKeys([worker]).size).toBe(0)
})
it('returns true when any event in events is a child agent', () => {
const worktree = makeWorktree()
const tab = makeTabWithIds('tab-1', worktree.id)
const childEntry = makeTestEntry({
orchestration: {
parentPaneKey: PANE_KEY_2,
taskId: 'task-1',
dispatchId: 'ctx-1'
}
it('keeps child classification from an older event while the parent is listed', () => {
const parent = makeTestThread(PANE_KEY)
const childEntry = makeTestEntry(PANE_KEY_2, {
orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
})
const event: ActivityEvent = {
id: 'event-1',
state: 'done',
timestamp: 1000,
unread: false,
worktree,
repo: null,
tab,
agentType: 'claude',
agentAlive: true,
entry: childEntry
}
const thread = makeTestThread({
currentAgentEntry: null,
latestEvent: null,
events: [event]
const child = makeTestThread(PANE_KEY_2, {
currentAgentEntry: makeTestEntry(PANE_KEY_2),
events: [makeEventFor(childEntry)]
})
expect(isChildAgentThread(thread)).toBe(true)
expect(collectChildAgentPaneKeys([parent, child])).toEqual(new Set([PANE_KEY_2]))
})
it('classifies a grandchild chained through a listed child', () => {
const root = makeTestThread(PANE_KEY)
const child = makeTestThread(PANE_KEY_2, {
currentAgentEntry: makeTestEntry(PANE_KEY_2, {
orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-1', dispatchId: 'ctx-1' }
})
})
const grandchild = makeTestThread(PANE_KEY_3, {
currentAgentEntry: makeTestEntry(PANE_KEY_3, {
orchestration: { parentPaneKey: PANE_KEY_2, taskId: 'task-2', dispatchId: 'ctx-2' }
})
})
expect(collectChildAgentPaneKeys([root, child, grandchild])).toEqual(
new Set([PANE_KEY_2, PANE_KEY_3])
)
})
it('promotes every member of a parent cycle instead of hiding them all', () => {
const a = makeTestThread(PANE_KEY, {
currentAgentEntry: makeTestEntry(PANE_KEY, {
orchestration: { parentPaneKey: PANE_KEY_2, taskId: 'task-1', dispatchId: 'ctx-1' }
})
})
const b = makeTestThread(PANE_KEY_2, {
currentAgentEntry: makeTestEntry(PANE_KEY_2, {
orchestration: { parentPaneKey: PANE_KEY, taskId: 'task-2', dispatchId: 'ctx-2' }
})
})
expect(collectChildAgentPaneKeys([a, b]).size).toBe(0)
})
})
@@ -1,29 +1,110 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { AgentPaneThread } from './activity-thread-types'
export function isChildAgentEntry(entry: AgentStatusEntry | null | undefined): boolean {
if (!entry?.orchestration) {
return false
/** Every entry that can carry the pane's orchestration lineage, newest first. */
function candidateEntries(thread: AgentPaneThread): AgentStatusEntry[] {
const entries: AgentStatusEntry[] = []
if (thread.currentAgentEntry) {
entries.push(thread.currentAgentEntry)
}
const orch = entry.orchestration
if (orch.parentPaneKey && orch.parentPaneKey !== entry.paneKey) {
return true
if (thread.latestEvent?.entry) {
entries.push(thread.latestEvent.entry)
}
if (orch.parentTerminalHandle && orch.parentTerminalHandle !== entry.terminalHandle) {
return true
for (const event of thread.events) {
entries.push(event.entry)
}
if (orch.coordinatorHandle && orch.coordinatorHandle !== entry.terminalHandle) {
return true
}
return false
return entries
}
export function isChildAgentThread(thread: AgentPaneThread): boolean {
if (isChildAgentEntry(thread.currentAgentEntry)) {
return true
function resolveEntryParentPaneKey(
entry: AgentStatusEntry,
ownPaneKey: string,
threadPaneKeys: ReadonlySet<string>,
paneKeyByTerminalHandle: ReadonlyMap<string, string>
): string | undefined {
const orch = entry.orchestration
if (!orch) {
return undefined
}
if (isChildAgentEntry(thread.latestEvent?.entry)) {
return true
if (
orch.parentPaneKey &&
orch.parentPaneKey !== ownPaneKey &&
threadPaneKeys.has(orch.parentPaneKey)
) {
return orch.parentPaneKey
}
return thread.events.some((event) => isChildAgentEntry(event.entry))
for (const handle of [orch.parentTerminalHandle, orch.coordinatorHandle]) {
const parentPaneKey = handle ? paneKeyByTerminalHandle.get(handle) : undefined
if (parentPaneKey && parentPaneKey !== ownPaneKey) {
return parentPaneKey
}
}
return undefined
}
/** A chain ending at a listed non-child thread is a real lineage; a cycle
* (malformed metadata) is not — its members stay top-level, like the dashboard tree. */
function ancestorChainReachesRoot(
paneKey: string,
parentByChildPaneKey: ReadonlyMap<string, string>
): boolean {
const seen = new Set([paneKey])
let current = paneKey
for (;;) {
const parent = parentByChildPaneKey.get(current)
if (!parent) {
return true
}
if (seen.has(parent)) {
return false
}
seen.add(parent)
current = parent
}
}
/**
* Pane keys of threads that are children of another currently listed thread.
* Mirrors the dashboard's resolveAgentRowParentPaneKey rules: a parent reference
* only counts while the parent thread still exists, so orphaned workers (their
* coordinator pane closed) are promoted to top level instead of staying hidden
* behind the child-agent filter.
*/
export function collectChildAgentPaneKeys(threads: readonly AgentPaneThread[]): Set<string> {
const threadPaneKeys = new Set(threads.map((thread) => thread.paneKey))
const paneKeyByTerminalHandle = new Map<string, string>()
for (const thread of threads) {
for (const entry of candidateEntries(thread)) {
if (entry.terminalHandle) {
if (!paneKeyByTerminalHandle.has(entry.terminalHandle)) {
paneKeyByTerminalHandle.set(entry.terminalHandle, thread.paneKey)
}
break
}
}
}
const parentByChildPaneKey = new Map<string, string>()
for (const thread of threads) {
for (const entry of candidateEntries(thread)) {
const parentPaneKey = resolveEntryParentPaneKey(
entry,
thread.paneKey,
threadPaneKeys,
paneKeyByTerminalHandle
)
if (parentPaneKey) {
parentByChildPaneKey.set(thread.paneKey, parentPaneKey)
break
}
}
}
const childPaneKeys = new Set<string>()
for (const childPaneKey of parentByChildPaneKey.keys()) {
if (ancestorChainReachesRoot(childPaneKey, parentByChildPaneKey)) {
childPaneKeys.add(childPaneKey)
}
}
return childPaneKeys
}
@@ -152,7 +152,7 @@ export function ActivityStatusGroupHeader({
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold uppercase tracking-[0.05em] text-foreground/80 transition-colors group-hover:text-foreground">
{group.label}
</span>
<span className="rounded-full border border-border/80 bg-muted/80 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums leading-none text-foreground/80 shadow-2xs">
<span className="rounded-full border border-border/80 bg-muted/80 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums leading-none text-foreground/80 shadow-xs">
{group.threads.length}
</span>
</div>
@@ -36,6 +36,10 @@ const observeActivityListRect: typeof observeElementRect = (instance, cb) =>
cb(rect.height > 0 ? rect : ZERO_RECT_FALLBACK_VIEWPORT)
})
// Uncontrolled collapse state must survive remounts alongside the caller's scroll
// ref, or a restored scrollTop lands on a different (all-expanded) layout.
const collapsedGroupsByScrollRef = new WeakMap<React.MutableRefObject<number>, Set<string>>()
export function ActivityThreadListPane({
threadListRef,
threadListWidth,
@@ -112,7 +116,7 @@ export function ActivityThreadListPane({
scrollTopRef?: React.MutableRefObject<number>
}): React.JSX.Element {
const [internalCollapsedGroupKeys, setInternalCollapsedGroupKeys] = useState<Set<string>>(
() => new Set()
() => (scrollTopRef ? collapsedGroupsByScrollRef.get(scrollTopRef) : undefined) ?? new Set()
)
const isControlled = collapsedGroupKeys !== undefined && onToggleGroupCollapse !== undefined
const effectiveCollapsedGroupKeys = isControlled ? collapsedGroupKeys : internalCollapsedGroupKeys
@@ -126,24 +130,32 @@ export function ActivityThreadListPane({
} else {
next.add(groupKey)
}
if (scrollTopRef) {
collapsedGroupsByScrollRef.set(scrollTopRef, next)
}
return next
})
}
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
const hasRestoredScrollRef = useRef(false)
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (scrollTopRef) {
scrollTopRef.current = event.currentTarget.scrollTop
if (!scrollTopRef) {
return
}
const scrollTop = event.currentTarget.scrollTop
// A clamp-to-0 fired before the deferred restore must not wipe the saved offset.
if (!hasRestoredScrollRef.current) {
if (scrollTop === 0) {
return
}
hasRestoredScrollRef.current = true
}
scrollTopRef.current = scrollTop
},
[scrollTopRef]
)
useEffect(() => {
if (scrollContainerRef.current && scrollTopRef) {
scrollContainerRef.current.scrollTop = scrollTopRef.current
}
}, [scrollTopRef])
const virtualItems = useMemo(
() =>
buildActivityVirtualItems({
@@ -210,6 +222,29 @@ export function ActivityThreadListPane({
useFlushSync: false
})
// Row heights differ between densities; drop stale measurements on toggle.
useEffect(() => {
virtualizer.measure()
}, [virtualizer, compactMode])
// Restore only once the (estimated) content can contain the saved offset, so a
// pre-hydration mount doesn't clamp the restore to 0.
const totalSize = virtualizer.getTotalSize()
useEffect(() => {
if (!scrollTopRef || hasRestoredScrollRef.current) {
return
}
if (scrollTopRef.current > 0 && scrollTopRef.current >= totalSize) {
return
}
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) {
return
}
scrollContainer.scrollTop = scrollTopRef.current
hasRestoredScrollRef.current = true
}, [scrollTopRef, totalSize])
const scrollOffset = virtualizer.scrollOffset ?? 0
const activeStickyHeaderIndex =
groupBy !== 'none'
@@ -140,6 +140,39 @@ describe('ActivityThreadListPane virtualization', () => {
expect(container.textContent).toContain('Virtual agent 2')
})
it('keeps the saved scroll offset when the pane mounts before threads hydrate', () => {
const scrollTopRef = { current: 360 }
renderPane(root, { threads: [], scrollTopRef })
const scrollContainer = container.querySelector<HTMLElement>('.overflow-y-auto')
// Empty list cannot contain the offset: restore is deferred, not clamped to 0.
act(() => {
if (scrollContainer) {
scrollContainer.scrollTop = 0
scrollContainer.dispatchEvent(new Event('scroll', { bubbles: true }))
}
})
expect(scrollTopRef.current).toBe(360)
renderPane(root, { threads: makeManyThreads(), scrollTopRef })
expect(container.querySelector<HTMLElement>('.overflow-y-auto')?.scrollTop).toBe(360)
})
it('restores collapse state saved alongside the scroll ref across remounts', () => {
const scrollTopRef = { current: 0 }
renderPane(root, { threads: makeManyThreads(), scrollTopRef })
const header = container.querySelector('[role="button"]') as HTMLElement
act(() => {
header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(header.getAttribute('aria-expanded')).toBe('false')
act(() => root.unmount())
root = createRoot(container)
renderPane(root, { threads: makeManyThreads(), scrollTopRef })
const remountedHeader = container.querySelector('[role="button"]')
expect(remountedHeader?.getAttribute('aria-expanded')).toBe('false')
})
it('restores the Agents scroll position without storing it in React state', () => {
const scrollTopRef = { current: 240 }
renderPane(root, { threads: makeManyThreads(), scrollTopRef })
@@ -147,7 +147,7 @@ export function ActivityThreadListToolbar({
className={cn(
'size-7 shrink-0 p-0 rounded-md transition-all',
readFilter === 'unread'
? '!border border-primary/50 !bg-primary/20 !text-primary shadow-2xs hover:!bg-primary/30'
? '!border border-primary/50 !bg-primary/20 !text-primary shadow-xs hover:!bg-primary/30'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
)}
aria-label={translate(
@@ -124,21 +124,7 @@ export type ActivityThreadRowCopy = {
workspaceLabel: string
}
export function formatCompactRelativeTime(timestamp: number, now = Date.now()): string {
const delta = Math.max(0, now - timestamp)
if (delta < 60_000) {
return 'now'
}
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) {
return `${minutes}m`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h`
}
return `${Math.floor(hours / 24)}d`
}
export { formatShortTimeAgo as formatCompactRelativeTime } from '@/lib/short-time-ago'
function normalizeScanLabel(value: string): string {
return value.trim().toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ')
@@ -1,365 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agent card — before / after</title>
<style>
@font-face {
font-family: Geist;
src: url('../../assets/fonts/Geist-Variable.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
:root {
--background: #fff;
--foreground: #0a0a0a;
--muted: #f5f5f5;
--muted-foreground: #737373;
--border: #e5e5e5;
--sidebar: #fafafa;
--primary: #171717;
--agent-question-text: #c2410c;
--status-success: #15803d;
--working: #ca8a04;
letter-spacing: 0.01em;
}
html.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a1a1a1;
--border: rgb(255 255 255 / 0.1);
--sidebar: #171717;
--primary: #fafafa;
--agent-question-text: #fdba74;
--status-success: #4ade80;
--working: #eab308;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 32px;
font-family: Geist, ui-sans-serif, system-ui, sans-serif;
background: #f3f3f3;
color: var(--foreground);
}
html.dark body {
background: #111;
}
h1 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 600;
}
.lede {
margin: 0 0 20px;
max-width: 640px;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.5;
}
button.theme {
height: 32px;
padding: 0 12px;
margin-bottom: 24px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--background);
color: var(--foreground);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.cols {
display: grid;
grid-template-columns: 280px 280px;
gap: 40px;
align-items: start;
}
h2 {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
}
.why {
margin: 0 0 12px;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.45;
min-height: 54px;
}
.sidebar {
width: 268px;
padding: 6px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--sidebar);
}
.group {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 6px;
color: var(--muted-foreground);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.count {
margin-left: auto;
padding: 2px 6px;
border-radius: 999px;
border: 1px solid var(--border);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.card {
position: relative;
width: 100%;
border: 1px solid transparent;
border-radius: 10px;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
text-align: left;
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex: none;
}
.dot.done {
background: var(--status-success);
}
.dot.working {
background: var(--working);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--working) 22%, transparent);
}
.dot.waiting {
background: #ea580c;
}
.icon {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 4px;
background: var(--foreground);
color: var(--background);
font-size: 9px;
font-weight: 700;
flex: none;
}
.icon.claude {
background: #d97706;
color: #fff;
}
.unread {
position: absolute;
top: 10px;
bottom: 10px;
left: 4px;
width: 2px;
border-radius: 999px;
background: var(--primary);
}
.now {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
}
.now-top,
.now-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 10px;
color: var(--muted-foreground);
}
.now-top-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.proj {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.branch {
font-family: ui-monospace, Menlo, monospace;
font-size: 10.5px;
}
.now-title {
font-size: 13px;
font-weight: 500;
}
.noise {
outline: 1.5px dashed #dc2626;
outline-offset: 1px;
border-radius: 3px;
}
.after {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
}
.after-title {
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
.after-title.plain {
font-weight: 500;
}
.meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted-foreground);
}
.meta .who {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.time {
flex: none;
font-variant-numeric: tabular-nums;
}
.status {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--background);
font-size: 13px;
line-height: 1.4;
color: color-mix(in srgb, var(--foreground) 90%, transparent);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.status.wait {
color: var(--agent-question-text);
}
.key {
margin-top: 16px;
font-size: 12px;
color: var(--muted-foreground);
line-height: 1.5;
}
.key b {
color: var(--foreground);
font-weight: 600;
}
</style>
</head>
<body>
<h1>What the card should answer</h1>
<p class="lede">
The current card answers <i>where</i> and <i>which CLI</i>. It never answers
<i>what happened</i>. Red dashed boxes mark chrome that crowds out the work.
</p>
<button class="theme" type="button" id="theme">Dark</button>
<div class="cols">
<section>
<h2>Now — identity chrome</h2>
<p class="why">
ORCA, the branch, “20 hours ago”, and Codex all speak before the task. Last activity is
hidden in compact mode, so this card has nothing useful on it.
</p>
<div class="sidebar">
<div class="group"><span class="dot done"></span> Done <span class="count">1</span></div>
<div class="card now">
<span class="unread"></span>
<div class="now-top">
<div class="now-top-left">
<span class="dot done"></span>
<span class="icon">Cx</span>
<span class="proj noise">ORCA</span>
<span class="branch noise">low-hanging-issues</span>
</div>
<span class="noise">20 hours ago</span>
</div>
<div class="now-title">low hanging issues</div>
<div class="now-foot"><span class="noise">Codex</span></div>
</div>
</div>
</section>
<section>
<h2>Proposed — title first, meta at the bottom</h2>
<p class="why">
Task, then last activity. State, agent, and time sit on one footer row — time pinned
right.
</p>
<div class="sidebar">
<div class="group"><span class="dot done"></span> Done <span class="count">1</span></div>
<div class="card after">
<span class="unread"></span>
<div class="after-title">Low hanging issues</div>
<div class="status">
Filed 8 issues from the audit. Left the flaky checkout path for a follow-up.
</div>
<div class="meta">
<span class="dot done"></span>
<span class="icon">Cx</span>
<span class="who">low-hanging-issues</span>
<span class="time">20h</span>
</div>
</div>
<div class="group">
<span class="dot working"></span> Working <span class="count">1</span>
</div>
<div class="card after" style="background: transparent">
<div class="after-title plain">Fix checkout race</div>
<div class="status">Edit src/checkout/session.ts</div>
<div class="meta">
<span class="dot working"></span>
<span class="icon claude">Cl</span>
<span class="who">checkout-race</span>
<span class="time">2m</span>
</div>
</div>
<div class="group">
<span class="dot waiting"></span> Waiting <span class="count">1</span>
</div>
<div class="card after" style="background: transparent">
<div class="after-title plain">Review PR 1842</div>
<div class="status wait">Waiting for input</div>
<div class="meta">
<span class="dot waiting"></span>
<span class="icon">Cx</span>
<span class="who">pr-1842</span>
<span class="time">8m</span>
</div>
</div>
</div>
</section>
</div>
<p class="key">
<b>Primary:</b> task title, then last activity.<br />
<b>Footer:</b> state, agent icon, workspace, and time — time at the bottom right.
</p>
<script>
document.getElementById('theme').addEventListener('click', () => {
const dark = document.documentElement.classList.toggle('dark')
document.getElementById('theme').textContent = dark ? 'Light' : 'Dark'
})
</script>
</body>
</html>
@@ -167,7 +167,7 @@ export const ActivityThreadRow = React.memo(function ActivityThreadRow({
onMarkRead(thread)
}}
onMouseDown={(event) => event.stopPropagation()}
className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-accent/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded hover:bg-accent/80 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={translate(
'auto.components.activity.ActivityPrototypePage.markThreadRead',
'Mark thread as read'
@@ -6,7 +6,7 @@ import type { AppState } from '@/store/types'
import { getSettingsFocusedExecutionHostId } from '../../../../shared/execution-host'
import { buildActivityEvents, createActivityEventBuildCache } from './activity-event-builder'
import { buildAgentPaneThreads, createAgentPaneThreadReuseCache } from './activity-thread-builder'
import { isChildAgentThread } from './activity-thread-child-agent'
import { collectChildAgentPaneKeys } from './activity-thread-child-agent'
import { filterThreadsByActivityScope, resolveActivityScopeRepoIds } from './activity-scope-filter'
import {
activityThreadMatchesSearchQuery,
@@ -53,10 +53,10 @@ export function useAgentPaneThreads(args: {
}): {
storeData: AgentPaneThreadsStoreData
allThreads: AgentPaneThread[]
/** Threads inside the persisted host/project scope; bulk actions must not affect rows outside it. */
scopedThreads: AgentPaneThread[]
selectedPaneKeyIsLive: boolean
effectiveSelectedPaneKey: string | null
/** Threads shown after every active filter; bulk actions (mark all read, clear
* completed) operate on exactly this set so they never touch hidden rows. */
visibleThreads: AgentPaneThread[]
visibleThreadGroups: ActivityThreadGroup[]
/** Threads excluded by the persisted host/project scope — the chips row shows this so scope filtering is never silent. */
@@ -141,11 +141,7 @@ export function useAgentPaneThreads(args: {
// Why scope runs before the per-view filters: its hidden count must mean "hidden by
// the persisted host/project scope alone", not folded into unread/search misses.
const {
threads: scopeVisibleThreads,
matchingThreads: scopedThreads,
hiddenCount: scopeHiddenThreadCount
} = useMemo(
const { threads: scopeVisibleThreads, hiddenCount: scopeHiddenThreadCount } = useMemo(
() =>
filterThreadsByActivityScope({
threads: allThreads,
@@ -166,6 +162,11 @@ export function useAgentPaneThreads(args: {
]
)
// Why over allThreads (not the scoped list): child classification asks whether the
// parent pane still exists at all, and a scope filter hiding the parent must not
// reclassify its workers as orphans.
const childAgentPaneKeys = useMemo(() => collectChildAgentPaneKeys(allThreads), [allThreads])
// Why deferred: filtering hundreds of threads is interruptible background work; the input
// echoes the keystroke at full priority while the list catches up on the deferred value.
const deferredQuery = useDeferredValue(query)
@@ -185,7 +186,7 @@ export function useAgentPaneThreads(args: {
// Why: child agents (e.g. dispatched orchestration workers) are hidden by default to keep top-level agent views focused on root tasks.
if (
!showChildAgents &&
isChildAgentThread(thread) &&
childAgentPaneKeys.has(thread.paneKey) &&
thread.paneKey !== effectiveSelectedPaneKey
) {
return false
@@ -195,7 +196,14 @@ export function useAgentPaneThreads(args: {
}
return activityThreadMatchesSearchQuery({ thread, searchQuery: normalizedQuery })
})
}, [scopeVisibleThreads, readFilter, deferredQuery, effectiveSelectedPaneKey, showChildAgents])
}, [
scopeVisibleThreads,
readFilter,
deferredQuery,
effectiveSelectedPaneKey,
showChildAgents,
childAgentPaneKeys
])
const visibleThreadGroups = useMemo(
() => buildActivityThreadGroups(visibleThreads, groupBy),
@@ -205,7 +213,6 @@ export function useAgentPaneThreads(args: {
return {
storeData,
allThreads,
scopedThreads,
selectedPaneKeyIsLive,
effectiveSelectedPaneKey,
visibleThreads,
@@ -3,7 +3,7 @@
import type { CSSProperties, ReactNode } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { tmpdir } from 'node:os'
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../../shared/constants'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
@@ -39,7 +39,24 @@ vi.mock('@/components/ui/tooltip', () => ({
}))
vi.mock('./SidebarHeader', () => ({
default: () => <div data-testid="sidebar-header" />
default: ({
agentToolbar,
agentSearchRow
}: {
agentToolbar?: ReactNode
agentSearchRow?: ReactNode
}) => (
<div data-testid="sidebar-header">
{agentToolbar}
{agentSearchRow}
</div>
)
}))
vi.mock('./SidebarAgentsList', () => ({
default: ({ query }: { query: string }) => (
<div data-testid="sidebar-agents-list" data-query={query} />
)
}))
vi.mock('./SidebarNav', () => ({
@@ -223,6 +240,31 @@ describe('Sidebar', () => {
expect(fetchAllWorktrees).not.toHaveBeenCalled()
})
it('clears the agents search query when the search row closes', async () => {
setSidebarState(getDefaultSettings(tmpdir()))
mocks.state = { ...mocks.state, sidebarBody: 'agents' }
const view = render(sidebarElement())
const agentsList = await view.findByTestId('sidebar-agents-list')
const searchToggle = view.getAllByRole('button', { name: 'Search' })[0]
fireEvent.click(searchToggle)
const searchInput = view.getByPlaceholderText('Filter...')
fireEvent.change(searchInput, { target: { value: 'deploy' } })
expect(agentsList.getAttribute('data-query')).toBe('deploy')
// Escape hides the row; a lingering query would silently keep filtering the list.
fireEvent.keyDown(searchInput, { key: 'Escape' })
expect(view.queryByPlaceholderText('Filter...')).toBeNull()
expect(agentsList.getAttribute('data-query')).toBe('')
fireEvent.click(searchToggle)
fireEvent.change(view.getByPlaceholderText('Filter...'), { target: { value: 'again' } })
expect(agentsList.getAttribute('data-query')).toBe('again')
fireEvent.click(searchToggle)
expect(view.queryByPlaceholderText('Filter...')).toBeNull()
expect(agentsList.getAttribute('data-query')).toBe('')
})
it('closes the dashboard drawer when the Agents sidebar is disabled', async () => {
setSidebarState({ ...getDefaultSettings(tmpdir()), showAgentsSidebar: false })
const setAgentDashboardDrawerOpen = vi.fn()
@@ -52,7 +52,6 @@ export default function SidebarAgentsList({
const {
storeData,
scopedThreads,
selectedPaneKeyIsLive,
effectiveSelectedPaneKey,
visibleThreads,
@@ -66,33 +65,34 @@ export default function SidebarAgentsList({
}
}, [selectedPaneKeyIsLive])
// Why useMemo: rows are React.memo'd on these handlers; a fresh closure per render
// would defeat the bail-out and re-render every mounted row on unrelated updates.
const {
hasUnreadThreads,
markThreadRead,
markThreadUnread,
selectThread,
jumpToWorkspace,
markAllThreadsRead
} = useMemo(
() =>
createActivityThreadActions({
allThreads: scopedThreads,
acknowledgeAgents: storeData.acknowledgeAgents,
unacknowledgeAgents: storeData.unacknowledgeAgents,
setSelectedPaneKey
}),
[scopedThreads, storeData.acknowledgeAgents, storeData.unacknowledgeAgents]
)
// Why a ref: rows are React.memo'd on these handlers; recreating them whenever the
// thread array identity changes (every status ping) would re-render every mounted row.
const visibleThreadsRef = useRef(visibleThreads)
useEffect(() => {
visibleThreadsRef.current = visibleThreads
}, [visibleThreads])
const { markThreadRead, markThreadUnread, selectThread, jumpToWorkspace, markAllThreadsRead } =
useMemo(
() =>
createActivityThreadActions({
getVisibleThreads: () => visibleThreadsRef.current,
acknowledgeAgents: storeData.acknowledgeAgents,
unacknowledgeAgents: storeData.unacknowledgeAgents,
setSelectedPaneKey
}),
[storeData.acknowledgeAgents, storeData.unacknowledgeAgents]
)
// Why visibleThreads: bulk actions and their enablement must match what the list
// shows — clearing/acking rows hidden by search, unread, or child filters would be silent.
const hasUnreadThreads = useMemo(() => visibleThreads.some((t) => t.unread), [visibleThreads])
const hasCompletedThreads = useMemo(
() => scopedThreads.some(isClearableActivityThread),
[scopedThreads]
() => visibleThreads.some(isClearableActivityThread),
[visibleThreads]
)
const handleClearCompleted = useCallback(() => {
clearCompletedActivity(scopedThreads)
}, [scopedThreads])
clearCompletedActivity(visibleThreadsRef.current)
}, [])
const canJumpToWorkspace = hasActivityThreadWorkspace
@@ -8,7 +8,8 @@ import SidebarHeader from './SidebarHeader'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const mocks = vi.hoisted(() => ({
openWorkspaceCreationComposerWithTourHandoff: vi.fn()
openWorkspaceCreationComposerWithTourHandoff: vi.fn(),
unreadCount: { value: 0 }
}))
type MockState = {
@@ -18,10 +19,14 @@ type MockState = {
sidebarWidth: number
setSidebarBody: (body: 'workspaces' | 'agents') => void
openModal: (modal: string, data?: unknown) => void
updateSettings: (patch: Record<string, unknown>) => void
openActivityPage: () => void
activeContextualTourId: string | null
settings?: {
showAgentsSidebar?: boolean
experimentalAgentDashboardPopout?: boolean
agentsSidebarIntroShown?: boolean
agentsSidebarMigratedFromExperimental?: boolean
}
}
@@ -60,6 +65,19 @@ vi.mock('../contextual-tours/workspace-creation-tour-handoff', () => ({
openWorkspaceCreationComposerWithTourHandoff: mocks.openWorkspaceCreationComposerWithTourHandoff
}))
vi.mock('@/components/activity/useActivityUnreadCount', () => ({
useActivityUnreadCount: (enabled: boolean) => (enabled ? mocks.unreadCount.value : 0)
}))
// Deterministic popover: expose the open flag instead of relying on radix portals.
vi.mock('@/components/ui/popover', () => ({
Popover: ({ children, open }: { children: React.ReactNode; open?: boolean }) => (
<div data-intro-open={open ? '' : undefined}>{children}</div>
),
PopoverAnchor: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
let container: HTMLDivElement
let root: Root
@@ -91,8 +109,13 @@ beforeEach(() => {
sidebarWidth: 280,
setSidebarBody: vi.fn(),
openModal: vi.fn(),
activeContextualTourId: null
updateSettings: vi.fn(),
openActivityPage: vi.fn(),
activeContextualTourId: null,
// Hydrated settings: the Agents tab is hidden until settings load.
settings: { showAgentsSidebar: true }
}
mocks.unreadCount.value = 0
container = document.createElement('div')
document.body.append(container)
root = createRoot(container)
@@ -252,6 +275,96 @@ describe('SidebarHeader', () => {
expect(mocks.openWorkspaceCreationComposerWithTourHandoff).toHaveBeenCalledTimes(1)
})
it('shows the unread count badge on the Agents tab', () => {
mocks.unreadCount.value = 3
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
})
const agentTab = container.querySelector<HTMLButtonElement>(
'button[data-sidebar-section-title="agents"]'
)
expect(agentTab?.textContent).toContain('3')
})
it('keeps the intro closed and unstamped before settings hydrate', () => {
mockState.settings = undefined
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
})
expect(container.querySelector('[data-intro-open]')).toBeNull()
const projectsTab = container.querySelector<HTMLButtonElement>(
'button[data-sidebar-section-title="projects"]'
)
act(() => {
projectsTab?.click()
})
expect(mockState.updateSettings).not.toHaveBeenCalled()
})
it('does not reset a persisted agents body before settings hydrate', () => {
mockState.settings = undefined
mockState.sidebarBody = 'agents'
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
})
expect(mockState.setSidebarBody).not.toHaveBeenCalled()
})
it('opens the intro once hydrated and stamps it only while it is on screen', () => {
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
})
expect(container.querySelector('[data-intro-open]')).toBeTruthy()
const agentTab = container.querySelector<HTMLButtonElement>(
'button[data-sidebar-section-title="agents"]'
)
act(() => {
agentTab?.click()
})
expect(mockState.updateSettings).toHaveBeenCalledWith({ agentsSidebarIntroShown: true })
})
it('never re-stamps the intro after it was acknowledged', () => {
mockState.settings = { showAgentsSidebar: true, agentsSidebarIntroShown: true }
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
})
expect(container.querySelector('[data-intro-open]')).toBeNull()
const agentTab = container.querySelector<HTMLButtonElement>(
'button[data-sidebar-section-title="agents"]'
)
act(() => {
agentTab?.click()
})
expect(mockState.updateSettings).not.toHaveBeenCalled()
})
it('opens the full Agents view from the expand button in agents mode', () => {
mockState.settings = { showAgentsSidebar: true, agentsSidebarIntroShown: true }
mockState.sidebarBody = 'agents'
act(() => {
root.render(<SidebarHeader onWorkspaceBoardMenuOpenChange={vi.fn()} />)
})
const expandButton = container.querySelector<HTMLButtonElement>(
'[aria-label="Open full Agents view"]'
)
expect(expandButton).toBeTruthy()
act(() => {
expandButton?.click()
})
expect(mockState.openActivityPage).toHaveBeenCalledTimes(1)
})
it('switches to compact actions only below the wide-layout breakpoint', () => {
mockState.sidebarWidth = 234
act(() => {
@@ -1,12 +1,15 @@
import React, { useEffect } from 'react'
import { Maximize2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import { SidebarViewToggle } from './sidebar-view-toggle'
import { SidebarHeaderActions } from './sidebar-header-actions'
import { shouldShowAgentDashboardSidebarButton } from './agent-dashboard-sidebar-visibility'
import { useActivityUnreadCount } from '@/components/activity/useActivityUnreadCount'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
type SidebarHeaderProps = {
onWorkspaceBoardMenuOpenChange: (open: boolean) => void
@@ -37,8 +40,13 @@ const SidebarHeader = React.memo(function SidebarHeader({
const migratedFromExperimental = useAppStore(
(s) => s.settings?.agentsSidebarMigratedFromExperimental === true
)
// Why: settings are null until hydration; deriving intro visibility from the
// null default would flash the popover open (and stamp it shown) every launch.
const settingsHydrated = useAppStore((s) => s.settings != null)
const openActivityPage = useAppStore((s) => s.openActivityPage)
const agentsViewActive = showAgentsSidebar && sidebarBody === 'agents'
const introOpen = showAgentsSidebar && !agentsSidebarIntroShown
const agentsUnreadCount = useActivityUnreadCount(showAgentsSidebar, 'sidebar-badge')
const introOpen = settingsHydrated && showAgentsSidebar && !agentsSidebarIntroShown
const acknowledgeIntro = React.useCallback(() => {
void updateSettings?.({ agentsSidebarIntroShown: true })
}, [updateSettings])
@@ -48,10 +56,11 @@ const SidebarHeader = React.memo(function SidebarHeader({
const workspaceTabLabel = groupBy === 'none' ? spacesLabel : projectsLabel
useEffect(() => {
if (!showAgentsSidebar && sidebarBody === 'agents') {
// Wait for hydration: settings null must not clobber a persisted 'agents' body.
if (settingsHydrated && !showAgentsSidebar && sidebarBody === 'agents') {
setSidebarBody?.('workspaces')
}
}, [setSidebarBody, showAgentsSidebar, sidebarBody])
}, [setSidebarBody, settingsHydrated, showAgentsSidebar, sidebarBody])
return (
<>
@@ -70,7 +79,10 @@ const SidebarHeader = React.memo(function SidebarHeader({
ariaLabel={translate('auto.components.sidebar.SidebarHeader.views', 'Sidebar view')}
value={agentsViewActive ? 'agents' : 'workspaces'}
onSelect={(value) => {
acknowledgeIntro()
// Only stamp the intro as seen when it is actually on screen.
if (introOpen) {
acknowledgeIntro()
}
setSidebarBody?.(value as 'workspaces' | 'agents')
}}
options={[
@@ -85,7 +97,8 @@ const SidebarHeader = React.memo(function SidebarHeader({
{
value: 'agents' as const,
label: translate('dashboard.sidebar.label', 'Agents'),
sectionTitle: 'agents' as const
sectionTitle: 'agents' as const,
badgeCount: agentsUnreadCount
}
]
: [])
@@ -134,7 +147,34 @@ const SidebarHeader = React.memo(function SidebarHeader({
</div>
</PopoverContent>
</Popover>
{agentsViewActive ? <div className="shrink-0">{agentToolbar}</div> : null}
{agentsViewActive ? (
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
type="button"
className="text-muted-foreground"
aria-label={translate(
'auto.components.sidebar.SidebarHeader.openFullAgentsView',
'Open full Agents view'
)}
onClick={() => openActivityPage?.()}
>
<Maximize2 className="size-3.5" strokeWidth={2.25} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{translate(
'auto.components.sidebar.SidebarHeader.openFullAgentsView',
'Open full Agents view'
)}
</TooltipContent>
</Tooltip>
{agentToolbar}
</div>
) : null}
{!agentsViewActive ? (
<SidebarHeaderActions onWorkspaceBoardMenuOpenChange={onWorkspaceBoardMenuOpenChange} />
) : null}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { shouldShowAgentDashboardSidebarButton } from './agent-dashboard-sidebar-visibility'
describe('shouldShowAgentDashboardSidebarButton', () => {
it('hides while settings are not yet hydrated', () => {
expect(shouldShowAgentDashboardSidebarButton(null)).toBe(false)
expect(shouldShowAgentDashboardSidebarButton(undefined)).toBe(false)
})
it('honors the explicit setting', () => {
expect(shouldShowAgentDashboardSidebarButton({ showAgentsSidebar: true })).toBe(true)
expect(shouldShowAgentDashboardSidebarButton({ showAgentsSidebar: false })).toBe(false)
expect(
shouldShowAgentDashboardSidebarButton({
showAgentsSidebar: false,
experimentalAgentDashboardPopout: true
})
).toBe(false)
})
it('falls back to the legacy popout flag, then defaults on', () => {
expect(shouldShowAgentDashboardSidebarButton({ experimentalAgentDashboardPopout: true })).toBe(
true
)
expect(shouldShowAgentDashboardSidebarButton({ experimentalAgentDashboardPopout: false })).toBe(
false
)
expect(shouldShowAgentDashboardSidebarButton({})).toBe(true)
})
})
@@ -6,5 +6,9 @@ export function shouldShowAgentDashboardSidebarButton(
| null
| undefined
): boolean {
return settings?.showAgentsSidebar ?? settings?.experimentalAgentDashboardPopout ?? true
// Null means settings not yet hydrated; hide so opted-out profiles don't flash Agents UI at startup.
if (!settings) {
return false
}
return settings.showAgentsSidebar ?? settings.experimentalAgentDashboardPopout ?? true
}
+13 -2
View File
@@ -65,6 +65,11 @@ function Sidebar({
const [agentGroupBy, setAgentGroupBy] = React.useState<ActivityGroupBy>('status')
const [agentQuery, setAgentQuery] = React.useState('')
const [agentSearchOpen, setAgentSearchOpen] = React.useState(false)
// Why clear on close: the hidden input's query would keep filtering the list with no visible indicator.
const closeAgentSearch = React.useCallback(() => {
setAgentSearchOpen(false)
setAgentQuery('')
}, [])
const [agentOptionsTarget, setAgentOptionsTarget] = React.useState<HTMLDivElement | null>(null)
const agentsScrollTopRef = React.useRef(0)
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
@@ -161,7 +166,13 @@ function Sidebar({
'Search'
)}
aria-pressed={agentSearchOpen}
onClick={() => setAgentSearchOpen((open) => !open)}
onClick={() => {
if (agentSearchOpen) {
closeAgentSearch()
} else {
setAgentSearchOpen(true)
}
}}
>
<Search className="size-3.5" strokeWidth={2.25} />
</Button>
@@ -212,7 +223,7 @@ function Sidebar({
onChange={(event) => setAgentQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setAgentSearchOpen(false)
closeAgentSearch()
}
}}
placeholder={translate(
@@ -55,4 +55,25 @@ describe('SidebarViewToggle', () => {
expect(visibleLabel?.className).toContain('whitespace-nowrap')
expect(visibleLabel?.className.includes('truncate')).toBe(false)
})
it('renders the unread badge only for a positive badgeCount', () => {
act(() => {
root.render(
<SidebarViewToggle
ariaLabel="Sidebar view"
value="workspaces"
onSelect={() => undefined}
options={[
{ value: 'workspaces', label: 'Projects', sectionTitle: 'projects', badgeCount: 0 },
{ value: 'agents', label: 'Agents', sectionTitle: 'agents', badgeCount: 5 }
]}
/>
)
})
const agentsTab = container.querySelector('[data-sidebar-section-title="agents"]')
expect(agentsTab?.textContent).toContain('5')
const projectsTab = container.querySelector('[data-sidebar-section-title="projects"]')
expect(projectsTab?.textContent).toBe('ProjectsProjects')
})
})
@@ -7,6 +7,8 @@ type SidebarViewToggleOption = {
/** Every label this slot can ever show; reserves width so switching never resizes the tab. */
widthLabels?: readonly string[]
sectionTitle?: string
/** Unread count rendered after the label; hidden at 0. */
badgeCount?: number
}
type SidebarViewToggleProps = {
@@ -60,7 +62,14 @@ export function SidebarViewToggle({
{widthLabel}
</span>
))}
<span className="col-start-1 row-start-1 whitespace-nowrap">{option.label}</span>
<span className="col-start-1 row-start-1 inline-flex items-center justify-center gap-1 whitespace-nowrap">
{option.label}
{option.badgeCount ? (
<span className="rounded-full bg-primary px-1.5 py-px text-[10px] font-semibold text-primary-foreground">
{option.badgeCount}
</span>
) : null}
</span>
</button>
)
})}
@@ -265,6 +265,41 @@ export function setVisibleWorktreeShortcutTargets(
* recomputes the order the sidebar *would* render from the same row pipeline,
* so a closed sidebar numbers workspaces the same way an open one does (#9497).
*/
export function buildVisibleWorktreeOptionsFromState(
state: ReturnType<typeof useAppStore.getState>,
repoMap: Map<string, Repo>
): VisibleWorktreeOptions {
return {
filterRepoIds: state.filterRepoIds,
showSleepingWorkspaces: state.showSleepingWorkspaces,
tabsByWorktree: state.tabsByWorktree,
ptyIdsByTabId: state.ptyIdsByTabId,
browserTabsByWorktree: state.browserTabsByWorktree,
worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent(
state.agentStatusByPaneKey,
state.tabsByWorktree,
Date.now()
),
hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices: state.hideWorkspacesFromOtherDevices,
pairedDeviceIdsByEnvironment: state.hideWorkspacesFromOtherDevices
? getPairedDeviceIdsByEnvironment(
state.runtimeEnvironments,
state.runtimeStatusByEnvironmentId
)
: EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT,
alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace,
repoMap,
workspaceHostScope: state.workspaceHostScope,
visibleWorkspaceHostIds: state.visibleWorkspaceHostIds,
defaultHostId: getSettingsFocusedExecutionHostId(state.settings),
worktreeLineageById: state.worktreeLineageById
}
}
export function getVisibleWorktreeIds(): string[] {
// Prefer the published IDs that mirror the rendered sidebar order.
if (_publishedVisibleIds) {
@@ -299,35 +334,11 @@ export function getVisibleWorktreeIds(): string[] {
sortedIds = sorted.map((w) => w.id)
}
const visibleIds = computeVisibleWorktreeIds(state.worktreesByRepo, sortedIds, {
filterRepoIds: state.filterRepoIds,
showSleepingWorkspaces: state.showSleepingWorkspaces,
tabsByWorktree: state.tabsByWorktree,
ptyIdsByTabId: state.ptyIdsByTabId,
browserTabsByWorktree: state.browserTabsByWorktree,
worktreeIdsWithLiveAgent: getWorktreeIdsWithLiveAgent(
state.agentStatusByPaneKey,
state.tabsByWorktree,
Date.now()
),
hideDefaultBranchWorkspace: state.hideDefaultBranchWorkspace,
hideAutomationGeneratedWorkspaces: state.hideAutomationGeneratedWorkspaces,
hideCliCreatedWorkspaces: state.hideCliCreatedWorkspaces,
hideDetachedHeadWorkspaces: state.hideDetachedHeadWorkspaces,
hideWorkspacesFromOtherDevices: state.hideWorkspacesFromOtherDevices,
pairedDeviceIdsByEnvironment: state.hideWorkspacesFromOtherDevices
? getPairedDeviceIdsByEnvironment(
state.runtimeEnvironments,
state.runtimeStatusByEnvironmentId
)
: EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT,
alwaysShowDefaultBranchWorkspace: state.alwaysShowDefaultBranchWorkspace,
repoMap,
workspaceHostScope: state.workspaceHostScope,
visibleWorkspaceHostIds: state.visibleWorkspaceHostIds,
defaultHostId: getSettingsFocusedExecutionHostId(state.settings),
worktreeLineageById: state.worktreeLineageById
})
const visibleIds = computeVisibleWorktreeIds(
state.worktreesByRepo,
sortedIds,
buildVisibleWorktreeOptionsFromState(state, repoMap)
)
const visibleIdRank = new Map(visibleIds.map((id, index) => [id, index]))
const visibleHostIds = getVisibleWorkspaceHostIdSet(state)
@@ -0,0 +1,112 @@
/** @vitest-environment happy-dom */
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { TooltipProvider } from '@/components/ui/tooltip'
import { CompactAgentRow } from './worktree-card-compact-agent-row'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.mock('@/components/dashboard/use-agent-row-conversation-name', () => ({
useAgentRowConversationName: () => null
}))
vi.mock('./CacheTimer', () => ({
default: () => null,
usePromptCacheCountdownForPane: () => null
}))
function makeAgent({
stateStartedAt,
lastAssistantMessage,
state = 'working'
}: {
stateStartedAt: number
lastAssistantMessage?: string
state?: string
}): DashboardAgentRowData {
return {
paneKey: 'tab-1:leaf-1',
tab: { id: 'tab-1' },
agentType: 'claude',
state,
startedAt: 500,
entry: {
prompt: 'do the task',
state,
stateStartedAt,
lastAssistantMessage,
paneKey: 'tab-1:leaf-1',
updatedAt: stateStartedAt
}
} as unknown as DashboardAgentRowData
}
let root: Root | undefined
afterEach(() => {
act(() => root?.unmount())
document.body.replaceChildren()
})
function renderRow(agent: DashboardAgentRowData): HTMLElement {
const container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => {
root!.render(
<TooltipProvider>
<CompactAgentRow agent={agent} now={2000} onActivate={() => {}} />
</TooltipProvider>
)
})
return container
}
function rerenderRow(agent: DashboardAgentRowData): void {
act(() => {
root!.render(
<TooltipProvider>
<CompactAgentRow agent={agent} now={2000} onActivate={() => {}} />
</TooltipProvider>
)
})
}
describe('CompactAgentRow stable assistant message', () => {
it('holds the last assistant line when a same-turn ping omits it', () => {
const container = renderRow(
makeAgent({ stateStartedAt: 1000, lastAssistantMessage: 'First reply' })
)
expect(container.textContent).toContain('First reply')
rerenderRow(makeAgent({ stateStartedAt: 1000 }))
expect(container.textContent).toContain('First reply')
})
it('drops the held line when a new turn starts', () => {
const container = renderRow(
makeAgent({ stateStartedAt: 1000, lastAssistantMessage: 'First reply' })
)
rerenderRow(makeAgent({ stateStartedAt: 3000 }))
expect(container.textContent).not.toContain('First reply')
})
it('never holds across pings for entries without a turn identity (stateStartedAt 0)', () => {
const container = renderRow(makeAgent({ stateStartedAt: 0, lastAssistantMessage: 'Turn one' }))
expect(container.textContent).toContain('Turn one')
rerenderRow(makeAgent({ stateStartedAt: 0 }))
expect(container.textContent).not.toContain('Turn one')
})
it('drops the held line when the agent leaves working', () => {
const container = renderRow(
makeAgent({ stateStartedAt: 1000, lastAssistantMessage: 'First reply' })
)
rerenderRow(makeAgent({ stateStartedAt: 1000, state: 'done' }))
rerenderRow(makeAgent({ stateStartedAt: 1000, state: 'working' }))
expect(container.textContent).not.toContain('First reply')
})
})
@@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react'
import React, { useCallback, useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { AgentStateDot, agentStateLabel } from '@/components/AgentStateDot'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
@@ -13,22 +13,7 @@ import { agentNoUpdateLabel } from '@/lib/agent-row-decay-state'
import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name'
import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp'
import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer'
function formatShortTimeAgo(ts: number, now: number): string {
const delta = now - ts
if (delta < 60_000) {
return 'now'
}
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) {
return `${minutes}m`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h`
}
return `${Math.floor(hours / 24)}d`
}
import { formatShortTimeAgo } from '@/lib/short-time-ago'
function getCompactAgentPrimary(
agent: DashboardAgentRowData,
@@ -134,21 +119,22 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
const primary = getCompactAgentPrimary(agent, conversationName)
const isLineageChild = agent.lineage?.depth === 1
// Keep a live row's last assistant line stable while status/tool payloads
// briefly omit the hook-only field between updates.
const lastAssistantMessageRef = useRef<{ turn: number; message: string } | null>(null)
const turn = agent.entry.stateStartedAt ?? 0
// briefly omit the hook-only field between updates. State (not a ref) so a
// discarded concurrent render can't pin an uncommitted message; a zero
// stateStartedAt has no per-turn identity, so those rows never cache.
const [heldMessage, setHeldMessage] = useState<{ turn: number; message: string } | null>(null)
const turn = agent.entry.stateStartedAt
const currentMessage = agent.entry.lastAssistantMessage?.trim() ?? ''
if (agent.state === 'working' && currentMessage) {
lastAssistantMessageRef.current = { turn, message: currentMessage }
} else if (agent.state !== 'working') {
lastAssistantMessageRef.current = null
} else if (lastAssistantMessageRef.current?.turn !== turn) {
lastAssistantMessageRef.current = null
const turnHoldable = agent.state === 'working' && turn > 0
if (turnHoldable && currentMessage) {
if (heldMessage?.turn !== turn || heldMessage.message !== currentMessage) {
setHeldMessage({ turn, message: currentMessage })
}
} else if (heldMessage !== null && (!turnHoldable || heldMessage.turn !== turn)) {
setHeldMessage(null)
}
const stableMessage =
agent.state === 'working' && !currentMessage
? lastAssistantMessageRef.current?.message
: undefined
turnHoldable && !currentMessage && heldMessage?.turn === turn ? heldMessage.message : undefined
const secondary = getCompactAgentSecondary(agent, now, stableMessage)
// Why: sidebar truncation must preserve the passive-vs-active distinction.
const leadingText = dotState === 'monitoring' ? secondary : primary
@@ -0,0 +1,21 @@
import { useAppStore } from '@/store'
import { getRepoMapFromState } from '@/store/selectors'
import {
buildVisibleWorktreeOptionsFromState,
computeVisibleWorktreeIds
} from './visible-worktrees'
/**
* Filter-only visibility for one worktree id: runs the sidebar filter pipeline
* without collapse elision or rendered order, so a target inside a collapsed
* group is not misreported as hidden by filters.
*/
export function worktreePassesSidebarFilters(worktreeId: string): boolean {
const state = useAppStore.getState()
const repoMap = getRepoMapFromState(state)
return computeVisibleWorktreeIds(
state.worktreesByRepo,
[],
buildVisibleWorktreeOptionsFromState(state, repoMap)
).includes(worktreeId)
}
@@ -7,7 +7,7 @@ export function dismissStaleAgentRowByKey(paneKey: string): void {
const store = useAppStore.getState()
const liveExisted = paneKey in store.agentStatusByPaneKey
const retainedExisted = paneKey in store.retainedAgentsByPaneKey
store.dropAgentStatus(paneKey)
store.dropAgentStatus(paneKey, { paneRemoved: true })
store.dismissRetainedAgent(paneKey)
if (liveExisted || retainedExisted) {
toast.info(
@@ -47,7 +47,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr
const leafId = manager.getLeafId(paneId)
if (leafId) {
useAppStore.getState().setCacheTimerStartedAt(makePaneKey(tabId, leafId), null)
useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId))
useAppStore.getState().dropAgentStatus(makePaneKey(tabId, leafId), { paneRemoved: true })
}
setTerminalErrorsByPaneId((current) => clearPaneTerminalError(current, paneId))
if (leafId) {
+16 -1
View File
@@ -5247,7 +5247,8 @@
"ca6f729da2": "New workspace ({{value0}})",
"a30e34eb5c": "Close workspace board",
"views": "Sidebar view",
"moreActions": "More workspace actions"
"moreActions": "More workspace actions",
"openFullAgentsView": "Open full Agents view"
},
"SidebarNav": {
"80611a8b10": "Search",
@@ -17274,5 +17275,19 @@
"unlinkedPr": {
"status": "PR #{{number}} unlinked"
}
},
"agentsSidebarIntro": {
"migrated": {
"title": "Agents are easier to find",
"description": "Your Agents view is now a dedicated sidebar tab. Your activity and filters are preserved.",
"dismiss": "Got it",
"action": "Open Agents"
},
"new": {
"title": "Meet your Agents tab",
"description": "See what your agents are working on, what is done, and where you need to step in.",
"dismiss": "Maybe later",
"action": "Try Agents"
}
}
}
+19 -2
View File
@@ -4410,7 +4410,10 @@
"5c9c7c16aa": "Agregar un proyecto para crear espacios de trabajo",
"ca6f729da2": "Nuevo espacio de trabajo ({{value0}})",
"a30e34eb5c": "Cerrar tablero del espacio de trabajo",
"25a95899c9": "Agregar proyecto"
"25a95899c9": "Agregar proyecto",
"openFullAgentsView": "Abrir vista completa de Agentes",
"spaces": "Espacios",
"views": "Vista de la barra lateral"
},
"SidebarNav": {
"80611a8b10": "Buscar",
@@ -14144,7 +14147,7 @@
"b29191b3e0": "worktree",
"8c3b621ddf": "Proyecto",
"4a3986b200": "Estado",
"770d458144": "Agrupar actividad del agente por",
"770d458144": "Agrupar por",
"795cbf26e2": "Filtrar...",
"4616ea39fd": "Ir al workspace",
"markThreadRead": "Marcar hilo como leído",
@@ -14839,5 +14842,19 @@
"unknown": "Restart Orca to try again."
}
}
},
"agentsSidebarIntro": {
"migrated": {
"title": "Los agentes son más fáciles de encontrar",
"description": "Tu vista de Agentes ahora es una pestaña dedicada de la barra lateral. Tu actividad y filtros se conservan.",
"dismiss": "Entendido",
"action": "Abrir Agentes"
},
"new": {
"title": "Conoce tu pestaña de Agentes",
"description": "Ve en qué están trabajando tus agentes, qué está terminado y dónde necesitas intervenir.",
"dismiss": "Quizás más tarde",
"action": "Probar Agentes"
}
}
}
+19 -2
View File
@@ -4391,7 +4391,10 @@
"5c9c7c16aa": "プロジェクトを追加してワークスペースを作成する",
"ca6f729da2": "新規ワークスペース ({{value0}})",
"a30e34eb5c": "ワークスペースボードを閉じる",
"25a95899c9": "プロジェクトを追加"
"25a95899c9": "プロジェクトを追加",
"openFullAgentsView": "Agent の全画面ビューを開く",
"spaces": "スペース",
"views": "サイドバービュー"
},
"SidebarNav": {
"80611a8b10": "検索",
@@ -14144,7 +14147,7 @@
"b29191b3e0": "ワークツリー",
"8c3b621ddf": "プロジェクト",
"4a3986b200": "状態",
"770d458144": "Agent のアクティビティをグループ化する",
"770d458144": "グループ化",
"795cbf26e2": "フィルター…",
"4616ea39fd": "ワークスペースにジャンプ",
"markThreadRead": "スレッドを既読としてマーク",
@@ -14839,5 +14842,19 @@
"unknown": "Orca を再起動してから、もう一度お試しください。"
}
}
},
"agentsSidebarIntro": {
"migrated": {
"title": "Agent が見つけやすくなりました",
"description": "Agent ビューはサイドバーの専用タブになりました。アクティビティとフィルターはそのまま引き継がれます。",
"dismiss": "OK",
"action": "Agent を開く"
},
"new": {
"title": "Agent タブのご紹介",
"description": "Agent が何に取り組んでいるか、何が完了したか、どこで対応が必要かを確認できます。",
"dismiss": "後で",
"action": "Agent を試す"
}
}
}
+37 -7
View File
@@ -3912,7 +3912,9 @@
"searchLinks": "링크 검색",
"deleteSkills": "스킬 삭제…"
},
"SkillShareSelectionControls": { "01c5a15e02": "스킬 공유" },
"SkillShareSelectionControls": {
"01c5a15e02": "스킬 공유"
},
"SkillRow": {
"updatedUnknown": "날짜 없음",
"pathCopied": "경로 복사됨",
@@ -3922,13 +3924,17 @@
"viewDetails": "세부 정보 보기",
"deleteSkill": "삭제…"
},
"SkillsList": { "listLabel": "스킬" },
"SkillsList": {
"listLabel": "스킬"
},
"sourceStatus": {
"missing": "폴더를 찾을 수 없음",
"remoteRepo": "원격 리포지토리 — 검색 안 됨",
"unavailable": "검색 안 됨"
},
"sources": { "heading": "스킬 폴더" },
"sources": {
"heading": "스킬 폴더"
},
"sourceKind": {
"home": "홈",
"workspace": "워크스페이스",
@@ -3950,7 +3956,10 @@
"linkOne": "링크 {{count}}개",
"linkOther": "링크 {{count}}개"
},
"filter": { "allAgents": "모든 에이전트", "sharedAgent": "공유됨 (.agents)" },
"filter": {
"allAgents": "모든 에이전트",
"sharedAgent": "공유됨 (.agents)"
},
"SkillsSelectionHeader": {
"exit": "선택 나가기",
"exitTooltip": "선택 나가기 · Esc",
@@ -3959,7 +3968,11 @@
"clear": "지우기",
"deleteTitle": "삭제할 스킬 선택"
},
"SkillDetailDialog": { "agents": "에이전트", "updated": "업데이트됨", "copy": "복사" },
"SkillDetailDialog": {
"agents": "에이전트",
"updated": "업데이트됨",
"copy": "복사"
},
"SkillFreshnessNudge": {
"titleOne": "설치된 Orca 스킬이 오래되었습니다",
"titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다",
@@ -4383,7 +4396,10 @@
"5c9c7c16aa": "워크스페이스를 만들려면 프로젝트를 추가하세요.",
"ca6f729da2": "새 워크스페이스({{value0}})",
"a30e34eb5c": "워크스페이스 보드 닫기",
"25a95899c9": "프로젝트 추가"
"25a95899c9": "프로젝트 추가",
"openFullAgentsView": "에이전트 전체 보기 열기",
"spaces": "스페이스",
"views": "사이드바 보기"
},
"SidebarNav": {
"80611a8b10": "검색",
@@ -14187,7 +14203,7 @@
"b29191b3e0": "워크트리",
"8c3b621ddf": "프로젝트",
"4a3986b200": "상태",
"770d458144": "agent 활동 그룹화 기준",
"770d458144": "그룹화 기준",
"795cbf26e2": "필터...",
"4616ea39fd": "워크스페이스로 이동",
"markThreadRead": "스레드를 읽은 것으로 표시",
@@ -14943,5 +14959,19 @@
"unknown": "Restart Orca to try again."
}
}
},
"agentsSidebarIntro": {
"migrated": {
"title": "에이전트를 더 쉽게 찾을 수 있습니다",
"description": "에이전트 보기가 이제 사이드바 전용 탭이 되었습니다. 활동과 필터는 그대로 유지됩니다.",
"dismiss": "확인",
"action": "에이전트 열기"
},
"new": {
"title": "에이전트 탭을 만나보세요",
"description": "에이전트가 무엇을 작업 중인지, 무엇이 완료되었는지, 어디에 개입이 필요한지 확인하세요.",
"dismiss": "나중에",
"action": "에이전트 사용해 보기"
}
}
}
+37 -7
View File
@@ -3922,7 +3922,9 @@
"searchLinks": "搜索链接",
"deleteSkills": "删除技能…"
},
"SkillShareSelectionControls": { "01c5a15e02": "共享技能" },
"SkillShareSelectionControls": {
"01c5a15e02": "共享技能"
},
"SkillRow": {
"updatedUnknown": "无日期",
"pathCopied": "路径已复制",
@@ -3932,13 +3934,17 @@
"viewDetails": "查看详情",
"deleteSkill": "删除…"
},
"SkillsList": { "listLabel": "技能" },
"SkillsList": {
"listLabel": "技能"
},
"sourceStatus": {
"missing": "未找到文件夹",
"remoteRepo": "远程仓库 — 未扫描",
"unavailable": "未扫描"
},
"sources": { "heading": "技能文件夹" },
"sources": {
"heading": "技能文件夹"
},
"sourceKind": {
"home": "主目录",
"workspace": "工作区",
@@ -3968,7 +3974,10 @@
"deleteLinkOne": "{{count}} 个链接",
"deleteLinkOther": "{{count}} 个链接"
},
"filter": { "allAgents": "所有 Agent", "sharedAgent": "共享 (.agents)" },
"filter": {
"allAgents": "所有 Agent",
"sharedAgent": "共享 (.agents)"
},
"SkillsSelectionHeader": {
"exit": "退出选择",
"exitTooltip": "退出选择 · Esc",
@@ -3977,7 +3986,11 @@
"clear": "清除",
"deleteTitle": "选择要删除的技能"
},
"SkillDetailDialog": { "agents": "Agent", "updated": "已更新", "copy": "复制" },
"SkillDetailDialog": {
"agents": "Agent",
"updated": "已更新",
"copy": "复制"
},
"SkillFreshnessNudge": {
"titleOne": "已安装的 Orca 技能已过期",
"titleMany": "{{value0}} 个已安装的 Orca 技能已过期",
@@ -4426,7 +4439,10 @@
"5c9c7c16aa": "添加项目以创建工作区",
"ca6f729da2": "新工作区 ({{value0}})",
"a30e34eb5c": "关闭工作区板",
"25a95899c9": "添加项目"
"25a95899c9": "添加项目",
"openFullAgentsView": "打开智能体完整视图",
"spaces": "空间",
"views": "侧边栏视图"
},
"SidebarNav": {
"80611a8b10": "搜索",
@@ -14187,7 +14203,7 @@
"b29191b3e0": "工作树",
"8c3b621ddf": "项目",
"4a3986b200": "状态",
"770d458144": "对智能体活动进行分组",
"770d458144": "分组方式",
"795cbf26e2": "筛选...",
"4616ea39fd": "跳转到工作区",
"markThreadRead": "将话题标记为已读",
@@ -14943,5 +14959,19 @@
"unknown": "请重启 Orca 以重试。"
}
}
},
"agentsSidebarIntro": {
"migrated": {
"title": "智能体更容易找到了",
"description": "智能体视图现在是侧边栏的专用标签页。你的活动和筛选条件都会保留。",
"dismiss": "知道了",
"action": "打开智能体"
},
"new": {
"title": "认识你的智能体标签页",
"description": "查看智能体正在做什么、哪些已完成,以及哪些需要你介入。",
"dismiss": "以后再说",
"action": "试用智能体"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
/** Compact "now / 5m / 3h / 2d" age label shared by agent rows and activity threads. */
export function formatShortTimeAgo(ts: number, now = Date.now()): string {
const delta = now - ts
if (delta < 60_000) {
return 'now'
}
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) {
return `${minutes}m`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h`
}
return `${Math.floor(hours / 24)}d`
}
@@ -3,17 +3,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getState: vi.fn(),
activateAndRevealWorktree: vi.fn(),
activateAndRevealFolderWorkspace: vi.fn(),
getVisibleWorktreeShortcutTargets: vi.fn(),
worktreePassesSidebarFilters: vi.fn(),
warning: vi.fn()
}))
vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } }))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: mocks.activateAndRevealWorktree
activateAndRevealWorktree: mocks.activateAndRevealWorktree,
activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace
}))
vi.mock('@/components/sidebar/visible-worktrees', () => ({
getVisibleWorktreeShortcutTargets: mocks.getVisibleWorktreeShortcutTargets
}))
vi.mock('@/components/sidebar/worktree-filter-visibility', () => ({
worktreePassesSidebarFilters: mocks.worktreePassesSidebarFilters
}))
vi.mock('sonner', () => ({ toast: { warning: mocks.warning } }))
import { jumpToWorktreeFromSidebar } from './worktree-jump-navigation'
@@ -22,7 +28,9 @@ describe('worktree jump navigation', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null })
mocks.activateAndRevealFolderWorkspace.mockReturnValue({ primaryTabId: null })
mocks.getVisibleWorktreeShortcutTargets.mockReturnValue([])
mocks.worktreePassesSidebarFilters.mockReturnValue(false)
mocks.getState.mockReturnValue({
sidebarBody: 'agents',
setSidebarBody: vi.fn(),
@@ -60,4 +68,35 @@ describe('worktree jump navigation', () => {
expect(mocks.warning).not.toHaveBeenCalled()
})
it('reveals instead of warning when the target is only inside a collapsed group', () => {
// Absent from the rendered list (collapse elision) but not excluded by filters.
mocks.getVisibleWorktreeShortcutTargets.mockReturnValue([])
mocks.worktreePassesSidebarFilters.mockReturnValue(true)
expect(jumpToWorktreeFromSidebar('wt-collapsed')).toBe(true)
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-collapsed', {})
expect(mocks.warning).not.toHaveBeenCalled()
})
it('routes folder workspaces through the path-status-gated folder activation', () => {
const state = mocks.getState()
expect(jumpToWorktreeFromSidebar('folder:folder-1', { executionHostId: 'local' })).toBe(true)
expect(mocks.activateAndRevealFolderWorkspace).toHaveBeenCalledWith('folder-1', {
executionHostId: 'local'
})
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
expect(state.setSidebarBody).toHaveBeenCalledWith('workspaces')
})
it('propagates a blocked folder-workspace activation as failure', () => {
const state = mocks.getState()
mocks.activateAndRevealFolderWorkspace.mockReturnValue(false)
expect(jumpToWorktreeFromSidebar('folder:folder-1')).toBe(false)
expect(state.setSidebarBody).not.toHaveBeenCalled()
})
})
@@ -1,9 +1,14 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
activateAndRevealFolderWorkspace,
activateAndRevealWorktree
} from '@/lib/worktree-activation'
import { getVisibleWorktreeShortcutTargets } from '@/components/sidebar/visible-worktrees'
import { worktreePassesSidebarFilters } from '@/components/sidebar/worktree-filter-visibility'
import { sidebarHasActiveFilters } from '@/components/sidebar/sidebar-filter-actions'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { normalizeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host'
function wasHiddenBySidebarFilters(worktreeId: string, executionHostId?: ExecutionHostId): boolean {
@@ -13,7 +18,7 @@ function wasHiddenBySidebarFilters(worktreeId: string, executionHostId?: Executi
return false
}
return !getVisibleWorktreeShortcutTargets().some((target) => {
const inRenderedTargets = getVisibleWorktreeShortcutTargets().some((target) => {
if (target.id !== worktreeId) {
return false
}
@@ -24,6 +29,12 @@ function wasHiddenBySidebarFilters(worktreeId: string, executionHostId?: Executi
normalizeExecutionHostId(target.executionHostId) === normalizeExecutionHostId(executionHostId)
)
})
if (inRenderedTargets) {
return false
}
// Absent from the rendered list can mean a collapsed group, not a filter:
// collapsed-but-unfiltered targets should be revealed, not toasted.
return !worktreePassesSidebarFilters(worktreeId)
}
/** Navigate from a worktree reference in either sidebar back to the workspace surface. */
@@ -32,6 +43,21 @@ export function jumpToWorktreeFromSidebar(
options?: { executionHostId?: ExecutionHostId }
): boolean {
const state = useAppStore.getState()
// Folder workspaces must go through the folder branch so its path-status gate runs.
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
const activated = activateAndRevealFolderWorkspace(
workspaceScope.folderWorkspaceId,
options?.executionHostId ? { executionHostId: options.executionHostId } : undefined
)
if (activated === false) {
return false
}
state.setSidebarBody?.('workspaces')
return true
}
const hiddenByFilters = wasHiddenBySidebarFilters(worktreeId, options?.executionHostId)
const activated = activateAndRevealWorktree(worktreeId, {
@@ -183,7 +183,11 @@ export function buildRetractedMirroredTabSweepPatch(
// so it must see the post-removal tab list, not the one the snapshot replaced.
tabsByWorktree: nextTabsByWorktree
}
const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId)
// Why: a retraction can be a reconnect re-key, not pane death (ssh-execution-boundary); keeping
// cutoffs means a republished pane cannot replay activity the user cleared on this client.
const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId, {
preserveActivityClearedState: true
})
if (!sweep?.agentStatusByPaneKey || !batchContext) {
return sweep ?? null
}
@@ -1,8 +1,12 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import type { RetainedAgentEntry } from './agent-status'
import { createTestStore } from './store-test-helpers'
import {
sanitizeAcknowledgedAgentsByPaneKey,
sanitizeActivityClearedAtByPaneKey
} from './ui/ui-slice-hydration-sanitizers'
function makeRetained(paneKey: string, worktreeId = 'wt-1'): RetainedAgentEntry {
const entry: AgentStatusEntry = {
@@ -88,3 +92,78 @@ describe('dismissRetainedAgents', () => {
expect(store.getState().retainedAgentsByPaneKey).toBe(before)
})
})
describe('dropAgentStatus cleared-at/manual-unread lifecycle', () => {
// Why: setAgentStatus schedules a real 30-minute freshness setTimeout.
afterEach(() => {
vi.useRealTimers()
})
function seedLiveWithClearState(store: ReturnType<typeof createTestStore>): void {
vi.useFakeTimers()
store.getState().setAgentStatus('tab-a:1', { state: 'done', prompt: 'p', agentType: 'claude' })
store.getState().applyActivityClearedAt({ 'tab-a:1': 5_000 })
store.getState().unacknowledgeAgents(['tab-a:1'])
expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBe(5_000)
expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeGreaterThan(0)
}
it('row dismissal keeps the cutoff and manual-unread stamp for a still-live pane', () => {
const store = createTestStore()
seedLiveWithClearState(store)
store.getState().dropAgentStatus('tab-a:1')
expect(store.getState().agentStatusByPaneKey['tab-a:1']).toBeUndefined()
// The pane may republish its full stateHistory; without the cutoff every
// cleared event would flood back as unread (the Clear-completed undo bug).
expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBe(5_000)
expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeGreaterThan(0)
})
it('paneRemoved drop clears the cutoff and manual-unread stamp with the pane', () => {
const store = createTestStore()
seedLiveWithClearState(store)
store.getState().dropAgentStatus('tab-a:1', { paneRemoved: true })
expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBeUndefined()
expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeUndefined()
})
})
describe('dropAgentStatusByTabPrefix preserveActivityClearedState', () => {
afterEach(() => {
vi.useRealTimers()
})
it('keeps cutoffs and manual-unread stamps for a mirrored-tab retraction sweep', () => {
vi.useFakeTimers()
const store = createTestStore()
store.getState().setAgentStatus('tab-a:1', { state: 'done', prompt: 'p', agentType: 'claude' })
store.getState().applyActivityClearedAt({ 'tab-a:1': 5_000 })
store.getState().unacknowledgeAgents(['tab-a:1'])
store.getState().dropAgentStatusByTabPrefix('tab-a', { preserveActivityClearedState: true })
expect(store.getState().agentStatusByPaneKey['tab-a:1']).toBeUndefined()
// Loss of contact is not pane death: the host republishes the same panes on reconnect,
// and the preserved cutoff keeps cleared activity from replaying.
expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBe(5_000)
expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeGreaterThan(0)
store.getState().dropAgentStatusByTabPrefix('tab-a')
expect(store.getState().activityClearedAtByPaneKey['tab-a:1']).toBeUndefined()
expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-a:1']).toBeUndefined()
})
})
describe('sanitizeActivityClearedAtByPaneKey hydration TTL', () => {
it('keeps cutoffs past the 7-day ack TTL so they outlive the persisted entries they guard', () => {
const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000
const record = { 'tab-a:1': eightDaysAgo }
// Main prunes persisted entries at 7d from receivedAt; a same-aged cutoff must survive
// hydration or the entry it shadows replays as unread on restart.
expect(sanitizeAcknowledgedAgentsByPaneKey(record)).toEqual({})
expect(sanitizeActivityClearedAtByPaneKey(record)).toEqual(record)
const fifteenDaysAgo = Date.now() - 15 * 24 * 60 * 60 * 1000
expect(sanitizeActivityClearedAtByPaneKey({ 'tab-a:1': fifteenDaysAgo })).toEqual({})
})
})
@@ -42,8 +42,18 @@ export type DropHibernatedAgentPaneOptions = {
retainedCompletionEvidence?: readonly RetainedAgentEntry[]
}
export type DropAgentStatusOptions = {
/** The pane itself is gone (pane close, stale-row teardown). Row-only dismissals leave the
* cleared-at cutoff and manual-unread stamp in place so a still-live pane's next hook event
* cannot resurrect activity the user already cleared. */
paneRemoved?: boolean
}
export type DropAgentStatusByTabPrefixOptions = {
worktreeId?: string
/** Keep cleared-at cutoffs and manual-unread stamps: a mirrored-tab retraction is loss of
* contact, not pane death, and the host republishes the same panes on reconnect. */
preserveActivityClearedState?: boolean
}
export type AgentLaunchConfigRegistrationMetadata = {
@@ -1,6 +1,7 @@
import type {
RetainedAgentEntry,
DropAgentStatusByTabPrefixOptions,
DropAgentStatusOptions,
DropHibernatedAgentPaneOptions
} from './agent-status-contract'
import type { AgentStatusSlice } from './agent-status-slice-contract'
@@ -33,7 +34,7 @@ export function createAgentStatusDropActions(
> {
const { set, freshness } = runtime
return {
dropAgentStatus: (paneKey) => {
dropAgentStatus: (paneKey, opts?: DropAgentStatusOptions) => {
let liveExisted = false
set((s) => {
const hasLive = paneKey in s.agentStatusByPaneKey
@@ -44,8 +45,14 @@ 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)
// Row dismissal keeps cutoff/manual-unread: the pane may still be live, and its next
// hook event would replay every cleared stateHistory event as unread without them.
const nextClearedAt = opts?.paneRemoved
? removeAcknowledgement(s.activityClearedAtByPaneKey, paneKey)
: s.activityClearedAtByPaneKey
const nextManualUnread = opts?.paneRemoved
? removeAcknowledgement(s.manuallyUnreadTurnsByPaneKey, paneKey)
: s.manuallyUnreadTurnsByPaneKey
const hasLaunchConfig = paneKey in s.agentLaunchConfigByPaneKey
const nextLaunchConfigs = hasLaunchConfig
? { ...s.agentLaunchConfigByPaneKey }
@@ -106,16 +106,12 @@ export function buildAgentStatusTabPrefixDropPatch(
s.recentlyRetiredAgentStatusPaneKeys,
retiredAliasPaneKeys
)
const nextClearedAt = removePaneKeyRecords(
s.activityClearedAtByPaneKey,
prefix,
completedOrphanKeySet
)
const nextManualUnread = removePaneKeyRecords(
s.manuallyUnreadTurnsByPaneKey,
prefix,
completedOrphanKeySet
)
const nextClearedAt = opts?.preserveActivityClearedState
? s.activityClearedAtByPaneKey
: removePaneKeyRecords(s.activityClearedAtByPaneKey, prefix, completedOrphanKeySet)
const nextManualUnread = opts?.preserveActivityClearedState
? s.manuallyUnreadTurnsByPaneKey
: removePaneKeyRecords(s.manuallyUnreadTurnsByPaneKey, prefix, completedOrphanKeySet)
if (
liveKeys.length === 0 &&
@@ -14,6 +14,7 @@ import type {
AgentProviderSessionMetadata,
DropAgentStatusByTabPrefixOptions,
DropAgentStatusByWorktreeOptions,
DropAgentStatusOptions,
DropHibernatedAgentPaneOptions,
RetainedAgentEntry,
AllAgentSessionCaptureMode
@@ -133,7 +134,7 @@ export type AgentStatusSlice = {
clearTransientAgentStatuses: (connectionId: string, clearedAt: number) => void
/** Remove a single entry AND suppress re-retention on its next disappearance (user-initiated teardown: X button, pane close). */
dropAgentStatus: (paneKey: string) => void
dropAgentStatus: (paneKey: string, opts?: DropAgentStatusOptions) => void
/** Remove all entries under a tab AND suppress re-retention for each (tab close — no rows may reappear). */
dropAgentStatusByTabPrefix: (
@@ -58,7 +58,8 @@ export function sweepRetiredTerminalTabState(
export function buildRetiredTerminalTabStateSweepPatch(
state: RetiredTerminalTabSweepState,
tabIds: readonly string[],
worktreeId?: string | null
worktreeId?: string | null,
opts?: { preserveActivityClearedState?: boolean }
): Partial<RetiredTerminalTabSweepState> | null {
if (tabIds.length === 0) {
return null
@@ -73,7 +74,10 @@ export function buildRetiredTerminalTabStateSweepPatch(
swept,
tabId,
retireAgentPaneAuthorityAliasesByOwnerTab(tabId),
worktreeId ? { worktreeId } : undefined
{
...(worktreeId ? { worktreeId } : {}),
...(opts?.preserveActivityClearedState ? { preserveActivityClearedState: true } : {})
}
)
const foreground = buildPaneForegroundAgentTabPrefixClearPatch(
swept.paneForegroundAgentByPaneKey,
@@ -51,7 +51,7 @@ import {
normalizeHydratedVisibleWorkspaceHostIds,
preserveStringArrayIdentity,
sanitizeAcknowledgedAgentsByPaneKey,
sanitizePaneKeyTimestampRecord,
sanitizeActivityClearedAtByPaneKey,
sanitizeHydratedActiveView,
sanitizePersistedRepoIds,
sanitizeShowDotfilesByWorktree,
@@ -267,7 +267,9 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par
acknowledgedAgentsByPaneKey: sanitizeAcknowledgedAgentsByPaneKey(
ui.acknowledgedAgentsByPaneKey
),
activityClearedAtByPaneKey: sanitizePaneKeyTimestampRecord(ui.activityClearedAtByPaneKey),
activityClearedAtByPaneKey: sanitizeActivityClearedAtByPaneKey(
ui.activityClearedAtByPaneKey
),
workspaceCleanupDismissals: sanitizeWorkspaceCleanupDismissals(
ui.workspaceCleanup?.dismissals
),
@@ -282,7 +284,7 @@ export function createUiHydrationActions(set: UISliceSet, _get: UISliceGet): Par
// Why: restore only on startup; on 'sync' broadcasts it would clobber the window's current per-window view.
activeView:
source === 'startup'
? sanitizeHydratedActiveView(ui.activeView, s.settings?.experimentalActivity === true)
? sanitizeHydratedActiveView(ui.activeView, s.settings)
: s.activeView,
persistedUIReady: true
}
@@ -15,6 +15,8 @@ import {
normalizeExecutionHostScope
} from '../../../../../shared/execution-host'
import { persistedUIValuesEqual } from '../../../../../shared/persisted-ui-equality'
// Pure predicate over GlobalSettings; safe to share with the store layer.
import { shouldShowAgentDashboardSidebarButton } from '@/components/sidebar/agent-dashboard-sidebar-visibility'
import { DEFAULT_STATUS_BAR_ITEMS } from '../../../../../shared/constants'
import type { UISlice } from './ui-slice-contract'
@@ -104,11 +106,14 @@ export function sanitizePersistedSidebarWidth(
return Math.min(maxWidth, Math.max(MIN_SIDEBAR_WIDTH, width))
}
export function sanitizePaneKeyTimestampRecord(value: unknown): Record<string, number> {
export function sanitizePaneKeyTimestampRecord(
value: unknown,
maxAgeMs: number = HYDRATE_MAX_AGE_MS
): Record<string, number> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
const cutoff = Date.now() - HYDRATE_MAX_AGE_MS
const cutoff = Date.now() - maxAgeMs
const out: Record<string, number> = {}
for (const [key, ackAt] of Object.entries(value as Record<string, unknown>)) {
if (!isSafePersistedRecordKey(key)) {
@@ -124,6 +129,14 @@ export function sanitizePaneKeyTimestampRecord(value: unknown): Record<string, n
export const sanitizeAcknowledgedAgentsByPaneKey = sanitizePaneKeyTimestampRecord
/** Cleared-at cutoffs must outlive any persisted status entry they guard: main prunes entries
* at HYDRATE_MAX_AGE_MS from receivedAt, and cutoff values trail receipt time, so pruning them
* on the same clock can resurrect a cleared entry. Double the TTL keeps the guard alive past
* every entry it can still shadow. */
export function sanitizeActivityClearedAtByPaneKey(value: unknown): Record<string, number> {
return sanitizePaneKeyTimestampRecord(value, 2 * HYDRATE_MAX_AGE_MS)
}
export function sanitizeWorkspaceCleanupDismissals(
value: unknown
): Record<string, WorkspaceCleanupDismissal> {
@@ -162,14 +175,14 @@ export function sanitizeWorkspaceCleanupDismissals(
export function sanitizeHydratedActiveView(
value: PersistedUIState['activeView'],
experimentalActivityEnabled: boolean
settings: Parameters<typeof shouldShowAgentDashboardSidebarButton>[0]
): TopLevelView {
// Why: older data (pre-activeView) or a view a different build doesn't have falls back to terminal rather than rendering nothing.
if (!isTopLevelView(value)) {
return 'terminal'
}
// Why: activity is hidden when its setting is off, so gate only it (mobile/automations stay functional when hidden).
if (value === 'activity' && !experimentalActivityEnabled) {
// Why: activity is hidden when its entry points are, so gate only it (mobile/automations stay functional when hidden).
if (value === 'activity' && !shouldShowAgentDashboardSidebarButton(settings)) {
return 'terminal'
}
return value
@@ -1,10 +1,14 @@
import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract'
import { rewindHistoryIndexPastView } from '../worktree-nav-history'
// Pure predicate over GlobalSettings; safe to share with the store layer.
import { shouldShowAgentDashboardSidebarButton } from '@/components/sidebar/agent-dashboard-sidebar-visibility'
export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial<UISlice> {
return {
openActivityPage: () => {
if (get().settings?.experimentalActivity !== true) {
// Agents graduated from experimentalActivity; gate on the same visibility
// rule as the sidebar entry points so the view is reachable iff shown.
if (!shouldShowAgentDashboardSidebarButton(get().settings)) {
return
}
set((state) => ({