Graduate Agents sidebar and separate dashboard experiment

The Agents tab now has its own `showAgentsSidebar` setting (defaults on) independent from the dashboard popout experiment. Activity unread counting is simplified to count all events uniformly without mode-specific filtering. Dashboard visibility is now controlled solely by `experimentalAgentDashboardPopout`, with its own UI in the Experimental settings pane. Migration path updated: only `experimentalActivity=true` graduates to the sidebar; the dashboard experiment remains separate.
This commit is contained in:
Jinjing
2026-09-02 10:10:54 -07:00
parent 80624bd4b7
commit a9a9063899
48 changed files with 561 additions and 510 deletions
@@ -7,7 +7,7 @@ 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.
// Simulates a profile created before the dedicated Experimental switch was persisted.
function normalizeLegacyProfile(overrides: Partial<GlobalSettings>): PersistedState['settings'] {
const defaults = getDefaultPersistedState(homedir())
const settings: Partial<GlobalSettings> = { ...defaults.settings }
@@ -22,8 +22,8 @@ function normalizeLegacyProfile(overrides: Partial<GlobalSettings>): PersistedSt
return normalizeLoadedGlobalSettings(parsed, terminal, profile)
}
describe('showAgentsSidebar graduation migration', () => {
it('keeps the sidebar for Agents-view opt-ins regardless of the popout experiment', () => {
describe('showAgentsSidebar experimental-setting migration', () => {
it('keeps the sidebar for Agents-view opt-ins regardless of the dashboard experiment', () => {
const normalized = normalizeLegacyProfile({
experimentalActivity: true,
experimentalAgentDashboardPopout: false
@@ -32,18 +32,15 @@ describe('showAgentsSidebar graduation migration', () => {
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true)
})
it('graduates either legacy opt-in to the sidebar', () => {
it('carries the legacy Agents-view opt-in into the sidebar', () => {
expect(normalizeLegacyProfile({ experimentalActivity: true }).showAgentsSidebar).toBe(true)
expect(
normalizeLegacyProfile({ experimentalAgentDashboardPopout: true }).showAgentsSidebar
).toBe(true)
})
it('uses migration copy for legacy Agent Dashboard opt-ins', () => {
it('does not show Agents migration copy for a dashboard-only opt-in', () => {
expect(
normalizeLegacyProfile({ experimentalAgentDashboardPopout: true })
.agentsSidebarMigratedFromExperimental
).toBe(true)
).toBe(false)
})
it('defaults profiles with no legacy signal to the sidebar', () => {
@@ -52,16 +49,10 @@ describe('showAgentsSidebar graduation migration', () => {
expect(normalized.agentsSidebarMigratedFromExperimental).toBe(false)
})
it('keeps an explicit popout opt-out hidden when the Agents view was never used', () => {
it('does not treat a dashboard opt-out as an Agents-tab opt-out', () => {
expect(
normalizeLegacyProfile({ experimentalAgentDashboardPopout: false }).showAgentsSidebar
).toBe(false)
expect(
normalizeLegacyProfile({
experimentalActivity: false,
experimentalAgentDashboardPopout: false
}).showAgentsSidebar
).toBe(false)
).toBe(true)
})
it('ignores a pre-stamp forced-default experimentalActivity true (not an opt-in)', () => {
@@ -86,22 +86,16 @@ export function normalizeLoadedGlobalSettings(
...migratedTerminalTuiScrollSensitivity.settings,
experimentalActivity: migratedExperimentalActivity,
experimentalActivityDefaultedOffForAllUsers: true,
// 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.
// Why migrated, not raw: a pre-stamp profile's stored true is the old forced default,
// not an opt-in (see prepareLoadedProfileSettings).
// Keep the experimental Agents tab's rollout default for older profiles while
// preserving any choice made through its dedicated Experimental setting.
showAgentsSidebar: resolveAgentsSidebarVisible({
showAgentsSidebar: parsed.settings?.showAgentsSidebar,
experimentalActivity: migratedExperimentalActivity,
experimentalAgentDashboardPopout: parsed.settings?.experimentalAgentDashboardPopout
showAgentsSidebar: parsed.settings?.showAgentsSidebar
}),
// Preserve the legacy opt-in before the experimental setting is normalized away. This
// drives the migration-specific introduction copy without changing runtime behavior.
agentsSidebarMigratedFromExperimental:
parsed.settings?.agentsSidebarMigratedFromExperimental === true ||
migratedExperimentalActivity ||
parsed.settings?.experimentalAgentDashboardPopout === true,
migratedExperimentalActivity,
// Why: compact worktree cards graduated from Experimental; preserve the old opt-in for rollout-era profiles.
compactWorktreeCards: loadedCompactWorktreeCards,
experimentalCompactWorktreeCards: undefined,
@@ -35,7 +35,6 @@ import {
import { mapWithConcurrency } from '../../../shared/map-with-concurrency'
import type { OnboardingState } from '../../../shared/onboarding-state-types'
import { restoreLocalStructuredSessionTabsOnce } from '../runtime/local-structured-session-tabs-sync'
import { seedDevActivityFixture } from '../components/activity/dev-activity-fixture'
async function listRuntimeSessionHostIdsForStartup(): Promise<ExecutionHostId[]> {
try {
@@ -276,6 +275,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
import.meta.env.DEV &&
String(import.meta.env.VITE_ACTIVITY_DEV_FIXTURE).toLowerCase() === 'true'
) {
const { seedDevActivityFixture } =
await import('../components/activity/dev-activity-fixture')
seedDevActivityFixture()
}
logRendererStartupDiagnostic('startup-hydration-done', {
@@ -81,7 +81,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
: null
const selectedTabId = selectedThread?.tab.id ?? null
const selectedWorktreeAvailable = selectedThread
? hasActivityThreadWorkspace(selectedThread)
? hasActivityThreadWorkspace(selectedThread, storeData)
: false
// Why: repo-less terminal buckets can produce Activity rows, but the workspace Terminal tree only portals real worktrees.
const selectedHasLiveTab =
@@ -95,7 +95,7 @@ export default function ActivityPrototypePage(): React.JSX.Element {
: null
const displayedTabId = displayedThread?.tab.id ?? null
const displayedWorktreeAvailable = displayedThread
? hasActivityThreadWorkspace(displayedThread)
? hasActivityThreadWorkspace(displayedThread, storeData)
: false
const displayedHasLiveTab =
displayedThread && displayedTabId && displayedWorktreeAvailable
@@ -260,7 +260,15 @@ export default function ActivityPrototypePage(): React.JSX.Element {
setSelectedPaneKey
})
const canJumpToWorkspace = hasActivityThreadWorkspace
const canJumpToWorkspace = useCallback(
(thread: Parameters<typeof hasActivityThreadWorkspace>[0]) =>
hasActivityThreadWorkspace(thread, {
worktreesByRepo: storeData.worktreesByRepo,
detectedWorktreesByRepo: storeData.detectedWorktreesByRepo,
folderWorkspaces: storeData.folderWorkspaces
}),
[storeData.worktreesByRepo, storeData.detectedWorktreesByRepo, storeData.folderWorkspaces]
)
useEffect(() => {
if (
@@ -8,7 +8,7 @@ import { useActivityUnreadCount } from './useActivityUnreadCount'
import { translate } from '@/i18n/i18n'
export function ActivityTitlebarControls(): React.JSX.Element {
const unreadCount = useActivityUnreadCount(true, 'agent-events')
const unreadCount = useActivityUnreadCount()
const closeActivityPage = useAppStore((s) => s.closeActivityPage)
return (
@@ -51,6 +51,19 @@ describe('activity thread host routing', () => {
getKnownWorktreeById.mockReturnValue(thread.worktree)
mocks.getState.mockReturnValue({
getKnownWorktreeById,
worktreesByRepo: { [thread.worktree.repoId]: [thread.worktree] },
detectedWorktreesByRepo: {},
folderWorkspaces: [],
showSleepingWorkspaces: true,
filterRepoIds: [],
hideDefaultBranchWorkspace: false,
hideAutomationGeneratedWorkspaces: false,
hideCliCreatedWorkspaces: false,
hideDetachedHeadWorkspaces: false,
hideWorkspacesFromOtherDevices: false,
alwaysShowDefaultBranchWorkspace: true,
visibleWorkspaceHostIds: null,
workspaceHostScope: 'all',
tabsByWorktree: { [thread.worktree.id]: [thread.tab] },
activeRepoId: thread.worktree.repoId,
activeWorktreeId: thread.worktree.id,
@@ -3,16 +3,25 @@ import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation'
import { useAppStore } from '@/store'
import { getWorktreeExecutionHostId } from '../../../../shared/execution-host'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta'
import type { AppState } from '@/store/types'
import type { AgentPaneThread } from './activity-thread-types'
function getActivityThreadExecutionHostId(thread: AgentPaneThread) {
return getWorktreeExecutionHostId(thread.worktree, thread.repo ?? undefined)
}
export function hasActivityThreadWorkspace(thread: AgentPaneThread): boolean {
const state = useAppStore.getState()
type ActivityThreadWorkspaceCatalog = Pick<
AppState,
'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces'
>
export function hasActivityThreadWorkspace(
thread: AgentPaneThread,
catalog: ActivityThreadWorkspaceCatalog = useAppStore.getState()
): boolean {
return Boolean(
state.getKnownWorktreeById(thread.worktree.id, getActivityThreadExecutionHostId(thread))
findKnownWorktreeById(catalog, thread.worktree.id, getActivityThreadExecutionHostId(thread))
)
}
@@ -31,6 +31,7 @@ export type AgentPaneThreadsStoreData = Pick<
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
| 'repos'
| 'worktreesByRepo'
| 'folderWorkspaces'
| 'detectedWorktreesByRepo'
| 'getKnownWorktreeById'
@@ -79,6 +80,7 @@ export function useAgentPaneThreads(args: {
tabsByWorktree: s.tabsByWorktree,
unifiedTabsByWorktree: s.unifiedTabsByWorktree,
repos: s.repos,
worktreesByRepo: s.worktreesByRepo,
folderWorkspaces: s.folderWorkspaces,
detectedWorktreesByRepo: s.detectedWorktreesByRepo,
getKnownWorktreeById: s.getKnownWorktreeById,
@@ -27,23 +27,21 @@ function makeSource(entry: AgentStatusEntry, ackAt = 0) {
}
describe('countActivityUnread session-boundary rows (STA-3386)', () => {
it('does not count a session-boundary done as unread in either mode', () => {
it('does not count a session-boundary done as unread', () => {
const source = makeSource(makeEntry({ sessionBoundary: true }))
expect(countActivityUnread(source, 'sidebar-badge')).toBe(0)
expect(countActivityUnread(source, 'agent-events')).toBe(0)
expect(countActivityUnread(source)).toBe(0)
})
it('keeps counting a real completion displaced into history by a session boundary', () => {
// Why: agent finished (unacknowledged), then the user resumed the session — the
// boundary row replaces the live done but the finish must stay unread in both badges.
// boundary row replaces the live done but the finish must stay unread.
const source = makeSource(
makeEntry({
sessionBoundary: true,
stateHistory: [{ state: 'done', prompt: 'fix bug', startedAt: 1_000 }]
})
)
expect(countActivityUnread(source, 'sidebar-badge')).toBe(1)
expect(countActivityUnread(source, 'agent-events')).toBe(1)
expect(countActivityUnread(source)).toBe(1)
})
it('stops counting the displaced completion once acknowledged', () => {
@@ -54,47 +52,12 @@ describe('countActivityUnread session-boundary rows (STA-3386)', () => {
}),
1_500
)
expect(countActivityUnread(source, 'sidebar-badge')).toBe(0)
expect(countActivityUnread(source, 'agent-events')).toBe(0)
expect(countActivityUnread(source)).toBe(0)
})
it('still counts an ordinary unacknowledged done in sidebar-badge mode', () => {
it('still counts an ordinary unacknowledged done', () => {
const source = makeSource(makeEntry({}))
expect(countActivityUnread(source, 'sidebar-badge')).toBe(1)
})
})
describe('countActivityUnread child-agent filtering (sidebar-badge)', () => {
const PARENT_PANE = 'tab-2:22222222-2222-4222-8222-222222222222'
function makeChildSource(parentPaneKey: string) {
const parent = makeEntry({ state: 'working', paneKey: PARENT_PANE })
const child = makeEntry({
orchestration: { taskId: 'task-1', dispatchId: 'dispatch-1', parentPaneKey }
})
return {
acknowledgedAgentsByPaneKey: {},
agentStatusByPaneKey: { [PARENT_PANE]: parent, [PANE]: child },
migrationUnsupportedByPtyId: {},
retainedAgentsByPaneKey: {}
}
}
it('excludes a child of a listed parent so the badge matches what Mark all read clears', () => {
const source = makeChildSource(PARENT_PANE)
expect(countActivityUnread(source, 'sidebar-badge')).toBe(0)
// The full-page event badge keeps counting every event.
expect(countActivityUnread(source, 'agent-events')).toBe(1)
})
it('counts the child when child agents are shown', () => {
const source = { ...makeChildSource(PARENT_PANE), showChildAgents: true }
expect(countActivityUnread(source, 'sidebar-badge')).toBe(1)
})
it('counts an orphaned child whose parent pane is gone (promoted to top level)', () => {
const source = makeChildSource('tab-9:99999999-9999-4999-8999-999999999999')
expect(countActivityUnread(source, 'sidebar-badge')).toBe(1)
expect(countActivityUnread(source)).toBe(1)
})
})
@@ -109,8 +72,7 @@ describe('countActivityUnread with Clear completed cutoffs', () => {
activityClearedAtByPaneKey: { [PANE]: 2_000 }
}
// Both the history event (1_000) and the live done (2_000) are at or before the cutoff.
expect(countActivityUnread(source, 'agent-events')).toBe(0)
expect(countActivityUnread(source, 'sidebar-badge')).toBe(0)
expect(countActivityUnread(source)).toBe(0)
})
it('keeps counting turns newer than the cutoff', () => {
@@ -123,7 +85,6 @@ describe('countActivityUnread with Clear completed cutoffs', () => {
),
activityClearedAtByPaneKey: { [PANE]: 2_000 }
}
expect(countActivityUnread(source, 'agent-events')).toBe(1)
expect(countActivityUnread(source, 'sidebar-badge')).toBe(1)
expect(countActivityUnread(source)).toBe(1)
})
})
@@ -2,18 +2,9 @@ import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { entryWithRuntimeOrchestration } from '../sidebar/worktree-agent-row-orchestration'
import { useAppStore } from '@/store'
import type { AppState } from '@/store/types'
import type {
AgentStatusEntry,
AgentStatusOrchestrationContext,
AgentStatusState
} from '../../../../shared/agent-status-types'
import {
collectChildAgentPaneKeys,
type ChildAgentClassifiableThread
} from './activity-thread-child-agent'
import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types'
type ActivityUnreadCountSource = Pick<
AppState,
@@ -24,135 +15,49 @@ type ActivityUnreadCountSource = Pick<
> & {
/** Per-pane "Clear completed" cutoffs; hidden events must not count as unread. */
activityClearedAtByPaneKey?: Record<string, number>
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
/** Mirrors the Agents list's child filter: children of listed parents are
* excluded from sidebar-badge counts unless the user shows child agents. */
showChildAgents?: boolean
}
type ActivityUnreadCountMode = 'agent-events' | 'sidebar-badge'
const EMPTY_MIGRATION_UNSUPPORTED: AppState['migrationUnsupportedByPtyId'] = {}
const EMPTY_RETAINED_AGENTS: AppState['retainedAgentsByPaneKey'] = {}
const EMPTY_ACKNOWLEDGED_AGENTS: AppState['acknowledgedAgentsByPaneKey'] = {}
const EMPTY_ACTIVITY_CLEARED_AT: Record<string, number> = {}
const EMPTY_RUNTIME_ORCHESTRATION: Record<string, AgentStatusOrchestrationContext> = {}
const DISABLED_ACTIVITY_UNREAD_INPUTS = {
sortEpoch: 0,
migrationUnsupportedByPtyId: EMPTY_MIGRATION_UNSUPPORTED,
retainedAgentsByPaneKey: EMPTY_RETAINED_AGENTS,
acknowledgedAgentsByPaneKey: EMPTY_ACKNOWLEDGED_AGENTS,
activityClearedAtByPaneKey: EMPTY_ACTIVITY_CLEARED_AT,
runtimeAgentOrchestrationByPaneKey: EMPTY_RUNTIME_ORCHESTRATION,
showChildAgents: false
}
function isUnreadAgentState(state: AgentStatusState): boolean {
return state === 'done' || state === 'blocked' || state === 'waiting'
}
/** Minimal rows stand in for threads so the always-mounted badge never runs the
* full thread-building pipeline. */
function toChildClassifierRow(entry: AgentStatusEntry): ChildAgentClassifiableThread {
return {
paneKey: entry.paneKey,
currentAgentEntry: entry
}
}
function collectHiddenChildPaneKeys(source: ActivityUnreadCountSource): ReadonlySet<string> {
const rows: ChildAgentClassifiableThread[] = []
const seenPaneKeys = new Set<string>()
const push = (entry: AgentStatusEntry | null): void => {
if (!entry || seenPaneKeys.has(entry.paneKey)) {
return
}
seenPaneKeys.add(entry.paneKey)
rows.push(
toChildClassifierRow(
entryWithRuntimeOrchestration(entry, source.runtimeAgentOrchestrationByPaneKey)
)
)
}
for (const entry of Object.values(source.agentStatusByPaneKey)) {
push(entry)
}
for (const retained of Object.values(source.retainedAgentsByPaneKey)) {
push(retained.entry)
}
for (const unsupported of Object.values(source.migrationUnsupportedByPtyId)) {
push(migrationUnsupportedToAgentStatusEntry(unsupported))
}
return collectChildAgentPaneKeys(rows)
}
export function countActivityUnread(
source: ActivityUnreadCountSource,
mode: ActivityUnreadCountMode
): number {
/** Counts unread done/blocked/waiting events for the Activity page titlebar badge. */
export function countActivityUnread(source: ActivityUnreadCountSource): number {
let count = 0
// Why no worktree.isUnread here: the Agents tab lists only agent threads, so a
// worktree unread would light a badge with no row to read and no way to clear it.
// Why the child exclusion: the badge counts exactly what Mark all read can clear;
// a child hidden by the default filter must not keep the badge lit forever.
const hiddenChildPaneKeys =
mode === 'sidebar-badge' && source.showChildAgents !== true
? collectHiddenChildPaneKeys(source)
: null
// Why no worktree.isUnread here: Activity lists only agent threads, so a worktree
// unread would light a badge with no row to read and no way to clear it.
const countEntry = (entry: AgentStatusEntry, ackAt: number): void => {
// Why: "Clear completed" hides events at or before the pane's cutoff from the feed,
// so a hidden event must not keep the badge lit; treat the cutoff like an ack floor.
const clearedAt = source.activityClearedAtByPaneKey?.[entry.paneKey] ?? 0
const mutedAt = Math.max(ackAt, clearedAt)
if (mode === 'agent-events') {
// Why: Activity feed surfaces historical done/blocked/waiting events
// from stateHistory, so the titlebar badge must mirror that event count.
for (const history of entry.stateHistory) {
if (isUnreadAgentState(history.state) && mutedAt < history.startedAt) {
count += 1
}
// Why: Activity feed surfaces historical done/blocked/waiting events
// from stateHistory, so the titlebar badge must mirror that event count.
for (const history of entry.stateHistory) {
if (isUnreadAgentState(history.state) && mutedAt < history.startedAt) {
count += 1
}
}
// Why: a session-boundary done is an idle connect (STA-3386), not an event to read.
// History never contains a boundary, but it DOES keep the real completion a boundary
// displaced (the slice pushes it on done→done), so sidebar-badge mode — which skips the
// history loop above — must still count that displaced completion or the badge silently
// drops an unacknowledged finish the moment its session is resumed.
if (
isUnreadAgentState(entry.state) &&
entry.sessionBoundary !== true &&
mutedAt < entry.stateStartedAt
) {
count += 1
} else if (mode === 'sidebar-badge' && entry.state === 'done' && entry.sessionBoundary) {
const displaced = entry.stateHistory.at(-1)
if (displaced && isUnreadAgentState(displaced.state) && mutedAt < displaced.startedAt) {
count += 1
}
}
}
for (const [paneKey, entry] of Object.entries(source.agentStatusByPaneKey)) {
if (hiddenChildPaneKeys?.has(paneKey)) {
continue
}
countEntry(entry, source.acknowledgedAgentsByPaneKey[paneKey] ?? 0)
}
for (const [paneKey, retained] of Object.entries(source.retainedAgentsByPaneKey)) {
if (mode === 'sidebar-badge' && retained.entry.state !== 'done') {
continue
}
if (hiddenChildPaneKeys?.has(paneKey)) {
continue
}
countEntry(retained.entry, source.acknowledgedAgentsByPaneKey[paneKey] ?? 0)
}
for (const unsupported of Object.values(source.migrationUnsupportedByPtyId)) {
const entry = migrationUnsupportedToAgentStatusEntry(unsupported)
if (entry && !hiddenChildPaneKeys?.has(entry.paneKey)) {
if (entry) {
countEntry(entry, source.acknowledgedAgentsByPaneKey[entry.paneKey] ?? 0)
}
}
@@ -160,61 +65,40 @@ export function countActivityUnread(
return count
}
export function useActivityUnreadCount(enabled: boolean, mode: ActivityUnreadCountMode): number {
export function useActivityUnreadCount(): number {
const {
sortEpoch,
migrationUnsupportedByPtyId,
retainedAgentsByPaneKey,
acknowledgedAgentsByPaneKey,
activityClearedAtByPaneKey,
runtimeAgentOrchestrationByPaneKey,
showChildAgents
activityClearedAtByPaneKey
} = useAppStore(
useShallow((state) => {
if (!enabled) {
return DISABLED_ACTIVITY_UNREAD_INPUTS
}
return {
// Why: live status prompt/tool updates churn agentStatusByPaneKey but
// cannot change unread count unless a sort-relevant state transition
// or removal occurred. sortEpoch is the cheap invalidation signal.
sortEpoch: state.sortEpoch,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey,
activityClearedAtByPaneKey: state.activityClearedAtByPaneKey,
runtimeAgentOrchestrationByPaneKey: state.runtimeAgentOrchestrationByPaneKey,
showChildAgents: state.agentsShowChildAgents
}
})
useShallow((state) => ({
// Why: live status prompt/tool updates churn agentStatusByPaneKey but
// cannot change unread count unless a sort-relevant state transition
// or removal occurred. sortEpoch is the cheap invalidation signal.
sortEpoch: state.sortEpoch,
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
acknowledgedAgentsByPaneKey: state.acknowledgedAgentsByPaneKey,
activityClearedAtByPaneKey: state.activityClearedAtByPaneKey
}))
)
return useMemo(() => {
if (!enabled) {
return 0
}
void sortEpoch
return countActivityUnread(
{
agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey,
migrationUnsupportedByPtyId,
retainedAgentsByPaneKey,
acknowledgedAgentsByPaneKey,
activityClearedAtByPaneKey,
runtimeAgentOrchestrationByPaneKey,
showChildAgents
},
mode
)
return countActivityUnread({
agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey,
migrationUnsupportedByPtyId,
retainedAgentsByPaneKey,
acknowledgedAgentsByPaneKey,
activityClearedAtByPaneKey
})
}, [
acknowledgedAgentsByPaneKey,
activityClearedAtByPaneKey,
enabled,
migrationUnsupportedByPtyId,
mode,
retainedAgentsByPaneKey,
runtimeAgentOrchestrationByPaneKey,
showChildAgents,
sortEpoch
])
}
@@ -304,7 +304,6 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
/>
<DashboardAgentRowMessage
expanded={expanded}
isWorking={isWorking}
isInterrupted={isInterrupted}
lastAssistantMessage={lastAssistantMessage}
/>
@@ -4,21 +4,18 @@ import { translate } from '@/i18n/i18n'
type DashboardAgentRowMessageProps = {
expanded: boolean
isWorking: boolean
isInterrupted: boolean
lastAssistantMessage: string
}
export function DashboardAgentRowMessage({
expanded,
isWorking,
isInterrupted,
lastAssistantMessage
}: DashboardAgentRowMessageProps): React.JSX.Element | null {
const visibleMessage = isWorking ? '' : lastAssistantMessage
// Why: message slot is always reserved in collapsed view so the row height
// stays fixed as assistant text arrives or clears.
if (!isInterrupted && !visibleMessage) {
if (!isInterrupted && !lastAssistantMessage) {
return expanded ? null : (
<div className="mt-0.5 pl-5 text-[10px] leading-snug text-muted-foreground/70"> </div>
)
@@ -40,9 +37,9 @@ export function DashboardAgentRowMessage({
)}
</span>
) : null}
{visibleMessage ? (
{lastAssistantMessage ? (
<CommentMarkdown
content={visibleMessage}
content={lastAssistantMessage}
// Why: animate between a clipped preview and natural height without
// measuring markdown content in JS.
className={cn(
@@ -52,7 +49,7 @@ export function DashboardAgentRowMessage({
!expanded &&
'truncate whitespace-nowrap [&_*]:inline [&_*]:!whitespace-nowrap [&_*]:!m-0 [&_*]:!p-0 [&_ul]:list-none [&_ol]:list-none [&_br]:hidden'
)}
title={!expanded ? visibleMessage : undefined}
title={!expanded ? lastAssistantMessage : undefined}
/>
) : null}
</div>
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { toast } from 'sonner'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
@@ -62,7 +62,7 @@ export function useAiVaultOriginalPaneActions(): {
return
}
if (!jumpToWorktreeFromSidebar(target.worktreeId)) {
if (!activateAndRevealWorktree(target.worktreeId)) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable',
@@ -80,7 +80,7 @@ export function useAiVaultOriginalPaneActions(): {
}, [])
const jumpToWorktree = useCallback((worktreeId: string): void => {
if (!jumpToWorktreeFromSidebar(worktreeId)) {
if (!activateAndRevealWorktree(worktreeId)) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.worktreeUnavailable',
@@ -0,0 +1,105 @@
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { translate } from '@/i18n/i18n'
import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSegmentedControl, SettingsSwitch } from './SettingsFormControls'
import { getExperimentalSearchEntry } from './experimental-search'
type AgentDashboardExperimentalSettingProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function AgentDashboardExperimentalSetting({
settings,
updateSettings
}: AgentDashboardExperimentalSettingProps): React.JSX.Element {
const enabled = settings.experimentalAgentDashboardPopout === true
const mode = settings.experimentalAgentDashboardMode ?? 'in-window'
return (
<SearchableSetting
title={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.title',
'Agent Dashboard'
)}
description={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.description',
'Kanban board for monitoring agents across worktrees, in-window or as a pop-out.'
)}
keywords={getExperimentalSearchEntry().agentDashboard.keywords}
className="space-y-3 py-2"
id="experimental-agent-dashboard"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.title',
'Agent Dashboard'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.copy',
'Adds an Agent Dashboard entry to the left sidebar. Monitor agents that need you, are working, or are done, with optional idle agents.'
)}
</p>
</div>
<SettingsSwitch
checked={enabled}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
'Toggle Agent Dashboard'
)}
onChange={() => updateSettings({ experimentalAgentDashboardPopout: !enabled })}
/>
</div>
{enabled ? (
<div className="ml-4 space-y-3 border-l border-border pl-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeLabel',
'Open as'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy',
'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.'
)}
</p>
</div>
<SettingsSegmentedControl
value={mode}
onChange={(next) => updateSettings({ experimentalAgentDashboardMode: next })}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeAriaLabel',
'Agent Dashboard open mode'
)}
size="sm"
options={[
{
value: 'in-window',
label: translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeInWindow',
'In-window'
)
},
{
value: 'popout',
label: translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modePopout',
'Pop-out'
)
}
]}
/>
</div>
</div>
) : null}
</SearchableSetting>
)
}
@@ -132,7 +132,7 @@ describe('ExperimentalPane', () => {
)
})
it('exposes the Agents sidebar visibility switch for discoverability', () => {
it('renders the Agents sidebar switch in Experimental without stale Appearance copy', () => {
const settings = getDefaultSettings('/tmp')
const markup = renderToStaticMarkup(
<ExperimentalPane settings={settings} updateSettings={vi.fn()} />
@@ -140,13 +140,45 @@ describe('ExperimentalPane', () => {
expect(settings.experimentalAgentDashboardPopout).toBeUndefined()
expect(markup).toContain('Show Agents Button')
expect(markup).toContain('Appearance')
expect(markup).toContain('Window &amp; Sidebar')
expect(markup).toContain('Controls whether the Agents tab appears in the left sidebar')
expect(markup).not.toContain('Window &amp; Sidebar')
expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain(
'Show Agents Button'
)
})
it('renders the agent dashboard as an off-by-default searchable experiment', () => {
const settings = getDefaultSettings('/tmp')
const markup = renderToStaticMarkup(
<ExperimentalPane settings={settings} updateSettings={vi.fn()} />
)
expect(settings.experimentalAgentDashboardPopout).toBeUndefined()
expect(markup).toContain('Agent Dashboard')
expect(markup).toContain('Monitor agents that need you, are working, or are done')
expect(getExperimentalPaneSearchEntries().map((entry) => entry.title)).toContain(
'Agent Dashboard'
)
})
it('enables the agent dashboard through its experimental switch', async () => {
const updateSettings = vi.fn()
const { root, container } = await renderExperimentalPane({ updateSettings })
const switchButton = container.querySelector<HTMLButtonElement>(
'#experimental-agent-dashboard button[role="switch"]'
)
if (!switchButton) {
throw new Error('Agent Dashboard switch was not rendered')
}
await act(async () => {
switchButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(updateSettings).toHaveBeenCalledWith({ experimentalAgentDashboardPopout: true })
root.unmount()
})
it('keeps idle-agent visibility out of global settings', () => {
const markup = renderToStaticMarkup(
<ExperimentalPane
@@ -9,6 +9,7 @@ import { HiddenExperimentalGroup } from './HiddenExperimentalGroup'
import { NumberField, SettingsSwitch } from './SettingsFormControls'
import { translate } from '@/i18n/i18n'
import { NativeChatExperimentalSetting } from './NativeChatExperimentalSetting'
import { AgentDashboardExperimentalSetting } from './AgentDashboardExperimentalSetting'
import { EphemeralVmsExperimentalSetting } from './EphemeralVmsExperimentalSetting'
import {
MAX_AGENT_HIBERNATION_IDLE_MS,
@@ -41,6 +42,9 @@ export function ExperimentalPane({
const showAgentsSidebar = matchesSettingsSearch(searchQuery, [
getExperimentalSearchEntry().agentsSidebar
])
const showAgentDashboard = matchesSettingsSearch(searchQuery, [
getExperimentalSearchEntry().agentDashboard
])
const showTerminalAttention = matchesSettingsSearch(searchQuery, [
getExperimentalSearchEntry().terminalAttention
])
@@ -89,6 +93,10 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showAgentDashboard ? (
<AgentDashboardExperimentalSetting settings={settings} updateSettings={updateSettings} />
) : null}
{showPet ? (
<SearchableSetting
title={translate('auto.components.settings.ExperimentalPane.dd6f0a1d45', 'Pet')}
@@ -49,6 +49,50 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog(
},
getNativeChatExperimentalSearchEntry(),
getAgentsSidebarEntry(),
{
title: translate(
'auto.components.settings.experimental.search.agentDashboard.title',
'Agent Dashboard'
),
description: translate(
'auto.components.settings.experimental.search.agentDashboard.description',
'Kanban board for monitoring agents across worktrees, in-window or as a pop-out.'
),
keywords: [
...translateSearchKeyword(
'auto.components.settings.experimental.search.0d24759f14',
'experimental'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.agent',
'agent'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.dashboard',
'dashboard'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.kanban',
'kanban'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.popout',
'pop-out'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.board',
'board'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.inWindow',
'in-window'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.worktrees',
'worktrees'
)
]
},
{
title: translate(
'auto.components.settings.experimental.search.9e4ddf776d',
@@ -160,6 +204,12 @@ export function getExperimentalSearchEntry() {
translate('auto.components.settings.experimental.search.nativeChat.title', 'Chat UI')
),
agentsSidebar: getAgentsSidebarEntry(),
agentDashboard: findEntry(
translate(
'auto.components.settings.experimental.search.agentDashboard.title',
'Agent Dashboard'
)
),
terminalAttention: findEntry(
translate('auto.components.settings.experimental.search.9e4ddf776d', 'Terminal attention')
),
@@ -265,8 +265,12 @@ describe('Sidebar', () => {
expect(agentsList.getAttribute('data-query')).toBe('')
})
it('closes the dashboard drawer when the Agents sidebar is disabled', async () => {
setSidebarState({ ...getDefaultSettings(tmpdir()), showAgentsSidebar: false })
it('closes the dashboard drawer when the dashboard experiment is disabled', async () => {
setSidebarState({
...getDefaultSettings(tmpdir()),
showAgentsSidebar: true,
experimentalAgentDashboardPopout: false
})
const setAgentDashboardDrawerOpen = vi.fn()
mocks.state = {
...mocks.state,
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useAppStore } from '@/store'
import { ActivityScopeFilterChips } from '@/components/activity/activity-scope-filter-controls'
@@ -76,7 +76,15 @@ export default function SidebarAgentsList({
setSelectedPaneKey
})
const canJumpToWorkspace = hasActivityThreadWorkspace
const canJumpToWorkspace = useCallback(
(thread: Parameters<typeof hasActivityThreadWorkspace>[0]) =>
hasActivityThreadWorkspace(thread, {
worktreesByRepo: storeData.worktreesByRepo,
detectedWorktreesByRepo: storeData.detectedWorktreesByRepo,
folderWorkspaces: storeData.folderWorkspaces
}),
[storeData.worktreesByRepo, storeData.detectedWorktreesByRepo, storeData.folderWorkspaces]
)
return (
<>
@@ -9,7 +9,6 @@ import SidebarHeader from './SidebarHeader'
const mocks = vi.hoisted(() => ({
openWorkspaceCreationComposerWithTourHandoff: vi.fn(),
unreadCount: { value: 0 },
popoverContentProps: { current: null as Record<string, unknown> | null },
toast: vi.fn()
}))
@@ -66,10 +65,6 @@ vi.mock('../contextual-tours/workspace-creation-tour-handoff', () => ({
openWorkspaceCreationComposerWithTourHandoff: mocks.openWorkspaceCreationComposerWithTourHandoff
}))
vi.mock('@/components/activity/useActivityUnreadCount', () => ({
useActivityUnreadCount: (enabled: boolean) => (enabled ? mocks.unreadCount.value : 0)
}))
vi.mock('sonner', () => ({ toast: mocks.toast }))
// Deterministic popover: expose the open flag instead of relying on radix portals.
@@ -122,7 +117,6 @@ beforeEach(() => {
// 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)
@@ -282,18 +276,6 @@ 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(() => {
@@ -4,8 +4,7 @@ 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 { shouldShowAgentsSidebar } from './agents-sidebar-visibility'
import { Popover, PopoverAnchor, PopoverArrow, PopoverContent } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Sparkles } from 'lucide-react'
@@ -29,9 +28,7 @@ const SidebarHeader = React.memo(function SidebarHeader({
const sidebarBody = useAppStore((s) => s.sidebarBody ?? 'workspaces')
// Why the derived boolean, not s.settings: the settings object gets a new identity on
// every write, which would re-render this memoized header subtree each time.
const showAgentsSidebarFromStore = useAppStore((s) =>
shouldShowAgentDashboardSidebarButton(s.settings)
)
const showAgentsSidebarFromStore = useAppStore((s) => shouldShowAgentsSidebar(s.settings))
const showAgentsSidebar = showAgentsSidebarProp ?? showAgentsSidebarFromStore
const groupBy = useAppStore((s) => s.groupBy)
const setSidebarBody = useAppStore((s) => s.setSidebarBody)
@@ -44,7 +41,6 @@ const SidebarHeader = React.memo(function SidebarHeader({
// null default would flash the popover open (and stamp it shown) every launch.
const settingsHydrated = useAppStore((s) => s.settings != null)
const agentsViewActive = showAgentsSidebar && sidebarBody === 'agents'
const agentsUnreadCount = useActivityUnreadCount(showAgentsSidebar, 'sidebar-badge')
const introOpen = settingsHydrated && showAgentsSidebar && !agentsSidebarIntroShown
const acknowledgeIntro = React.useCallback(() => {
void updateSettings?.({ agentsSidebarIntroShown: true })
@@ -106,7 +102,6 @@ const SidebarHeader = React.memo(function SidebarHeader({
value: 'agents' as const,
label: translate('dashboard.sidebar.label', 'Agents'),
sectionTitle: 'agents' as const,
badgeCount: agentsUnreadCount,
renderWrapper: (button: React.ReactNode) => (
<PopoverAnchor asChild>{button}</PopoverAnchor>
)
@@ -119,22 +114,42 @@ const SidebarHeader = React.memo(function SidebarHeader({
{/* Why: prevent startup terminal/editor auto-focus from dismissing the intro popover. */}
<PopoverContent
side="bottom"
align="start"
sideOffset={10}
className="w-72 overflow-visible rounded-xl border border-[color-mix(in_srgb,var(--ai-action-accent)_28%,var(--border))] bg-[color-mix(in_srgb,var(--ai-action-accent)_7%,var(--card))] p-3.5 text-card-foreground shadow-[0_12px_28px_-4px_rgba(139,92,246,0.18),0_4px_12px_rgba(0,0,0,0.08)] backdrop-blur-xl dark:border-[color-mix(in_srgb,var(--ai-action-accent)_36%,var(--border))] dark:bg-[color-mix(in_srgb,var(--ai-action-accent)_14%,var(--card))] dark:shadow-[0_16px_36px_-4px_rgba(0,0,0,0.5),0_0_24px_rgba(167,139,250,0.12)]"
align="center"
sideOffset={8}
className="w-72 overflow-visible rounded-xl border border-border bg-popover p-3.5 text-popover-foreground shadow-floating"
onOpenAutoFocus={(event) => event.preventDefault()}
onFocusOutside={(event) => event.preventDefault()}
>
<PopoverArrow
width={14}
height={7}
className="fill-[color-mix(in_srgb,var(--ai-action-accent)_7%,var(--card))] dark:fill-[color-mix(in_srgb,var(--ai-action-accent)_14%,var(--card))]"
/>
<PopoverArrow asChild width={14} height={7} className="overflow-visible">
<svg
viewBox="0 0 30 10"
preserveAspectRatio="none"
className="block overflow-visible"
>
<polygon points="0,0 30,0 15,10" className="fill-popover" />
<path d="M0 0 L15 10 L30 0" fill="none" className="stroke-border stroke-[1.5]" />
</svg>
</PopoverArrow>
<div className="space-y-2.5">
<svg width="0" height="0" className="absolute pointer-events-none" aria-hidden="true">
<defs>
<linearGradient id="agents-intro-aquatic" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="45%" stopColor="#06b6d4" />
<stop offset="100%" stopColor="#2563eb" />
</linearGradient>
</defs>
</svg>
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Sparkles className="size-3.5 shrink-0 text-[var(--ai-action-accent)]" />
<h3 className="text-sm font-semibold tracking-tight text-foreground">
<Sparkles
className="size-4 shrink-0"
stroke="url(#agents-intro-aquatic)"
fill="url(#agents-intro-aquatic)"
fillOpacity={0.15}
strokeWidth={2}
/>
<h3 className="text-sm font-semibold text-foreground">
{migratedFromExperimental
? translate('agentsSidebarIntro.migrated.title', 'Agents are easier to find')
: translate('agentsSidebarIntro.new.title', 'Meet your Agents tab')}
@@ -152,7 +167,7 @@ const SidebarHeader = React.memo(function SidebarHeader({
)}
</p>
</div>
<div className="flex justify-end gap-2 pt-1">
<div className="flex justify-end items-center gap-2 pt-0.5">
{!migratedFromExperimental ? (
<Button variant="ghost" size="sm" onClick={deferAgentsIntro}>
{translate('agentsSidebarIntro.new.dismiss', 'Maybe later')}
@@ -160,7 +175,7 @@ const SidebarHeader = React.memo(function SidebarHeader({
) : null}
<Button
size="sm"
className="bg-[var(--ai-action-accent)] text-white hover:bg-[color-mix(in_srgb,var(--ai-action-accent)_85%,black)] dark:hover:bg-[color-mix(in_srgb,var(--ai-action-accent)_85%,white)]"
className="border-0 bg-[linear-gradient(135deg,#10b981_0%,#06b6d4_45%,#2563eb_100%)] text-white font-medium shadow-xs hover:brightness-105 active:scale-[0.98] transition-all"
onClick={() => {
acknowledgeIntro()
setSidebarBody?.('agents')
@@ -218,26 +218,27 @@ describe('SidebarNav', () => {
setSidebarState()
})
it('mounts the Agent Dashboard row by default', async () => {
const container = await renderSidebarNav()
await waitFor(() => expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull())
expect(mocks.getAgentBucketCounts).toHaveBeenCalledTimes(1)
})
it('keeps the Agent Dashboard row unmounted after an explicit opt-out', async () => {
setSidebarState({
settings: {
...getDefaultSettings('/tmp'),
showAgentsSidebar: false
}
})
it('keeps the Agent Dashboard row unmounted while its experiment is off', async () => {
const container = await renderSidebarNav()
expect(queryButtonByText(container, 'Agent Dashboard')).toBeNull()
expect(mocks.getAgentBucketCounts).not.toHaveBeenCalled()
})
it('mounts the Agent Dashboard row only when its experiment is enabled', async () => {
setSidebarState({
settings: {
...getDefaultSettings('/tmp'),
showAgentsSidebar: false,
experimentalAgentDashboardPopout: true
}
})
const container = await renderSidebarNav()
await waitFor(() => expect(queryButtonByText(container, 'Agent Dashboard')).not.toBeNull())
expect(mocks.getAgentBucketCounts).toHaveBeenCalledTimes(1)
})
it('uses a question glyph only for the Needs You count', async () => {
mocks.agentBucketCounts = { attention: 2, working: 3, done: 1, idle: 4 }
setSidebarState({
@@ -14,7 +14,6 @@ import { SidebarTaskNavButton } from './SidebarTaskNavButton'
import { HideSidebarMenu } from './sidebar-nav-controls'
import { translate } from '@/i18n/i18n'
import { lazyWithRetry } from '@/lib/lazy-with-retry'
import { shouldShowAgentDashboardSidebarButton } from './agent-dashboard-sidebar-visibility'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
export { getSetupGuideSidebarEntryReady, shouldShowSetupGuideEntry } from './SetupGuideSidebarEntry'
@@ -43,6 +42,12 @@ export function shouldShowSkillsButton(
return settings?.showSkillsButton === true
}
export function shouldShowAgentDashboardButton(
settings: Partial<Pick<GlobalSettings, 'experimentalAgentDashboardPopout'>> | null | undefined
): boolean {
return settings?.experimentalAgentDashboardPopout === true
}
const AgentDashboardSidebarEntry = lazyWithRetry(() => import('./AgentDashboardSidebarEntry'))
const SidebarNav = React.memo(function SidebarNav() {
@@ -57,9 +62,7 @@ const SidebarNav = React.memo(function SidebarNav() {
const openModal = useAppStore((s) => s.openModal)
const updateSettings = useAppStore((s) => s.updateSettings)
const activeView = useAppStore((s) => s.activeView)
const showAgentDashboardButton = useAppStore((s) =>
shouldShowAgentDashboardSidebarButton(s.settings)
)
const showAgentDashboardButton = useAppStore((s) => shouldShowAgentDashboardButton(s.settings))
const showAutomationsButton = useAppStore((s) => shouldShowAutomationsButton(s.settings))
const showMobileButton = useAppStore((s) => shouldShowMobileButton(s.settings))
const showArtifactsButton = useAppStore((s) => shouldShowArtifactsButton(s.settings))
@@ -1,40 +0,0 @@
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)
})
it('graduates an Agents-view opt-in even when the popout experiment was opted out', () => {
// Matches main's migration formula so an un-migrated profile never flickers between the two.
expect(
shouldShowAgentDashboardSidebarButton({
experimentalActivity: true,
experimentalAgentDashboardPopout: false
})
).toBe(true)
})
})
@@ -1,14 +0,0 @@
import {
resolveAgentsSidebarVisible,
type AgentsSidebarVisibilitySettings
} from '../../../../shared/agents-sidebar-visibility'
export function shouldShowAgentDashboardSidebarButton(
settings: Partial<AgentsSidebarVisibilitySettings> | null | undefined
): boolean {
// Null means settings not yet hydrated; hide so opted-out profiles don't flash Agents UI at startup.
if (!settings) {
return false
}
return resolveAgentsSidebarVisible(settings)
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { shouldShowAgentsSidebar } from './agents-sidebar-visibility'
describe('shouldShowAgentsSidebar', () => {
it('hides while settings are not yet hydrated', () => {
expect(shouldShowAgentsSidebar(null)).toBe(false)
expect(shouldShowAgentsSidebar(undefined)).toBe(false)
})
it('defaults on and honors only the dedicated setting', () => {
expect(shouldShowAgentsSidebar({})).toBe(true)
expect(shouldShowAgentsSidebar({ showAgentsSidebar: true })).toBe(true)
expect(shouldShowAgentsSidebar({ showAgentsSidebar: false })).toBe(false)
})
})
@@ -0,0 +1,11 @@
import {
resolveAgentsSidebarVisible,
type AgentsSidebarVisibilitySettings
} from '../../../../shared/agents-sidebar-visibility'
export function shouldShowAgentsSidebar(
settings: Partial<AgentsSidebarVisibilitySettings> | null | undefined
): boolean {
// Settings hydrate after first render; avoid flashing UI for opted-out profiles.
return settings ? resolveAgentsSidebarVisible(settings) : false
}
@@ -4,7 +4,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useSidebarResize } from '@/hooks/useSidebarResize'
import SidebarHeader from './SidebarHeader'
import SidebarNav from './SidebarNav'
import { shouldShowAgentDashboardSidebarButton } from './agent-dashboard-sidebar-visibility'
import { shouldShowAgentsSidebar } from './agents-sidebar-visibility'
import SetupScriptPromptCard from './SetupScriptPromptCard'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
@@ -59,7 +59,8 @@ function Sidebar({
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
const settings = useAppStore((s) => s.settings)
const sidebarBody = useAppStore((s) => s.sidebarBody ?? 'workspaces')
const showAgentsSidebar = shouldShowAgentDashboardSidebarButton(settings)
const showAgentsSidebar = shouldShowAgentsSidebar(settings)
const showAgentDashboard = settings?.experimentalAgentDashboardPopout === true
const agentDashboardDrawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen)
const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
const [agentReadFilter, setAgentReadFilter] = React.useState<ThreadReadFilter>('all')
@@ -139,10 +140,10 @@ function Sidebar({
}, [closeWorkspaceBoard, sidebarOpen, workspaceBoardRenderedOpen])
useEffect(() => {
if (!showAgentsSidebar && agentDashboardDrawerOpen) {
if (!showAgentDashboard && agentDashboardDrawerOpen) {
setAgentDashboardDrawerOpen(false)
}
}, [agentDashboardDrawerOpen, setAgentDashboardDrawerOpen, showAgentsSidebar])
}, [agentDashboardDrawerOpen, setAgentDashboardDrawerOpen, showAgentDashboard])
const { containerRef, onResizeStart, isResizing } = useSidebarResize<HTMLDivElement>({
isOpen: sidebarOpen,
@@ -357,7 +358,7 @@ function Sidebar({
onMenuOpenChange={setWorkspaceBoardMenuOpen}
/>
) : null}
{showAgentsSidebar ? (
{showAgentDashboard ? (
<React.Suspense fallback={null}>
<AgentDashboardSidebarHost
sidebarOpen={sidebarOpen}
@@ -109,61 +109,4 @@ 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')
})
it('overlays the badge outside layout flow and clamps large counts', () => {
const renderWithCount = (badgeCount: number) =>
act(() => {
root.render(
<SidebarViewToggle
ariaLabel="Sidebar view"
value="workspaces"
onSelect={() => undefined}
options={[
{ value: 'workspaces', label: 'Projects', sectionTitle: 'projects' },
{ value: 'agents', label: 'Agents', sectionTitle: 'agents', badgeCount }
]}
/>
)
})
renderWithCount(99)
const agentsTab = container.querySelector('[data-sidebar-section-title="agents"]')
const badge = [...(agentsTab?.querySelectorAll('span') ?? [])].find(
(span) => span.textContent === '9+'
)
// Absolute overlay: the frozen-width contract holds as the count appears and grows.
expect(badge?.className).toContain('absolute')
expect(badge?.getAttribute('aria-hidden')).not.toBeNull()
// The visible label stays the sole in-flow content at any count.
const inFlowText = (): string | undefined =>
[...(container.querySelectorAll('[data-sidebar-section-title="agents"] span') ?? [])]
.filter((span) => !span.className.includes('absolute'))
.map((span) => span.textContent)
.join('')
const withBadge = inFlowText()
renderWithCount(0)
expect(inFlowText()).toBe(withBadge)
})
})
@@ -1,6 +1,5 @@
import React from 'react'
import { cn } from '@/lib/utils'
import { SidebarCountBadge } from './sidebar-count-badge'
type SidebarViewToggleOption = {
value: string
@@ -8,9 +7,6 @@ type SidebarViewToggleOption = {
/** Every label this slot can ever show; reserves width so switching never resizes the tab. */
widthLabels?: readonly string[]
sectionTitle?: string
/** Unread count shown as a corner overlay (9+ clamp); hidden at 0. Absolute so
* the frozen-width contract holds as the count appears and changes. */
badgeCount?: number
renderWrapper?: (button: React.ReactNode) => React.ReactNode
}
@@ -86,7 +82,6 @@ export function SidebarViewToggle({
</span>
))}
<span className="col-start-1 row-start-1 whitespace-nowrap">{option.label}</span>
{option.badgeCount ? <SidebarCountBadge count={option.badgeCount} /> : null}
</button>
)
+3 -1
View File
@@ -191,12 +191,14 @@ function PopoverContent({
function PopoverArrow({
className,
style,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Arrow>) {
return (
<PopoverPrimitive.Arrow
data-slot="popover-arrow"
className={cn('fill-popover', className)}
className={cn('fill-popover !visible block overflow-visible', className)}
style={{ visibility: 'visible', ...style }}
{...props}
/>
)
+2 -1
View File
@@ -14853,7 +14853,8 @@
"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"
"action": "Probar Agentes",
"hiddenToast": "La pestaña Agentes está oculta. Vuelve a activarla en Configuración → Experimental."
}
}
}
+2 -1
View File
@@ -14853,7 +14853,8 @@
"title": "Agent タブのご紹介",
"description": "Agent が何に取り組んでいるか、何が完了したか、どこで対応が必要かを確認できます。",
"dismiss": "後で",
"action": "Agent を試す"
"action": "Agent を試す",
"hiddenToast": "Agent タブを非表示にしました。設定 → 実験的機能で再度有効にできます。"
}
}
}
+2 -1
View File
@@ -14970,7 +14970,8 @@
"title": "에이전트 탭을 만나보세요",
"description": "에이전트가 무엇을 작업 중인지, 무엇이 완료되었는지, 어디에 개입이 필요한지 확인하세요.",
"dismiss": "나중에",
"action": "에이전트 사용해 보기"
"action": "에이전트 사용해 보기",
"hiddenToast": "에이전트 탭이 숨겨졌습니다. 설정 → 실험 기능에서 다시 활성화하세요."
}
}
}
+2 -1
View File
@@ -14970,7 +14970,8 @@
"title": "认识你的智能体标签页",
"description": "查看智能体正在做什么、哪些已完成,以及哪些需要你介入。",
"dismiss": "以后再说",
"action": "试用智能体"
"action": "试用智能体",
"hiddenToast": "智能体标签页已隐藏。可在设置 → 实验性功能中重新启用。"
}
}
}
@@ -41,7 +41,8 @@ describe('worktree jump navigation', () => {
hideWorkspacesFromOtherDevices: false,
alwaysShowDefaultBranchWorkspace: true,
visibleWorkspaceHostIds: null,
workspaceHostScope: 'all'
workspaceHostScope: 'all',
revealWorktreeInSidebar: vi.fn()
})
})
@@ -66,6 +67,16 @@ describe('worktree jump navigation', () => {
expect(mocks.warning).not.toHaveBeenCalled()
})
it('reveals without warning when activation wakes a target hidden only by Hide sleeping', () => {
const state = mocks.getState()
mocks.worktreePassesSidebarFilters.mockReturnValueOnce(false).mockReturnValueOnce(true)
expect(jumpToWorktreeFromSidebar('wt-sleeping')).toBe(true)
expect(state.revealWorktreeInSidebar).toHaveBeenCalledWith('wt-sleeping', {})
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([])
@@ -44,13 +44,13 @@ export function jumpToWorktreeFromSidebar(
const state = useAppStore.getState()
// Folder workspaces aren't in the worktree filter pipeline; only git worktrees can be filter-hidden.
const hiddenByFilters =
const hiddenBeforeActivation =
parseWorkspaceKey(worktreeId)?.type !== 'folder' &&
wasHiddenBySidebarFilters(worktreeId, options?.executionHostId)
// Why the workspace dispatcher: it owns the folder-vs-worktree split and the folder path-status gate.
const activated = activateAndRevealWorkspace(worktreeId, {
...(hiddenByFilters ? { revealInSidebar: false, clearSidebarFilters: false } : {}),
...(hiddenBeforeActivation ? { revealInSidebar: false, clearSidebarFilters: false } : {}),
...(options?.executionHostId ? { executionHostId: options.executionHostId } : {})
})
if (activated === false) {
@@ -60,7 +60,20 @@ export function jumpToWorktreeFromSidebar(
// The worktree list is the Spaces/Projects sidebar body; jump actions should always expose it.
state.setSidebarBody?.('workspaces')
if (hiddenByFilters) {
const hiddenAfterActivation =
hiddenBeforeActivation && wasHiddenBySidebarFilters(worktreeId, options?.executionHostId)
if (hiddenBeforeActivation && !hiddenAfterActivation) {
// Activation can seed a terminal, making a workspace excluded only by Hide sleeping visible.
// Queue the reveal after that state transition instead of reporting a filter conflict.
useAppStore
.getState()
.revealWorktreeInSidebar(
worktreeId,
options?.executionHostId ? { executionHostId: options.executionHostId } : {}
)
}
if (hiddenAfterActivation) {
toast.warning(
translate(
'auto.lib.worktreeJumpNavigation.filteredNotice',
@@ -20,11 +20,17 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => {
.getState()
.setAgentStatus('tab-1:0', { state: 'working', prompt: 'p', agentType: 'claude' })
store.getState().acknowledgeAgents(['tab-1:0'])
store.setState({
activityClearedAtByPaneKey: { 'tab-1:0': 100 },
manuallyUnreadTurnsByPaneKey: { 'tab-1:0': 200 }
})
expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeGreaterThan(0)
store.getState().removeAgentStatus('tab-1:0')
expect(store.getState().acknowledgedAgentsByPaneKey['tab-1:0']).toBeUndefined()
expect(store.getState().activityClearedAtByPaneKey['tab-1:0']).toBeUndefined()
expect(store.getState().manuallyUnreadTurnsByPaneKey['tab-1:0']).toBeUndefined()
})
it('removeAgentStatusByTabPrefix drops every ack entry whose paneKey starts with the tab prefix', () => {
@@ -40,6 +46,10 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => {
.getState()
.setAgentStatus('tab-10:0', { state: 'working', prompt: 'p', agentType: 'claude' })
store.getState().acknowledgeAgents(['tab-1:0', 'tab-1:1', 'tab-10:0'])
store.setState({
activityClearedAtByPaneKey: { 'tab-1:0': 100, 'tab-10:0': 300 },
manuallyUnreadTurnsByPaneKey: { 'tab-1:1': 200, 'tab-10:0': 400 }
})
store.getState().removeAgentStatusByTabPrefix('tab-1')
@@ -49,6 +59,8 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => {
// Why: the ":" delimiter on the prefix guards against false-prefix matches
// across tab ids that share a leading substring (tab-1 vs tab-10).
expect(ack['tab-10:0']).toBeGreaterThan(0)
expect(store.getState().activityClearedAtByPaneKey).toEqual({ 'tab-10:0': 300 })
expect(store.getState().manuallyUnreadTurnsByPaneKey).toEqual({ 'tab-10:0': 400 })
})
it('dropAgentStatus drops the ack entry even when the pane had no live entry', () => {
@@ -2,6 +2,7 @@ import type { AgentStatusSlice } from './agent-status-slice-contract'
import type { AgentStatusRuntime } from './agent-status-runtime'
import { collectWorktreeIdsForConnection } from './agent-status-connection-worktree-scope'
import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries'
import { removePaneKeys, removePaneKeysByTabPrefix } from './agent-status-pane-keyed-records'
/** Actions for removing transient rows and migration-era cache entries. */
export function createAgentStatusCleanupActions(
@@ -53,6 +54,9 @@ export function createAgentStatusCleanupActions(
if (
!(paneKey in current.agentStatusByPaneKey) &&
!(paneKey in current.agentLaunchConfigByPaneKey) &&
!(paneKey in current.acknowledgedAgentsByPaneKey) &&
!(paneKey in current.activityClearedAtByPaneKey) &&
!(paneKey in current.manuallyUnreadTurnsByPaneKey) &&
!Object.values(current.migrationUnsupportedByPtyId).some(
(entry) => entry.paneKey === paneKey
)
@@ -76,12 +80,10 @@ export function createAgentStatusCleanupActions(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey === paneKey
)
// Ack entries belong to the pane lifecycle; never let a reused key inherit one.
let nextAck = s.acknowledgedAgentsByPaneKey
if (paneKey in nextAck) {
nextAck = { ...nextAck }
delete nextAck[paneKey]
}
const paneKeys = new Set([paneKey])
const nextAck = removePaneKeys(s.acknowledgedAgentsByPaneKey, paneKeys)
const nextClearedAt = removePaneKeys(s.activityClearedAtByPaneKey, paneKeys)
const nextManualUnread = removePaneKeys(s.manuallyUnreadTurnsByPaneKey, paneKeys)
return {
agentStatusByPaneKey: next,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
@@ -89,6 +91,12 @@ export function createAgentStatusCleanupActions(
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
...(nextClearedAt !== s.activityClearedAtByPaneKey
? { activityClearedAtByPaneKey: nextClearedAt }
: {}),
...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey
? { manuallyUnreadTurnsByPaneKey: nextManualUnread }
: {}),
agentStatusEpoch: s.agentStatusEpoch + 1,
sortEpoch: s.sortEpoch + 1
}
@@ -108,7 +116,17 @@ export function createAgentStatusCleanupActions(
const hasMigrationUnsupported = Object.values(current.migrationUnsupportedByPtyId).some(
(entry) => entry.paneKey?.startsWith(prefix)
)
if (toRemove.length === 0 && launchConfigKeys.length === 0 && !hasMigrationUnsupported) {
const hasPaneActivityState = [
current.acknowledgedAgentsByPaneKey,
current.activityClearedAtByPaneKey,
current.manuallyUnreadTurnsByPaneKey
].some((record) => Object.keys(record).some((key) => key.startsWith(prefix)))
if (
toRemove.length === 0 &&
launchConfigKeys.length === 0 &&
!hasMigrationUnsupported &&
!hasPaneActivityState
) {
return
}
set((s) => {
@@ -124,14 +142,12 @@ export function createAgentStatusCleanupActions(
s.migrationUnsupportedByPtyId,
(entry) => entry.paneKey?.startsWith(prefix) ?? false
)
let nextAck = s.acknowledgedAgentsByPaneKey
const ackKeys = Object.keys(nextAck).filter((key) => key.startsWith(prefix))
if (ackKeys.length > 0) {
nextAck = { ...nextAck }
for (const key of ackKeys) {
delete nextAck[key]
}
}
const nextAck = removePaneKeysByTabPrefix(s.acknowledgedAgentsByPaneKey, tabIdPrefix)
const nextClearedAt = removePaneKeysByTabPrefix(s.activityClearedAtByPaneKey, tabIdPrefix)
const nextManualUnread = removePaneKeysByTabPrefix(
s.manuallyUnreadTurnsByPaneKey,
tabIdPrefix
)
return {
agentStatusByPaneKey: next,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
@@ -139,6 +155,12 @@ export function createAgentStatusCleanupActions(
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
...(nextClearedAt !== s.activityClearedAtByPaneKey
? { activityClearedAtByPaneKey: nextClearedAt }
: {}),
...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey
? { manuallyUnreadTurnsByPaneKey: nextManualUnread }
: {}),
agentStatusEpoch: s.agentStatusEpoch + 1,
sortEpoch: s.sortEpoch + 1
}
@@ -3,7 +3,8 @@ import type { DropAgentStatusByTabPrefixOptions } from './agent-status-contract'
import { pruneMigrationUnsupportedEntries } from './agent-status-migration-unsupported-entries'
import {
boundRecentlyClosedAgentStatusTabIds,
boundRecentlyRetiredAgentStatusPaneKeys
boundRecentlyRetiredAgentStatusPaneKeys,
removePaneKeysByTabPrefix
} from './agent-status-pane-keyed-records'
import { findCompletedOrphanPaneKeysForTabClose } from './agent-status-pane-key-tab-binding'
@@ -40,24 +41,6 @@ export type AgentStatusTabPrefixDropState = Pick<
| 'tabsByWorktree'
>
function removePaneKeyRecords<T>(
record: Record<string, T>,
prefix: string,
extraKeys: ReadonlySet<string>
): Record<string, T> {
const matchingKeys = Object.keys(record).filter(
(key) => key.startsWith(prefix) || extraKeys.has(key)
)
if (matchingKeys.length === 0) {
return record
}
const next = { ...record }
for (const key of matchingKeys) {
delete next[key]
}
return next
}
/** Pure form of the dropAgentStatusByTabPrefix reducer: the paired snapshot
* apply folds the same sweep into a patch it assembles itself, so the two
* paths cannot drift. `retiredAliasPaneKeys` comes from the caller because
@@ -108,10 +91,14 @@ export function buildAgentStatusTabPrefixDropPatch(
)
const nextClearedAt = opts?.preserveActivityClearedState
? s.activityClearedAtByPaneKey
: removePaneKeyRecords(s.activityClearedAtByPaneKey, prefix, completedOrphanKeySet)
: removePaneKeysByTabPrefix(s.activityClearedAtByPaneKey, tabIdPrefix, completedOrphanKeySet)
const nextManualUnread = opts?.preserveActivityClearedState
? s.manuallyUnreadTurnsByPaneKey
: removePaneKeyRecords(s.manuallyUnreadTurnsByPaneKey, prefix, completedOrphanKeySet)
: removePaneKeysByTabPrefix(
s.manuallyUnreadTurnsByPaneKey,
tabIdPrefix,
completedOrphanKeySet
)
if (
liveKeys.length === 0 &&
@@ -74,3 +74,15 @@ export function removePaneKeys<T>(
}
return next
}
export function removePaneKeysByTabPrefix<T>(
record: Record<string, T>,
tabPrefix: string,
extraPaneKeys: ReadonlySet<string> = new Set()
): Record<string, T> {
const prefix = `${tabPrefix}:`
const matchingKeys = Object.keys(record).filter(
(key) => key.startsWith(prefix) || extraPaneKeys.has(key)
)
return removePaneKeys(record, new Set(matchingKeys))
}
@@ -134,6 +134,7 @@ export function createAgentStatusProviderSessionActions(
if (nextRetained !== s.retainedAgentsByPaneKey) {
delete nextRetained[paneKey]
}
const retiredPaneKeys = new Set([paneKey])
// Why: on identity mismatch the sleeping record drops its launch config, so clear the stale
// registry entry too, else a later return to the old identity reuses stale args/env.
let nextLaunchConfigs = s.agentLaunchConfigByPaneKey
@@ -159,12 +160,14 @@ export function createAgentStatusProviderSessionActions(
agentLaunchConfigByPaneKey: nextLaunchConfigs,
acknowledgedAgentsByPaneKey: removePaneKeys(
s.acknowledgedAgentsByPaneKey,
new Set([paneKey])
retiredPaneKeys
),
unreadAgentCompletionPanes: removePaneKeys(
s.unreadAgentCompletionPanes,
new Set([paneKey])
activityClearedAtByPaneKey: removePaneKeys(s.activityClearedAtByPaneKey, retiredPaneKeys),
manuallyUnreadTurnsByPaneKey: removePaneKeys(
s.manuallyUnreadTurnsByPaneKey,
retiredPaneKeys
),
unreadAgentCompletionPanes: removePaneKeys(s.unreadAgentCompletionPanes, retiredPaneKeys),
agentStatusEpoch: removedLiveStatus ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: removedLiveStatus ? s.sortEpoch + 1 : s.sortEpoch
}
@@ -8,6 +8,7 @@ import {
retainedAgentEntryFromLive,
shouldReplaceRetainedWithLive
} from './agent-status-pane-key-tab-binding'
import { removePaneKeys } from './agent-status-pane-keyed-records'
export function createAgentStatusWorktreeDropActions(
runtime: AgentStatusRuntime
@@ -72,21 +73,23 @@ export function createAgentStatusWorktreeDropActions(
}
}
const retainedEvidenceKeys = new Set(retainedEvidence.keys())
// Keep acknowledgement for completion evidence so a slept card does not turn bold again.
let nextAck = s.acknowledgedAgentsByPaneKey
const ackKeys = Object.keys(nextAck).filter(
(key) =>
!retainedEvidenceKeys.has(key) &&
(paneKeyMatchesAnyTabPrefix(key, tabPrefixes) ||
liveKeySet.has(key) ||
retainedKeySet.has(key))
// Completion evidence keeps its read/clear state; every fully retired pane drops all three maps.
const activityStateKeys = new Set(
[
...Object.keys(s.acknowledgedAgentsByPaneKey),
...Object.keys(s.activityClearedAtByPaneKey),
...Object.keys(s.manuallyUnreadTurnsByPaneKey)
].filter(
(key) =>
!retainedEvidenceKeys.has(key) &&
(paneKeyMatchesAnyTabPrefix(key, tabPrefixes) ||
liveKeySet.has(key) ||
retainedKeySet.has(key))
)
)
if (ackKeys.length > 0) {
nextAck = { ...nextAck }
for (const key of ackKeys) {
delete nextAck[key]
}
}
const nextAck = removePaneKeys(s.acknowledgedAgentsByPaneKey, activityStateKeys)
const nextClearedAt = removePaneKeys(s.activityClearedAtByPaneKey, activityStateKeys)
const nextManualUnread = removePaneKeys(s.manuallyUnreadTurnsByPaneKey, activityStateKeys)
if (
liveKeys.length === 0 &&
launchConfigKeys.length === 0 &&
@@ -94,9 +97,18 @@ export function createAgentStatusWorktreeDropActions(
retainedEvidence.size === 0 &&
!migrationUnsupported.changed
) {
return nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: s
const cleanupPatch = {
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
...(nextClearedAt !== s.activityClearedAtByPaneKey
? { activityClearedAtByPaneKey: nextClearedAt }
: {}),
...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey
? { manuallyUnreadTurnsByPaneKey: nextManualUnread }
: {})
}
return Object.keys(cleanupPatch).length > 0 ? cleanupPatch : s
}
hadLive = liveKeys.length > 0
const nextLive =
@@ -144,6 +156,12 @@ export function createAgentStatusWorktreeDropActions(
...(nextAck !== s.acknowledgedAgentsByPaneKey
? { acknowledgedAgentsByPaneKey: nextAck }
: {}),
...(nextClearedAt !== s.activityClearedAtByPaneKey
? { activityClearedAtByPaneKey: nextClearedAt }
: {}),
...(nextManualUnread !== s.manuallyUnreadTurnsByPaneKey
? { manuallyUnreadTurnsByPaneKey: nextManualUnread }
: {}),
agentStatusEpoch:
hadLive || migrationUnsupported.changed ? s.agentStatusEpoch + 1 : s.agentStatusEpoch,
sortEpoch: hadLive || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
@@ -16,7 +16,7 @@ import {
} 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 { shouldShowAgentsSidebar } from '@/components/sidebar/agents-sidebar-visibility'
import { DEFAULT_STATUS_BAR_ITEMS } from '../../../../../shared/constants'
import type { UISlice } from './ui-slice-contract'
@@ -175,14 +175,14 @@ export function sanitizeWorkspaceCleanupDismissals(
export function sanitizeHydratedActiveView(
value: PersistedUIState['activeView'],
settings: Parameters<typeof shouldShowAgentDashboardSidebarButton>[0]
settings: Parameters<typeof shouldShowAgentsSidebar>[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 entry points are, so gate only it (mobile/automations stay functional when hidden).
if (value === 'activity' && !shouldShowAgentDashboardSidebarButton(settings)) {
if (value === 'activity' && !shouldShowAgentsSidebar(settings)) {
return 'terminal'
}
return value
@@ -1,7 +1,7 @@
import type { UISlice, UISliceGet, UISliceSet } from './ui-slice-contract'
import { isSettingsNavigationTarget } from '../../../lib/settings-navigation-types'
// Pure predicate over GlobalSettings; safe to share with the store layer.
import { shouldShowAgentDashboardSidebarButton } from '@/components/sidebar/agent-dashboard-sidebar-visibility'
import { shouldShowAgentsSidebar } from '@/components/sidebar/agents-sidebar-visibility'
export function createUiSettingsActions(set: UISliceSet, get: UISliceGet): Partial<UISlice> {
return {
@@ -20,7 +20,7 @@ export function createUiSettingsActions(set: UISliceSet, get: UISliceGet): Parti
// Agents graduated from experimentalActivity; match openActivityPage's gate.
const previousView =
state.previousViewBeforeSettings === 'activity' &&
!shouldShowAgentDashboardSidebarButton(state.settings)
!shouldShowAgentsSidebar(state.settings)
? 'terminal'
: state.previousViewBeforeSettings
return { activeView: previousView }
@@ -1,14 +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'
import { shouldShowAgentsSidebar } from '@/components/sidebar/agents-sidebar-visibility'
export function createUiViewActions(set: UISliceSet, get: UISliceGet): Partial<UISlice> {
return {
openActivityPage: () => {
// 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)) {
if (!shouldShowAgentsSidebar(get().settings)) {
return
}
set((state) => ({
+1 -12
View File
@@ -1,7 +1,5 @@
export type AgentsSidebarVisibilitySettings = {
showAgentsSidebar?: boolean
experimentalActivity?: boolean
experimentalAgentDashboardPopout?: boolean
}
export function resolveAgentsSidebarVisible(
@@ -10,14 +8,5 @@ export function resolveAgentsSidebarVisible(
if (!settings) {
return true
}
if (typeof settings.showAgentsSidebar === 'boolean') {
return settings.showAgentsSidebar
}
if (settings.experimentalActivity === true) {
return true
}
if (typeof settings.experimentalAgentDashboardPopout === 'boolean') {
return settings.experimentalAgentDashboardPopout
}
return true
return settings.showAgentsSidebar !== false
}
+2 -2
View File
@@ -427,9 +427,9 @@ export type GlobalSettings = {
experimentalActivity: boolean
/** Experimental: pop-out Kanban dashboard for monitoring and opening agent terminals across worktrees. */
experimentalAgentDashboardPopout?: boolean
/** Whether the Agents dashboard tab is shown in the left sidebar. Defaults on. */
/** Experimental: whether the Agents tab is shown in the left sidebar. Defaults on. */
showAgentsSidebar?: boolean
/** Set after the graduated Agents tab introduction has been acknowledged. */
/** Set after the experimental Agents tab introduction has been acknowledged. */
agentsSidebarIntroShown?: boolean
/** True when the profile previously opted into the legacy experimental Agents view. */
agentsSidebarMigratedFromExperimental?: boolean