mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
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:
@@ -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 & Sidebar')
|
||||
expect(markup).toContain('Controls whether the Agents tab appears in the left sidebar')
|
||||
expect(markup).not.toContain('Window & 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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14853,7 +14853,8 @@
|
||||
"title": "Agent タブのご紹介",
|
||||
"description": "Agent が何に取り組んでいるか、何が完了したか、どこで対応が必要かを確認できます。",
|
||||
"dismiss": "後で",
|
||||
"action": "Agent を試す"
|
||||
"action": "Agent を試す",
|
||||
"hiddenToast": "Agent タブを非表示にしました。設定 → 実験的機能で再度有効にできます。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14970,7 +14970,8 @@
|
||||
"title": "에이전트 탭을 만나보세요",
|
||||
"description": "에이전트가 무엇을 작업 중인지, 무엇이 완료되었는지, 어디에 개입이 필요한지 확인하세요.",
|
||||
"dismiss": "나중에",
|
||||
"action": "에이전트 사용해 보기"
|
||||
"action": "에이전트 사용해 보기",
|
||||
"hiddenToast": "에이전트 탭이 숨겨졌습니다. 설정 → 실험 기능에서 다시 활성화하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user