mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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'
|
||||
|
||||
+33
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+112
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 を試す"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "에이전트 사용해 보기"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "试用智能体"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user