feat(agent-cockpit): replace bottom-panel dashboard with inline per-card agents list (#1251)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-04-29 18:51:24 -07:00 committed by GitHub
parent 89f41bfda2
commit c1620496cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 548 additions and 2055 deletions

View File

@ -64,7 +64,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
openLinksInApp: false,
rightSidebarOpenByDefault: true,
showTitlebarAgentActivity: true,
showAgentDashboard: true,
showTaskProviderIcons: true,
diffDefaultView: 'inline',
notifications: {

View File

@ -58,7 +58,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
openLinksInApp: false,
rightSidebarOpenByDefault: true,
showTitlebarAgentActivity: true,
showAgentDashboard: true,
showTaskProviderIcons: true,
diffDefaultView: 'inline',
notifications: {

View File

@ -341,15 +341,15 @@ app.whenReady().then(async () => {
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
// Why: managed hook installation mutates user-global agent config.
// Startup must fail open so a malformed local config never bricks Orca.
// Claude/Codex/Gemini installs are gated behind the experimental
// Agent Dashboard setting because the surface they feed (the in-progress
// agent dashboard) isn't shippable yet. Cursor installs unconditionally
// because cursor-agent emits no title-based working/idle signal at all
// (its terminal title stays literally "Cursor Agent" across a turn), so
// the hook channel is the only way to drive the sidebar spinner + unread
// path for it — there is no "pre-dashboard" fallback to degrade to the
// way Claude/Codex have. Toggling the setting takes effect on next launch
// because the hook scripts are installed once per boot.
// Claude/Codex/Gemini installs are gated behind the experimentalAgentDashboard
// setting because the feature they feed (the inline agent-activity list) is
// still in preview. Cursor installs unconditionally because cursor-agent
// emits no title-based working/idle signal at all (its terminal title stays
// literally "Cursor Agent" across a turn), so the hook channel is the only
// way to drive the sidebar spinner + unread path for it — there is no
// title-based fallback the way Claude/Codex have. Toggling the setting
// takes effect on next launch because the hook scripts are installed once
// per boot.
const agentDashboardEnabled = store.getSettings().experimentalAgentDashboard === true
if (agentDashboardEnabled) {
for (const installManagedHooks of [

View File

@ -9,7 +9,7 @@ export type AppRuntimeFlags = {
/** Whether the experimental agent dashboard setting was enabled when this
* session booted. When true, Claude/Codex/Gemini managed hook installation
* was attempted at startup (individual install failures are logged but do
* not flip this flag the dashboard UI itself treats missing hooks as
* not flip this flag the inline agents list treats missing hooks as
* no-ops). Toggling the setting only affects hook installation on the next
* launch, so the renderer compares this against the current setting to
* decide whether a "restart required" banner needs to be shown. */

View File

@ -202,9 +202,7 @@ describe('fetchGeminiRateLimits', () => {
return Promise.resolve(makeResponse(quotaResponse))
}
if (url.includes('token')) {
return Promise.resolve(
makeResponse({ access_token: 'retried-token', expires_in: 3600 })
)
return Promise.resolve(makeResponse({ access_token: 'retried-token', expires_in: 3600 }))
}
if (url.includes('loadCodeAssist')) {
return Promise.resolve(makeResponse({ cloudaicompanionProject: 'proj-123' }))

View File

@ -139,6 +139,13 @@ function App(): React.JSX.Element {
const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen)
const isFullScreen = useAppStore((s) => s.isFullScreen)
const settings = useAppStore((s) => s.settings)
// Why: render-level gate for the experimental agent dashboard retention
// sync. Reading the flag here (rather than only inside useDashboardData /
// useRetainedAgentsSync) lets us skip mounting RetainedAgentsSyncGate
// entirely for non-toggled users, which drops all feature-tied
// subscriptions (agentStatusByPaneKey, agentStatusEpoch, etc.) instead of
// keeping them alive behind an early-return inside the hook bodies.
const agentDashboardEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true)
const canGoBackWorktree = useAppStore(canGoBackWorktreeHistory)
const canGoForwardWorktree = useAppStore(canGoForwardWorktreeHistory)
const titlebarLeftControlsRef = useRef<HTMLDivElement | null>(null)
@ -147,10 +154,10 @@ function App(): React.JSX.Element {
// Subscribe to IPC push events
useIpcEvents()
// Why: retention must run at App level (not inside AgentDashboard) because
// the sidebar hovercard also reads retained entries. If retention only ran
// when the dashboard is mounted, "done" agents would vanish from the hover
// any time the user collapses the dashboard panel.
// Why: retention must run at App level so the inline per-card agents list
// always sees retained entries. If retention ran inside the sidebar-card
// subtree, "done" agents would vanish any time the user collapsed a card's
// inline agents section.
//
// The retention hooks are hosted inside <RetainedAgentsSyncGate /> (a leaf
// component that renders null) rather than being called inline here.
@ -159,10 +166,17 @@ function App(): React.JSX.Element {
// event frequency), re-rendering the entire app tree on every agent status
// update. Hosting the subscriptions in a leaf isolates that churn.
//
// The experimentalAgentDashboard gate still lives inside the hooks
// themselves (useDashboardData early-returns [] from its memo;
// useRetainedAgentsSync early-returns from its effect), so the gate
// component is cheap when the setting is off.
// The render-level gate on <RetainedAgentsSyncGate /> (see
// agentDashboardEnabled above) keeps the experimental feature fully dark
// for non-toggled users: without the gate mounted, none of its feature-tied
// zustand selectors (agentStatusByPaneKey / agentStatusEpoch / etc.) are
// ever subscribed, so PTY agent-status events cause zero work for them.
//
// The inner hook guards (useDashboardData early-returns [] from its memo;
// useRetainedAgentsSync early-returns from its effect) remain as
// defense-in-depth: they keep both hooks safe to call from any future
// callsite, and they handle the in-session off→on toggle transition
// cleanly without relying on a remount race when the setting flips.
// Why: git conflict-operation state also drives the worktree cards. Polling
// cannot live under RightSidebar because App unmounts that subtree when the
// sidebar is closed, which leaves stale "Rebasing"/"Merging" badges behind
@ -926,10 +940,16 @@ function App(): React.JSX.Element {
}
>
<TooltipProvider delayDuration={400}>
{/* Why: leaf-mounted retention sync. Hosts useDashboardData() +
useRetainedAgentsSync() so their high-churn store subscriptions
re-render a null component rather than the entire App tree. */}
<RetainedAgentsSyncGate />
{/* Why: leaf-mounted retention sync, gated at the render level by
agentDashboardEnabled. Hosting useDashboardData() +
useRetainedAgentsSync() inside a null-rendering leaf keeps their
high-churn store subscriptions from re-rendering the App tree;
the outer conditional drops those subscriptions entirely for
users who have not toggled the experimental agent dashboard on,
so PTY agent-status events do no feature-tied work for them.
The hooks' internal early-returns remain as defense-in-depth
(see the comment above useIpcEvents()). */}
{agentDashboardEnabled ? <RetainedAgentsSyncGate /> : null}
{/* Why: in workspace view (split groups always enabled), the full-width
titlebar is removed so tab groups + terminal extend to the top of
the window. Left titlebar controls move to a header above the sidebar.

View File

@ -1,334 +0,0 @@
import React, { useState, useCallback, useMemo, useRef } from 'react'
import { Search, X, ChevronDown, ChevronRight, FolderGit2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useDashboardData } from './useDashboardData'
import { useDashboardFilter } from './useDashboardFilter'
import { useDashboardKeyboard } from './useDashboardKeyboard'
import { useRetainedAgents } from './useRetainedAgents'
import DashboardFilterBar from './DashboardFilterBar'
import DashboardWorktreeCard from './DashboardWorktreeCard'
import { useNow } from './useNow'
const AgentDashboard = React.memo(function AgentDashboard() {
const liveGroups = useDashboardData()
// Why: useRetainedAgents keeps a "done" row visible after the terminal/pane
// is closed and the explicit status entry is evicted from the store. Without
// this, a completed agent vanishes entirely — and the user loses the signal
// that the agent finished. Retained rows are dismissed when the user clicks
// through to the worktree.
const { enrichedGroups: groups, dismissAgent } = useRetainedAgents(liveGroups)
const dropAgentStatus = useAppStore((s) => s.dropAgentStatus)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveTab = useAppStore((s) => s.setActiveTab)
const setActiveView = useAppStore((s) => s.setActiveView)
// Why: the persistent "selected" tint on a worktree card tracks the active
// worktree, not the last-clicked focus state. Keeping this in sync with the
// app-level activeWorktreeId makes the dashboard highlight what the user is
// currently viewing rather than where the keyboard/mouse last landed.
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
// Why: own the 30s relative-time tick once for the whole dashboard and
// thread it down to every agent row. Previously each DashboardAgentRow
// instantiated its own setInterval, which scaled linearly with the number
// of rows on screen (N timers → N staggered re-renders per cycle).
const now = useNow(30_000)
// Why: the store's explicit status entry persists after an agent reports
// `done` until the pane actually exits — which may be much later, since the
// user often leaves the Claude/Codex session alive to review output. The
// per-row dismiss removes both the live store entry and any retained entry
// so done agents don't pile up indefinitely in the dashboard.
const handleDismissAgent = useCallback(
(paneKey: string) => {
dropAgentStatus(paneKey)
dismissAgent(paneKey)
},
[dropAgentStatus, dismissAgent]
)
const [searchQuery, setSearchQuery] = useState('')
const { filter, setFilter, filteredGroups, hasResults } = useDashboardFilter(groups, searchQuery)
const [focusedWorktreeId, setFocusedWorktreeId] = useState<string | null>(null)
// Why: repo groups are collapsible so users can hide repos they aren't
// actively watching. State is per-session (intentionally not persisted) —
// a long-lived collapsed state across restarts would hide new activity
// under a closed header and silently erase the "needs attention" signal.
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
const toggleCollapse = useCallback((repoId: string) => {
setCollapsedRepos((prev) => {
const next = new Set(prev)
if (next.has(repoId)) {
next.delete(repoId)
} else {
next.add(repoId)
}
return next
})
}, [])
// Why: arrow-key nav should only step over worktrees whose repo header is
// expanded. Building this list here keeps the source of truth in one place
// for both the DOM render order and the keyboard iteration order.
const visibleWorktrees = useMemo(
() => filteredGroups.flatMap((g) => (collapsedRepos.has(g.repo.id) ? [] : g.worktrees)),
[filteredGroups, collapsedRepos]
)
// Why: the keyboard hook scopes its listener to this container (not window)
// so dashboard shortcuts (1-5, arrows, Enter, Escape) don't hijack the
// terminal or other focused inputs when the dashboard pane is merely open.
// We keep a local ref so the callback ref below can detect the null → element
// transition (for focus-on-first-mount), and separately feed the element into
// `useDashboardKeyboard` via its callback ref. The container is rendered
// unconditionally (including on the no-repos empty state) so keyboard
// shortcuts remain wired even before any repo is added — otherwise the
// filter keys (1-5) would silently no-op until the first repo arrived.
const containerRef = useRef<HTMLDivElement | null>(null)
// Why: clicking an agent row takes the user to the specific tab the agent
// ran in, not just the worktree's last-active tab. Retained rows can outlive
// their pane — fall back to worktree-only activation when the tab is no
// longer present so the click still lands somewhere useful.
const handleActivateAgentTab = useCallback(
(worktreeId: string, tabId: string) => {
setActiveWorktree(worktreeId)
setActiveView('terminal')
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
if (tabs.some((t) => t.id === tabId)) {
setActiveTab(tabId)
}
},
[setActiveWorktree, setActiveTab, setActiveView]
)
// Why: a stable reference shared across every DashboardWorktreeCard so
// React.memo can actually short-circuit re-renders. An inline
// `() => setFocusedWorktreeId(card.worktree.id)` per card per render would
// mint a fresh function every render and invalidate memo on all N cards
// even when nothing else changed — the 30s `now` tick alone would cascade.
const handleCardFocus = useCallback((worktreeId: string) => setFocusedWorktreeId(worktreeId), [])
const setKeyboardContainer = useDashboardKeyboard({
filteredWorktrees: visibleWorktrees,
focusedWorktreeId,
setFocusedWorktreeId,
filter,
setFilter
})
// Why: combine the local RefObject with the keyboard hook's callback ref so
// both see the same element on attach/detach, and use the callback to drive
// focus-on-first-mount (below). A callback ref is the canonical React
// pattern for "run code when an element mounts/unmounts" and is required
// here because the container div is conditionally rendered — a RefObject
// alone wouldn't notify the keyboard hook (or the focus logic) when the
// container appears after the empty-state branch is replaced.
const setContainerRef = useCallback(
(el: HTMLDivElement | null) => {
const hadEl = containerRef.current !== null
containerRef.current = el
setKeyboardContainer(el)
// Why: focus the container the first time it becomes non-null so keyboard
// shortcuts work immediately — including on the path where the empty-state
// branch renders first (no container) and repos arrive later. A []-deps
// useEffect would fire once against a null ref on that path and never
// re-run. activeElement guard prevents stealing focus from inputs the
// user is currently typing in (e.g. sidebar search, terminal).
if (el && !hadEl) {
if (document.activeElement === null || document.activeElement === document.body) {
el.focus()
}
}
},
[setKeyboardContainer]
)
const handleClearSearch = useCallback(() => setSearchQuery(''), [])
const searchActive = searchQuery.trim().length > 0
const showNoResults = searchActive && !hasResults
// Why: render the container unconditionally so the keyboard hook's
// callback ref fires on mount even when no repos exist yet — otherwise
// filter/arrow shortcuts don't bind until the first repo arrives.
const hasRepos = groups.length > 0
return (
<div
ref={setContainerRef}
tabIndex={-1}
className="flex h-full w-full flex-col overflow-hidden outline-none"
>
{!hasRepos ? (
<div className="flex h-full w-full items-center justify-center p-4">
<div className="text-center text-[11px] text-muted-foreground">
No repos added. Add a repo to see agent activity.
</div>
</div>
) : (
<>
<div className="flex shrink-0 flex-col gap-1.5 border-b border-border/40 px-2 py-1.5">
<div className="relative flex items-center">
<Search className="absolute left-2 size-3.5 text-muted-foreground pointer-events-none" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="h-7 pl-7 pr-7 text-[11px] border-none bg-muted/50 shadow-none focus-visible:ring-1 focus-visible:ring-ring/30"
/>
{searchActive && (
<Button
variant="ghost"
size="icon-xs"
onClick={handleClearSearch}
className="absolute right-1 size-5"
aria-label="Clear search"
>
<X className="size-3" />
</Button>
)}
</div>
<div className="flex items-center justify-center">
<DashboardFilterBar value={filter} onChange={setFilter} />
</div>
</div>
<div className="flex-1 overflow-y-auto scrollbar-sleek">
{hasResults ? (
<div className="flex flex-col">
{filteredGroups.map((group, groupIdx) => {
const isCollapsed = collapsedRepos.has(group.repo.id)
// Why: per-repo stats replace the global stats strip that used
// to sit above the whole dashboard. Counts live at the scope
// of the grouping so the user sees per-repo agent load instead
// of a rollup that hides which repo is busy. Counts come from
// useDashboardFilter's memo so this doesn't re-walk every agent
// in every worktree on each `now` tick or search change.
const { running: groupRunning, blocked: groupBlocked, done: groupDone } = group
const Icon = isCollapsed ? ChevronRight : ChevronDown
return (
<div
key={group.repo.id}
// Why: the entire repo group tints on hover (children and
// all) so the user sees a clear visual container for the
// repo — mirroring the worktree → agent pattern where the
// whole worktree tints when hovered and its nested agent
// rows tint more strongly on top. No card chrome, just an
// ambient hover.
className={cn(
// Why: light-mode needs to darken the surface (not add
// a pale accent to near-white) for the container tint
// to register. Use a subtle black alpha in light, keep
// the original alpha-on-accent in dark (which already
// reads as a faint lift on the dark surface).
'transition-colors duration-100 hover:bg-black/[0.02] dark:hover:bg-accent/10',
groupIdx !== filteredGroups.length - 1 && 'border-b border-border'
)}
>
{/* Why: the repo header is a lightweight row, not a card
no background fill, no border box. It stays an
expand/collapse control so users can hide repos they
aren't watching, but it doesn't wrap the children in
chrome that duplicates the worktree row's own borders. */}
<button
type="button"
onClick={() => toggleCollapse(group.repo.id)}
className={cn(
'flex w-full items-center gap-1.5 px-2.5 pt-1.5 pb-1',
'text-left text-[11px] text-muted-foreground/80'
)}
aria-expanded={!isCollapsed}
>
<Icon className="size-3 shrink-0 text-muted-foreground/60" />
{/* Why: mirror the sidebar's worktree list repos are
keyed by the FolderGit2 glyph colored with the repo's
own badgeColor, so the dashboard header reads as the
same repo entity a user scans for in the sidebar. */}
<FolderGit2
className="size-3 shrink-0"
style={{ color: group.repo.badgeColor }}
aria-hidden
/>
<span className="truncate font-medium text-foreground/80">
{group.repo.displayName}
</span>
<span className="ml-auto flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
{groupRunning > 0 && (
<span>
<span className="font-semibold text-yellow-500">{groupRunning}</span>{' '}
active
</span>
)}
{groupBlocked > 0 && (
<span>
<span className="font-semibold text-red-500">{groupBlocked}</span>{' '}
blocked
</span>
)}
{groupDone > 0 && (
<span>
<span className="font-semibold text-emerald-500">{groupDone}</span>{' '}
done
</span>
)}
</span>
</button>
{!isCollapsed &&
group.worktrees.map((card, i) => (
<DashboardWorktreeCard
key={card.worktree.id}
card={card}
isActive={activeWorktreeId === card.worktree.id}
onFocus={handleCardFocus}
onDismissAgent={handleDismissAgent}
onActivateAgentTab={handleActivateAgentTab}
isLast={i === group.worktrees.length - 1}
now={now}
/>
))}
</div>
)
})}
</div>
) : (
<div className="flex flex-col items-center pt-4 pb-6 gap-2">
<div className="text-[11px] text-muted-foreground/60">
{showNoResults
? 'No matches.'
: filter === 'active'
? 'No active agents.'
: filter === 'blocked'
? 'No agents are blocked.'
: filter === 'done'
? 'No completed agents to show.'
: 'No agent activity yet.'}
</div>
{showNoResults ? (
<button
type="button"
onClick={handleClearSearch}
className="text-[11px] text-primary/70 hover:text-primary hover:underline"
>
Clear search
</button>
) : (
filter !== 'all' && (
<button
type="button"
onClick={() => setFilter('all')}
className="text-[11px] text-primary/70 hover:text-primary hover:underline"
>
Show all
</button>
)
)}
</div>
)}
</div>
</>
)}
</div>
)
})
export default AgentDashboard

View File

@ -71,21 +71,29 @@ type Props = {
* Why: the relative-time labels ("Xm ago") need a periodic re-render to stay
* honest. We accept `now` from a parent container so a single 30s tick owned
* by the container drives every visible row, rather than each row running
* its own setInterval. See useNow.ts for the shared hook callers own the
* tick (AgentDashboard for the dashboard, AgentStatusHover for hovercards).
* its own setInterval. See useNow.ts for the shared hook WorktreeCardAgents
* owns the tick for the inline-in-card list.
*/
now: number
/**
* Why: bold weight for the prompt rides on the enclosing worktree's
* isUnread (unvisited) signal, not on the per-agent state. Passed in from
* DashboardWorktreeCard so the workspace name and its agent rows share
* Why: bold weight for the prompt rides on the enclosing workspace card's
* unvisited signal, not on the per-agent state. Passed in from
* WorktreeCardAgents so the workspace name and its agent rows share
* the same "you haven't looked at this yet" rule visiting the worktree
* clears isUnread, and the next render mutes both in lockstep.
* clears the signal, and the next render mutes both in lockstep.
*
* Optional so other callers can opt out and default to muted when their
* surface carries the unread signal elsewhere.
*/
isUnvisited?: boolean
/**
* Why: the inline-in-card variant sits in a tighter layout next to the
* agent identity icon, so 'md' reads as a second ~12px glyph that users
* can confuse with the agent icon. 'sm' keeps them visually distinct.
* The full dashboard has more breathing room and prefers 'md' for leading-
* slot presence, so default stays 'md'.
*/
stateDotSize?: 'sm' | 'md'
}
const DashboardAgentRow = React.memo(function DashboardAgentRow({
@ -93,7 +101,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
onDismiss,
onActivate,
now,
isUnvisited = false
isUnvisited = false,
stateDotSize = 'md'
}: Props) {
const [expanded, setExpanded] = useState(false)
// Why: stop propagation so clicking the X doesn't also fire the worktree
@ -146,7 +155,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
// slot would collapse the text column and leave the row with no human-
// readable label — just a state dot and icon. Fall back to the state label
// ("Working", "Done", "Waiting", …) so every row is identifiable at a
// glance, matching the old AgentStatusHover.tsx behavior.
// glance.
const displayLabel = prompt || agentStateLabel(asDotState(agent.state))
// Why: the tool row describes what the agent is *currently* doing; once it
// leaves working, that line goes stale and misleads (a done row showing
@ -179,8 +188,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
// role=button violates ARIA's "no interactive content inside interactive
// content" rule and breaks keyboard/AT navigation. Keyboard users reach
// the agent via the child buttons and the tab switcher; the outer <div>
// stays a plain clickable surface for pointer activation, mirroring the
// pattern in DashboardBottomPanel.tsx's collapse header.
// stays a plain clickable surface for pointer activation.
<div
onClick={handleActivate}
className={cn(
@ -206,7 +214,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0 items-center justify-center">
<AgentStateDot state={asDotState(agent.state)} size="md" />
<AgentStateDot state={asDotState(agent.state)} size={stateDotSize} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>

View File

@ -1,41 +0,0 @@
import React from 'react'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import type { DashboardFilter } from './useDashboardFilter'
const FILTERS: { value: DashboardFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' }
]
type Props = {
value: DashboardFilter
onChange: (value: DashboardFilter) => void
}
const DashboardFilterBar = React.memo(function DashboardFilterBar({ value, onChange }: Props) {
return (
<ToggleGroup
type="single"
value={value}
onValueChange={(v) => {
// ToggleGroup fires empty string when deselecting — keep current filter
if (v) {
onChange(v as DashboardFilter)
}
}}
variant="outline"
size="sm"
className="gap-0"
>
{FILTERS.map((f) => (
<ToggleGroupItem key={f.value} value={f.value} className="text-[11px] px-2.5 py-1 h-7">
{f.label}
</ToggleGroupItem>
))}
</ToggleGroup>
)
})
export default DashboardFilterBar

View File

@ -1,198 +0,0 @@
import React, { useCallback, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
import DashboardAgentRow from './DashboardAgentRow'
import type { DashboardWorktreeCard as DashboardWorktreeCardData } from './useDashboardData'
type Props = {
card: DashboardWorktreeCardData
/** True when this worktree is the one the user is currently viewing. */
isActive: boolean
/**
* Why: accepts the worktree id (not a SyntheticEvent) so the parent can pass
* a single stable callback shared across all cards instead of minting a
* fresh `() => setFocusedWorktreeId(id)` closure per card per render that
* inline lambda would invalidate React.memo on every AgentDashboard render.
*/
onFocus: (worktreeId: string) => void
onDismissAgent: (paneKey: string) => void
/** Navigate to a specific tab inside this card's worktree. */
onActivateAgentTab: (worktreeId: string, tabId: string) => void
isLast: boolean
/**
* Why: `now` is owned by the dashboard container and threaded through every
* card to its rows. One shared 30s tick re-renders all visible "Xm ago"
* labels instead of each row owning its own setInterval (which would fire N
* times per cycle, staggered by mount time).
*/
now: number
}
const DashboardWorktreeCard = React.memo(function DashboardWorktreeCard({
card,
isActive,
onFocus,
onDismissAgent,
onActivateAgentTab,
isLast,
now
}: Props) {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveView = useAppStore((s) => s.setActiveView)
const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents)
const paneKeys = useMemo(() => card.agents.map((a) => a.paneKey), [card.agents])
// Why: subscribe to the ack map's single reference (cheap Object.is check
// via Zustand's default equality) and derive the per-card slice locally.
// A useShallow selector here would allocate a fresh object on every
// store change — including unrelated ones like terminal output —
// multiplied across every card on screen. Reading the reference and
// memoizing the per-card slice collapses that to one allocation per
// card per genuine ack change. acknowledgeAgents in ui.ts preserves the
// map reference when no ack is actually moving forward, so unrelated
// clicks do not invalidate this memo either.
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
const ackByPaneKey = useMemo(() => {
const out: Record<string, number> = {}
for (const paneKey of paneKeys) {
out[paneKey] = acknowledgedAgentsByPaneKey[paneKey] ?? 0
}
return out
}, [paneKeys, acknowledgedAgentsByPaneKey])
// Why: an agent counts as "unvisited" when it has no ack OR the agent's
// current state began after the last ack (a new turn/state transition is
// a fresh signal the user hasn't seen). Using stateStartedAt (not
// updatedAt) means within-state tool/prompt pings don't re-trigger the
// unread highlight; only genuine state changes do.
const isAgentUnvisited = useCallback(
(paneKey: string, stateStartedAt: number) => {
const ackAt = ackByPaneKey[paneKey] ?? 0
return ackAt < stateStartedAt
},
[ackByPaneKey]
)
// Why: clicking a worktree row navigates AND acknowledges every agent
// currently shown under it. The user looking at the card counts as
// "seeing" all its rows, even ones they don't click individually —
// otherwise a workspace with five done agents would stay bold forever
// after the user scrolled past it. Dismissal of done rows still requires
// the explicit X; ack only changes the visual weight.
const handleClick = useCallback(() => {
setActiveWorktree(card.worktree.id)
setActiveView('terminal')
acknowledgeAgents(paneKeys)
}, [card.worktree.id, paneKeys, setActiveWorktree, setActiveView, acknowledgeAgents])
// Why: clicking an agent row navigates to that agent's tab AND acks the
// row so it fades to the visited weight. Scoped to a single paneKey so
// sibling rows (other agents on the same workspace) remain bold until the
// user looks at them. Dismissal still requires the explicit X.
const handleActivateAgent = useCallback(
(tabId: string, paneKey: string) => {
onActivateAgentTab(card.worktree.id, tabId)
acknowledgeAgents([paneKey])
},
[card.worktree.id, onActivateAgentTab, acknowledgeAgents]
)
// Why: React's onFocus handler receives a SyntheticEvent, but the parent
// needs the worktree id. Wrap here so the parent can pass a single stable
// callback that does not get invalidated per-card per-render.
const handleFocus = useCallback(() => {
onFocus(card.worktree.id)
}, [onFocus, card.worktree.id])
const branchName = card.worktree.branch?.replace(/^refs\/heads\//, '') ?? ''
// Why: workspace-level bold/muted weight tracks whether ANY of this card's
// agents are unvisited — so the workspace header stays bold while even one
// row inside it needs the user's attention, and fades once the user has
// clicked through every agent. Per-agent granularity lives on the rows
// themselves (DashboardAgentRow isUnvisited prop). If a workspace has no
// agents (edge case during spin-up), default to muted so the row doesn't
// read louder than it has value to.
const anyAgentUnvisited = card.agents.some((a) =>
isAgentUnvisited(a.paneKey, a.entry.stateStartedAt)
)
// Why: the card is a clickable *surface* but NOT a `role="button"` — its
// children (DashboardAgentRow) render real <button>s (dismiss X, chevron),
// and ARIA forbids interactive descendants inside a role=button ancestor
// (screen readers flatten it, leaving the inner buttons unreachable). The
// dashboard's keyboard hook (useDashboardKeyboard.ts) owns Enter/arrow-key
// routing via `closest('[data-worktree-id]')`, so activation is handled
// there — we only need the surface to be programmatically focusable
// (tabIndex={-1}) so arrow-key navigation's `cardEl.focus()` works. This
// mirrors the DashboardBottomPanel.tsx:247-253 pattern.
return (
<div
tabIndex={-1}
data-worktree-id={card.worktree.id}
onClick={handleClick}
onFocus={handleFocus}
className={cn(
'cursor-pointer px-2.5 py-1 transition-colors duration-100',
// Why: light-mode hovers have to darken (not lighten) the surface —
// `--accent` is #f5f5f5 so adding it to white lifts nothing. Use a
// black alpha overlay in light mode and keep the original
// alpha-on-accent for dark mode, mirroring WorktreeCard's active
// state pattern. Focus/focused are each one step stronger than
// hover, keeping the same hierarchy dark mode already reads.
'hover:bg-black/[0.04] dark:hover:bg-accent/20',
'focus-visible:outline-none focus-visible:bg-black/[0.06] dark:focus-visible:bg-accent/30',
// Why: the persistent tint tracks the *active* worktree (the one the
// user is viewing), not the last card that happened to receive focus.
// Focus state sticks around after click and never clears, so using
// it for the persistent highlight made every clicked row appear
// selected forever; tying it to activeWorktreeId keeps the highlight
// in sync with what the user actually has open.
isActive && 'bg-black/[0.05] dark:bg-accent/25',
!isLast && 'border-b border-border/80'
)}
>
{/* Worktree header row. Why: workspace name + branch share one line
to save vertical space the branch is a secondary qualifier that
reads fine as a muted suffix rather than its own line. Weight is
driven by anyAgentUnvisited so unvisited workspaces read boldly,
while already-visited ones fade into the background. */}
<div className="flex items-baseline gap-1.5 min-w-0">
<span
className={cn(
'text-[11px] truncate leading-tight shrink-0 max-w-[60%]',
anyAgentUnvisited
? 'font-semibold text-foreground'
: 'font-normal text-muted-foreground'
)}
>
{card.worktree.displayName}
</span>
{branchName && (
<span className="text-[10px] text-muted-foreground/60 truncate min-w-0 leading-tight">
{branchName}
</span>
)}
</div>
{/* Agent rows with activity blocks */}
{card.agents.length > 0 && (
<div className="mt-1.5 flex flex-col divide-y divide-border">
{card.agents.map((agent, index) => (
<div key={agent.paneKey} className={cn(index === 0 ? 'pb-1' : 'py-1')}>
<DashboardAgentRow
agent={agent}
onDismiss={onDismissAgent}
onActivate={handleActivateAgent}
now={now}
isUnvisited={isAgentUnvisited(agent.paneKey, agent.entry.stateStartedAt)}
/>
</div>
))}
</div>
)}
</div>
)
})
export default DashboardWorktreeCard

View File

@ -1,12 +1,13 @@
import { useDashboardData } from './useDashboardData'
import { useRetainedAgentsSync } from './useRetainedAgents'
// Why: isolate the dashboard retention subscriptions in a leaf component that
// renders null, so the high-churn slices read by useDashboardData
// Why: isolate the retention subscriptions in a leaf component that renders
// null, so the high-churn slices read by useDashboardData
// (agentStatusByPaneKey + agentStatusEpoch, which tick at PTY event frequency)
// do not re-render the entire App tree. Retention must still run at the App
// level — if it only ran when the dashboard is mounted, "done" agents would
// vanish from the sidebar hovercard whenever the panel is collapsed.
// level — if it only ran when a single card was mounted, "done" agents would
// vanish from the inline agents list any time the user scrolled that card
// out of view.
//
// The hooks inside still early-return when the experimentalAgentDashboard
// setting is off, so this gate is cheap when the feature is disabled.

View File

@ -9,7 +9,7 @@ import {
} from '../../../../shared/agent-status-types'
import type { Repo, Worktree, TerminalTab } from '../../../../shared/types'
// ─── Dashboard data types ─────────────────────────────────────────────────────
// ─── Shared data types ────────────────────────────────────────────────────────
export type DashboardAgentRow = {
paneKey: string
@ -23,19 +23,14 @@ export type DashboardAgentRow = {
startedAt: number
}
// Why: the shape here is deliberately minimal — just what useRetainedAgentsSync
// needs to diff liveGroups and decide which vanished agents to retain. The
// per-card rendering pipeline is separate (WorktreeCardAgents +
// useWorktreeAgentRows read retained entries directly from the store).
export type DashboardWorktreeCard = {
repo: Repo
worktree: Worktree
agents: DashboardAgentRow[]
/** Highest-priority agent state for filtering.
* Priority: blocked > working > done > idle.
* `waiting` is folded into `blocked` both are attention-needed states. */
dominantState: 'working' | 'blocked' | 'done' | 'idle'
/** Earliest startedAt across all agents in this worktree. Once the worktree
* has at least one agent, this value is stable new agents starting in
* the same worktree do not change it. Sorting worktrees by this value
* asc keeps list order stable while the user is reading. */
earliestStartedAt: number
}
export type DashboardRepoGroup = {
@ -45,39 +40,11 @@ export type DashboardRepoGroup = {
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function computeDominantState(
agents: DashboardAgentRow[]
): DashboardWorktreeCard['dominantState'] {
if (agents.length === 0) {
return 'idle'
}
let hasWorking = false
let hasDone = false
for (const agent of agents) {
if (agent.state === 'blocked' || agent.state === 'waiting') {
return 'blocked'
}
if (agent.state === 'working') {
hasWorking = true
}
if (agent.state === 'done') {
hasDone = true
}
}
if (hasWorking) {
return 'working'
}
if (hasDone) {
return 'done'
}
return 'idle'
}
// Why: the dashboard only surfaces agents that have reported state via a hook.
// A tab hosting a shell, a REPL before its first turn, or an agent we have no
// hook integration for will have no entry here — and that's correct. The
// dashboard's job is to show *agent work in progress*, not to guess which
// terminals might contain an agent.
// Why: only surface agents that have reported state via a hook. A tab hosting
// a shell, a REPL before its first turn, or an agent we have no hook
// integration for will have no entry here — and that's correct. Agent rows
// represent *agent work in progress*, not "which terminals might contain an
// agent".
function buildAgentRowsForWorktree(
worktreeId: string,
tabsByWorktree: Record<string, TerminalTab[]>,
@ -132,9 +99,9 @@ function buildDashboardData(
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
now: number
): DashboardRepoGroup[] {
// Why: build a tabId -> entries index once per dashboard computation instead
// of re-scanning every agent status entry inside the per-tab loop. paneKey
// is formatted as `${tabId}:${paneId}`; splitting on the first ':' lets us
// Why: build a tabId -> entries index once per computation instead of
// re-scanning every agent status entry inside the per-tab loop. paneKey is
// formatted as `${tabId}:${paneId}`; splitting on the first ':' lets us
// bucket entries by tab in a single O(N) pass, turning the per-worktree
// build from O(tabs × statuses) into O(tabs).
const entriesByTabId = new Map<string, AgentStatusEntry[]>()
@ -157,25 +124,7 @@ function buildDashboardData(
.filter((w) => !w.isArchived)
.map((worktree) => {
const agents = buildAgentRowsForWorktree(worktree.id, tabsByWorktree, entriesByTabId, now)
// Why: sort agents within a worktree oldest-first by startedAt. A new
// agent appears at the BOTTOM so it doesn't shove the row the user
// is currently reading down the list. Stable order also means
// retained/live transitions don't reshuffle siblings. Users care
// less about "which is newest" than "let this list stop moving
// while I read it".
agents.sort((a, b) => a.startedAt - b.startedAt)
// Why: earliestStartedAt (oldest agent in the worktree) is stable once
// the worktree has any agent at all. Using it for outer sorts means
// a brand-new agent in a different worktree no longer shoves this
// card around while the user is reading.
const earliestStartedAt = agents.length > 0 ? agents[0].startedAt : 0
return {
repo,
worktree,
agents,
dominantState: computeDominantState(agents),
earliestStartedAt
} satisfies DashboardWorktreeCard
return { repo, worktree, agents } satisfies DashboardWorktreeCard
})
return { repo, worktrees } satisfies DashboardRepoGroup
@ -184,6 +133,21 @@ function buildDashboardData(
// ─── Hook ─────────────────────────────────────────────────────────────────────
// Why: stable empty array reference so the memo returns the same
// value each call when the feature is off. Without this, fresh [] per
// memo run churns downstream effect deps and re-fires them on every
// PTY agent-status tick purely to early-return.
const EMPTY_GROUPS: DashboardRepoGroup[] = []
/**
* Cross-worktree aggregate of live agent rows. Used by useRetainedAgentsSync
* to drive retention: when a previously-live 'done' agent disappears from
* this set, its snapshot is moved into retainedAgentsByPaneKey so the inline
* per-card list can still render it.
*
* Not used to render anything directly the inline list reads its own
* worktree-scoped slice via useWorktreeAgentRows.
*/
export function useDashboardData(): DashboardRepoGroup[] {
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
@ -203,11 +167,11 @@ export function useDashboardData(): DashboardRepoGroup[] {
() => {
// Why: experimental-setting gate inside the memo avoids the
// O(repos × worktrees × agents) rebuild on every store update when the
// dashboard is disabled. Store selectors still subscribe to keep
// feature is disabled. Store selectors still subscribe to keep
// rules-of-hooks satisfied and so flipping the setting re-renders
// consumers.
if (!dashboardEnabled) {
return []
return EMPTY_GROUPS
}
return buildDashboardData(
repos,

View File

@ -1,185 +0,0 @@
import { useState, useMemo } from 'react'
import type {
DashboardRepoGroup,
DashboardWorktreeCard,
DashboardAgentRow
} from './useDashboardData'
export type DashboardFilter = 'active' | 'all' | 'blocked' | 'done'
export type FilteredDashboardGroup = {
repo: DashboardRepoGroup['repo']
worktrees: DashboardWorktreeCard[]
/** Earliest startedAt across all worktrees in this group. Stable once the
* group has any agent used for deterministic ordering between groups. */
earliestStartedAt: number
/**
* Why: per-repo agent state counts are computed here (inside the filter
* memo) rather than inline in the render body of AgentDashboard. Inline
* iteration re-walked every agent in every worktree on each `now` tick,
* search change, or store update; precomputing keeps that O(N) scan scoped
* to actual changes in `groups`/`filter`/`searchQuery`. Counts reflect the
* *filtered* agents the user actually sees, which matches the header
* numbers to the rendered rows.
*/
running: number
blocked: number
done: number
}
// Why: filters apply to individual AGENTS, not worktrees. Previously we filtered
// by the worktree's dominantState, so a done agent stayed visible under the
// 'active' tab if a sibling agent in the same worktree was working — which
// defeats the point of the filter. Agent-level filtering keeps the worktree
// grouping intact but hides rows whose state doesn't match.
function matchesAgent(agent: DashboardAgentRow, filter: DashboardFilter): boolean {
switch (filter) {
case 'all':
return true
case 'active':
return agent.state === 'working'
case 'blocked':
return agent.state === 'blocked' || agent.state === 'waiting'
case 'done':
return agent.state === 'done'
}
}
// Why: search also matches per-agent — so a typo'd prompt in one agent doesn't
// drag its unrelated siblings into the visible set. Repo/worktree/branch hits
// still surface the whole worktree row (keep all its agents) because at that
// point the user is looking for the *worktree*, not a specific agent inside it.
function worktreeMetaMatches(card: DashboardWorktreeCard, q: string): boolean {
if (card.repo.displayName.toLowerCase().includes(q)) {
return true
}
if (card.worktree.displayName.toLowerCase().includes(q)) {
return true
}
const branch = card.worktree.branch?.toLowerCase() ?? ''
return branch.includes(q)
}
function agentMatchesSearch(agent: DashboardAgentRow, q: string): boolean {
if (agent.entry.prompt?.toLowerCase().includes(q)) {
return true
}
if (agent.entry.toolName?.toLowerCase().includes(q)) {
return true
}
if (agent.entry.toolInput?.toLowerCase().includes(q)) {
return true
}
if (agent.entry.lastAssistantMessage?.toLowerCase().includes(q)) {
return true
}
return agent.agentType.toLowerCase().includes(q)
}
export function useDashboardFilter(
groups: DashboardRepoGroup[],
searchQuery: string
): {
filter: DashboardFilter
setFilter: (f: DashboardFilter) => void
filteredGroups: FilteredDashboardGroup[]
hasResults: boolean
} {
const [filter, setFilter] = useState<DashboardFilter>('all')
const filteredGroups = useMemo(() => {
const q = searchQuery.trim().toLowerCase()
const out: FilteredDashboardGroup[] = []
for (const group of groups) {
const worktrees: DashboardWorktreeCard[] = []
// Why: accumulate per-repo state counts while we already iterate the
// filtered agents — avoids a second pass (and avoids re-walking every
// agent in the AgentDashboard render body on every `now` tick).
let running = 0
let blocked = 0
let done = 0
for (const wt of group.worktrees) {
const stateMatched = wt.agents.filter((a) => matchesAgent(a, filter))
if (stateMatched.length === 0) {
continue
}
const agents: DashboardAgentRow[] =
q.length === 0 || worktreeMetaMatches(wt, q)
? stateMatched
: stateMatched.filter((a) => agentMatchesSearch(a, q))
// Why: drop worktrees whose agents are all filtered out — an empty
// row would just be noise in the list.
if (agents.length === 0) {
continue
}
for (const agent of agents) {
if (agent.state === 'working') {
running++
} else if (agent.state === 'blocked' || agent.state === 'waiting') {
blocked++
} else if (agent.state === 'done') {
done++
}
}
// Why: recompute earliestStartedAt from the filtered agents so the
// sort key below reflects the agents this worktree actually displays.
// Otherwise a filter that removes the earliest-starting agent leaves
// a phantom sort key from useDashboardData's un-filtered list and
// worktrees drift out of order when filters/search are applied.
// agents.length > 0 is guaranteed by the early-continue above, and
// agents are pre-sorted asc by startedAt upstream, so agents[0] is
// the minimum. startedAt is always set (useDashboardData derives it
// from stateStartedAt, which is set on every entry), so no fallback
// to wt.earliestStartedAt is needed — and using that upstream value
// would reintroduce the exact phantom sort key this block avoids.
const filteredEarliest = agents[0].startedAt
// Why: preserve worktree card identity in the no-op case so
// React.memo on DashboardWorktreeCard can short-circuit. `filter`
// always allocates a new array even when no elements are removed, so
// reference equality against `wt.agents` is never true — length
// equality is our proxy for "nothing was filtered out" (safe because
// .filter preserves order, so equal length means every element
// passed). When agents are full AND earliestStartedAt is unchanged,
// the upstream `wt` object is already the correct card and spreading
// would only defeat memoization, causing every `now` tick or
// unrelated store update to re-render all cards.
const stateMatchedAll = stateMatched.length === wt.agents.length
const agentsAreFull = stateMatchedAll && agents.length === wt.agents.length
const passthrough = agentsAreFull && filteredEarliest === wt.earliestStartedAt
worktrees.push(passthrough ? wt : { ...wt, agents, earliestStartedAt: filteredEarliest })
}
if (worktrees.length === 0) {
continue
}
// Why: sort worktrees within a group by earliest-started agent asc.
// Stable once populated — a new agent starting in a sibling worktree
// doesn't reshuffle this one while the user reads.
worktrees.sort((a, b) => a.earliestStartedAt - b.earliestStartedAt)
out.push({
repo: group.repo,
worktrees,
earliestStartedAt: worktrees[0]?.earliestStartedAt ?? 0,
running,
blocked,
done
})
}
// Why: sort groups by the earliest-started worktree asc for the same
// "stop moving while I read" reason. Repos with no activity yet get 0
// and fall to the top, which is fine since they render empty anyway.
out.sort((a, b) => a.earliestStartedAt - b.earliestStartedAt)
return out
}, [groups, filter, searchQuery])
// Why: filteredGroups drops any group whose worktrees array is empty (see the
// early-continue above), so a non-empty groups array guarantees at least one
// visible worktree. Avoids an extra flatMap just to check length.
const hasResults = filteredGroups.length > 0
return {
filter,
setFilter,
filteredGroups,
hasResults
}
}

View File

@ -1,223 +0,0 @@
import { useEffect, useCallback, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import type { DashboardWorktreeCard } from './useDashboardData'
import type { DashboardFilter } from './useDashboardFilter'
type UseDashboardKeyboardParams = {
filteredWorktrees: DashboardWorktreeCard[]
focusedWorktreeId: string | null
setFocusedWorktreeId: (id: string | null) => void
filter: DashboardFilter
setFilter: (f: DashboardFilter) => void
}
// Why: the listener must be scoped to the dashboard container so keystrokes
// (Arrow keys, digits 1-4, Enter, Escape) only fire when focus is inside the
// dashboard. Attaching to window intercepts terminal/xterm navigation (arrow
// keys for command history) and shell digit entry while the dashboard pane
// is merely open, which breaks those unrelated inputs.
//
// Why return a callback ref (and not accept a RefObject): AgentDashboard has
// an early-return branch that renders an empty state WITHOUT the container
// div when there are no repos. On initial render with no repos, a plain
// `useRef` would be null, our attach-effect would no-op, and then when repos
// later appear and the container mounts, React would NOT re-run the effect
// (a RefObject has stable identity, so its mutation doesn't trigger effects).
// The result: the keyboard listener would silently never attach on that path.
// A callback ref fires synchronously on attach/detach; storing the element in
// useState makes the effect re-run whenever the container appears or goes
// away, fixing the gap without any `ref.current`-as-dep anti-patterns.
type ContainerCallbackRef = (el: HTMLDivElement | null) => void
const FILTER_KEYS: Record<string, DashboardFilter> = {
'1': 'all',
'2': 'active',
'3': 'blocked',
'4': 'done'
}
export function useDashboardKeyboard({
filteredWorktrees,
focusedWorktreeId,
setFocusedWorktreeId,
filter,
setFilter
}: UseDashboardKeyboardParams): ContainerCallbackRef {
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveView = useAppStore((s) => s.setActiveView)
// Why: no `rightSidebarOpen` guard needed. The keydown listener is attached
// to the dashboard container element (see attach-effect below), which lives
// inside the right sidebar. When the sidebar is closed it collapses to 0
// width with overflow hidden, so focus cannot land inside the dashboard,
// and keydown events from the focused element (typically the terminal) are
// never dispatched to the dashboard container. Subscribing to the flag here
// would only cause this hook to recompute and re-attach the listener on
// every sidebar toggle without adding any safety.
// Why: track the container element in state so the attach-effect re-runs
// whenever the element mounts or unmounts. See the file-level comment for
// why a plain RefObject is insufficient here.
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
// Why: stash data the handler reads in refs so it doesn't re-bind on every
// agent-status update (which produces a fresh filteredWorktrees array most
// renders). Without this, the listener is add/removed at PTY event rate.
const filteredWorktreesRef = useRef(filteredWorktrees)
const focusedWorktreeIdRef = useRef(focusedWorktreeId)
const filterRef = useRef(filter)
const containerElRef = useRef<HTMLDivElement | null>(null)
// Why: mirror the three render-driven inputs into refs in a single
// commit-phase effect so the stable handleKeyDown callback always reads
// the latest values without re-binding the listener at PTY event rate.
useEffect(() => {
filteredWorktreesRef.current = filteredWorktrees
focusedWorktreeIdRef.current = focusedWorktreeId
filterRef.current = filter
})
useEffect(() => {
// Why: mirror the element into a ref so the (stable) handleKeyDown
// callback can query inside the current container without needing to
// re-bind when the element identity changes.
containerElRef.current = containerEl
}, [containerEl])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Don't intercept when focus is in an editable element
const target = e.target as HTMLElement
if (
target.isContentEditable ||
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT'
) {
return
}
// Don't intercept when a modifier key is held (let app shortcuts through)
if (e.metaKey || e.ctrlKey || e.altKey) {
return
}
// Filter quick-select: 1-4 keys.
// Why: only fire when focus is on the dashboard container or a worktree
// card, not on interactive descendants (dismiss X, expand chevron,
// filter toggle, clear-search). Otherwise pressing a digit while such
// a button is focused would silently change the filter — a foot-gun,
// since buttons are common focus targets after clicks/keyboard nav.
if (FILTER_KEYS[e.key]) {
const onCardOrContainer =
target === containerElRef.current || !!target.closest('[data-worktree-id]')
if (!onCardOrContainer) {
return
}
// Why: if the target itself is an interactive descendant of the worktree
// card (dismiss X, expand chevron, filter toggle, clear-search button),
// a digit keystroke would change the filter even though the user has a
// nested button focused. Reject those so filter keys only fire from the
// card surface itself or the dashboard container. (The container-is-target
// fast path is already handled above.)
if (target !== containerElRef.current) {
const interactiveAncestor = target.closest(
'button, a, input, textarea, select, [role="button"], [role="switch"], [role="tab"], [contenteditable="true"]'
)
if (interactiveAncestor && interactiveAncestor !== containerElRef.current) {
return
}
}
e.preventDefault()
setFilter(FILTER_KEYS[e.key])
return
}
// Escape: reset filter to 'all' (the default)
if (e.key === 'Escape') {
if (filterRef.current !== 'all') {
e.preventDefault()
setFilter('all')
}
return
}
// Arrow key navigation
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault()
const worktrees = filteredWorktreesRef.current
const ids = worktrees.map((wt) => wt.worktree.id)
if (ids.length === 0) {
return
}
const focused = focusedWorktreeIdRef.current
const currentIndex = focused ? ids.indexOf(focused) : -1
let nextIndex: number
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
nextIndex = currentIndex < ids.length - 1 ? currentIndex + 1 : 0
} else {
nextIndex = currentIndex > 0 ? currentIndex - 1 : ids.length - 1
}
const nextId = ids[nextIndex]
setFocusedWorktreeId(nextId)
// Focus the corresponding DOM card. Why: scope the lookup to the
// dashboard container so we don't accidentally match a card rendered
// elsewhere in the app (and so the query fails closed when the
// container is unmounted).
// Why: worktreeId is `${repoId}::${path}` (see src/shared/types.ts)
// and filesystem paths can contain characters like `"` or `\` that
// would otherwise break the attribute-selector string and throw a
// SyntaxError, silently killing arrow-key navigation. CSS.escape()
// safely encodes those special characters.
const cardEl = containerElRef.current?.querySelector(
`[data-worktree-id="${CSS.escape(nextId)}"]`
) as HTMLElement | null
cardEl?.focus()
return
}
// Enter: navigate to focused worktree.
// Why: only fire when the native keydown target is the card itself OR
// is nested inside one — but never on interactive descendants like the
// dismiss X, expand chevron, clear-search button, or filter toggle,
// whose own handlers would be blocked by preventDefault. Today the card
// has no focusable descendants (the action buttons stopPropagation and
// are reached via mouse, not keyboard Tab from the card), so using
// `closest('[data-worktree-id]')` is safe AND robust against future
// changes that might add focusable descendants elsewhere in the card.
if (e.key === 'Enter' && focusedWorktreeIdRef.current) {
const enterTarget = e.target as HTMLElement | null
if (!enterTarget || !enterTarget.closest('[data-worktree-id]')) {
return
}
e.preventDefault()
setActiveWorktree(focusedWorktreeIdRef.current)
setActiveView('terminal')
}
},
[setFocusedWorktreeId, setFilter, setActiveWorktree, setActiveView]
)
useEffect(() => {
// Why: attach to the dashboard container rather than window so these
// shortcuts only fire when focus is inside the dashboard. This prevents
// Arrow keys and digits 1-4 from hijacking the terminal (xterm history
// navigation) and shell input while the dashboard pane is open.
//
// Why depend on `containerEl` (state) not a ref: the container is not
// rendered on the empty-state branch, so it mounts *after* this hook
// first runs once repos appear. State-backed tracking via the callback
// ref guarantees this effect re-runs at that mount.
if (!containerEl) {
return
}
containerEl.addEventListener('keydown', handleKeyDown)
return () => containerEl.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown, containerEl])
// Why: return a stable callback ref so the caller can spread it onto the
// container's `ref` prop. useState's setter identity is stable across
// renders, so this doesn't churn React's ref-assignment cycle.
return setContainerEl
}

View File

@ -3,8 +3,8 @@ import { useEffect, useState } from 'react'
// Why: relative timestamps drift once mounted. A 30s tick keeps the "Xm
// ago" labels honest without burning a render every second.
//
// Hoisted to a shared hook so container components (AgentDashboard,
// AgentStatusHover) can own a single tick and thread `now` down to every
// Hoisted to a shared hook so container components (e.g.
// WorktreeCardAgents) can own a single tick and thread `now` down to every
// DashboardAgentRow. Previously each row instantiated its own interval,
// which meant N timers firing at staggered mount times for N rows on
// screen — turning one logical tick into N independent React commits.

View File

@ -1,19 +1,13 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useEffect, useRef } from 'react'
import { useAppStore } from '@/store'
import {
computeDominantState,
type DashboardRepoGroup,
type DashboardAgentRow,
type DashboardWorktreeCard
} from './useDashboardData'
import { type DashboardRepoGroup, type DashboardAgentRow } from './useDashboardData'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
// Why: when an agent finishes or its terminal closes, the store cleans up the
// explicit status entry and the agent vanishes from useDashboardData. Retaining
// the last-known "done" snapshot in the store (not in component state) lets the
// dashboard AND the sidebar hovercard render the exact same set of rows — the
// two surfaces must be consistent so the user sees the same completion in both
// places, and dismissal in one reflects in the other.
// the last-known "done" snapshot in the store lets the inline per-card agents
// list render the done row until the user dismisses it, rather than having the
// row wink out the moment the terminal process exits.
export function useRetainedAgentsSync(liveGroups: DashboardRepoGroup[]): void {
const retainAgents = useAppStore((s) => s.retainAgents)
@ -75,7 +69,7 @@ export function useRetainedAgentsSync(liveGroups: DashboardRepoGroup[]): void {
// would trigger N set(...) calls and N subscriber notifications when
// several agents vanish in the same frame (e.g. tab close, worktree
// teardown), exposing intermediate maps to consumers mid-loop. A single
// atomic update keeps the dashboard + sidebar hovercard visually stable.
// atomic update keeps the inline agents list visually stable.
retainAgents(toRetain)
prevAgentsRef.current = current
@ -92,125 +86,6 @@ export function useRetainedAgentsSync(liveGroups: DashboardRepoGroup[]): void {
])
}
export function useRetainedAgents(liveGroups: DashboardRepoGroup[]): {
enrichedGroups: DashboardRepoGroup[]
dismissAgent: (paneKey: string) => void
} {
// Why: the retention sync runs at App level (see useRetainedAgentsSync in
// App.tsx) so retained entries persist across dashboard mounts. This hook
// only reads + dismisses individual rows; bulk worktree-level dismissal
// was removed because silently dropping retained done agents when the
// user clicks a worktree can erase completion signals for other agents
// (e.g. a done Codex row while a live Claude row triggered the click).
const retained = useAppStore((s) => s.retainedAgentsByPaneKey)
const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent)
const enrichedGroups = useMemo(
() => enrichGroupsWithRetained(liveGroups, retained),
[liveGroups, retained]
)
const dismissAgent = useCallback(
(paneKey: string) => {
dismissRetainedAgent(paneKey)
},
[dismissRetainedAgent]
)
return { enrichedGroups, dismissAgent }
}
export function enrichGroupsWithRetained(
liveGroups: DashboardRepoGroup[],
retained: Record<string, RetainedAgentEntry>
): DashboardRepoGroup[] {
const retainedList = Object.values(retained)
if (retainedList.length === 0) {
return liveGroups
}
const byWorktree = new Map<string, RetainedAgentEntry[]>()
for (const ra of retainedList) {
const list = byWorktree.get(ra.worktreeId) ?? []
list.push(ra)
byWorktree.set(ra.worktreeId, list)
}
// Why: if the same paneKey is both live and retained during a render seam,
// the live row wins so we never double-render an agent mid-transition.
const livePaneKeys = new Set<string>()
for (const group of liveGroups) {
for (const wt of group.worktrees) {
for (const agent of wt.agents) {
livePaneKeys.add(agent.paneKey)
}
}
}
return liveGroups.map((group) => {
// Why: preserve reference identity at the group level when no worktree
// inside it has retained rows. Returning a fresh group/worktrees array
// unconditionally invalidates downstream React.memo across the entire
// tree whenever retainedAgentsByPaneKey changes — even for groups whose
// worktrees are untouched.
let anyChanged = false
const worktrees: DashboardWorktreeCard[] = []
for (const wt of group.worktrees) {
const retainedForWt = byWorktree
.get(wt.worktree.id)
?.filter((ra) => !livePaneKeys.has(ra.entry.paneKey))
if (!retainedForWt?.length) {
worktrees.push(wt)
continue
}
anyChanged = true
const retainedRows: DashboardAgentRow[] = retainedForWt.map(retainedToRow)
// Why: re-sort after merging retained rows ascending by startedAt so
// the list order matches useDashboardData (oldest first, new rows
// append at the bottom) and doesn't reshuffle rows the user is
// currently reading.
const mergedAgents = [...wt.agents, ...retainedRows].sort((a, b) => a.startedAt - b.startedAt)
worktrees.push({
...wt,
agents: mergedAgents,
// Why: share computeDominantState with useDashboardData so the
// dashboard (live-only) and the retained-enriched view apply the
// exact same blocked > working > done > idle priority. Keeping two
// copies risks drift where a priority tweak in one surface silently
// diverges the two — the two surfaces must stay in sync.
dominantState: computeDominantState(mergedAgents),
// Why: earliestStartedAt should anchor to the oldest start across live
// and retained rows — retained entries can be *older* than current
// live agents (they're what's lingering from a prior run), so the
// min keeps the worktree's list position stable as retained rows
// merge in.
earliestStartedAt: Math.min(
wt.earliestStartedAt > 0 ? wt.earliestStartedAt : Number.POSITIVE_INFINITY,
...retainedForWt.map((ra) => ra.startedAt)
)
} satisfies DashboardWorktreeCard)
}
if (!anyChanged) {
return group
}
return { ...group, worktrees } satisfies DashboardRepoGroup
})
}
function retainedToRow(ra: RetainedAgentEntry): DashboardAgentRow {
return {
paneKey: ra.entry.paneKey,
entry: ra.entry,
tab: ra.tab,
agentType: ra.agentType,
state: 'done',
startedAt: ra.startedAt
}
}
export function collectRetainedAgentsOnDisappear(args: {
previousAgents: Map<string, { row: DashboardAgentRow; worktreeId: string }>
currentAgents: Map<string, { row: DashboardAgentRow; worktreeId: string }>

View File

@ -1,340 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import AgentDashboard from '../dashboard/AgentDashboard'
const MIN_HEIGHT = 140
const DEFAULT_HEIGHT = 220
const HEADER_HEIGHT = 28
// Why: leave room for the tab content above the dashboard — used in both the drag max and the layout-effect measure, must stay in sync.
const RESERVED_ABOVE_PX = 160
const STORAGE_KEY = 'orca.dashboardSidebarPanel'
type PersistedState = {
height: number
collapsed: boolean
}
function loadPersistedState(): PersistedState {
if (typeof window === 'undefined') {
return { height: DEFAULT_HEIGHT, collapsed: false }
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) {
return { height: DEFAULT_HEIGHT, collapsed: false }
}
const parsed = JSON.parse(raw) as Partial<PersistedState>
// Why: stale or tampered localStorage can hold NaN, negative, zero, or
// absurdly large heights. The runtime clamp in onResizeMove only fires
// during an active drag, so without validation here the initial render
// can produce a zero-height strip or a panel that eats the whole sidebar
// before the user ever touches the resize handle. We can't clamp the
// upper bound yet (sidebarHeight isn't known until mount), but enforcing
// finite + MIN_HEIGHT eliminates the worst-case visual breakage.
const rawHeight =
typeof parsed.height === 'number' && Number.isFinite(parsed.height)
? parsed.height
: DEFAULT_HEIGHT
return {
height: Math.max(MIN_HEIGHT, rawHeight),
collapsed: typeof parsed.collapsed === 'boolean' ? parsed.collapsed : false
}
} catch {
return { height: DEFAULT_HEIGHT, collapsed: false }
}
}
// Why: a persistent bottom section of the right sidebar that always shows the
// agent dashboard, independent of which activity tab the user has open. The
// user drags the top edge to resize upward and can fully collapse to a
// single header row.
export default function DashboardBottomPanel(): React.JSX.Element {
// Why: read localStorage once per mount; multi-window state diverges intentionally.
const initial = useMemo(loadPersistedState, [])
const [height, setHeight] = useState<number>(initial.height)
const [collapsed, setCollapsed] = useState<boolean>(initial.collapsed)
// Why: tracks the sidebar-derived upper bound used to clamp rendering only.
// We deliberately keep this separate from `height` so the user's persisted
// preference is never overwritten by a transient small-window measurement
// (see the useLayoutEffect below for the full rationale).
const [measuredMaxHeight, setMeasuredMaxHeight] = useState<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const resizeStateRef = useRef<{
startY: number
startHeight: number
maxHeight: number
} | null>(null)
// Why: mirror `height`/`collapsed` into refs so callbacks and the unmount
// flush can read the latest value without being re-created on every change.
// `onResizeStart` previously listed `height` in its deps, which meant every
// mousemove (which calls setHeight) recreated the callback — pure waste on
// the hot drag path. Refs keep the callback identity stable.
const heightRef = useRef(height)
const collapsedRef = useRef(collapsed)
useEffect(() => {
heightRef.current = height
}, [height])
useEffect(() => {
collapsedRef.current = collapsed
}, [collapsed])
// Why: persist height + collapsed via localStorage (renderer-only) so the
// layout survives reloads. Debounce writes so continuous drag doesn't spam.
useEffect(() => {
const timer = window.setTimeout(() => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ height, collapsed }))
} catch {
// ignore quota / privacy-mode errors
}
}, 150)
return () => window.clearTimeout(timer)
}, [height, collapsed])
// Why: the debounced write above clears its pending timeout on every deps
// change AND on unmount — so the user's final drag value is lost if the
// component unmounts within the 150ms debounce window (hot reload, hiding
// the dashboard, closing the window). This separate mount-lifecycle effect
// has empty deps, so its cleanup runs ONLY on true unmount (never on deps
// re-run), and it flushes the latest values synchronously to localStorage.
// Reading via refs ensures we write the final state, not a stale snapshot.
useEffect(() => {
return () => {
try {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ height: heightRef.current, collapsed: collapsedRef.current })
)
} catch {
// ignore quota / privacy-mode errors
}
}
// Why: empty deps are intentional — adding [height, collapsed] would turn
// this unmount-only flush into a per-change write, defeating the debounce.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onResizeMove = useCallback((event: MouseEvent) => {
const state = resizeStateRef.current
if (!state) {
return
}
const deltaY = state.startY - event.clientY
const next = Math.max(MIN_HEIGHT, Math.min(state.maxHeight, state.startHeight + deltaY))
setHeight(next)
}, [])
// Why: keyboard support for the resize separator is required for a11y —
// pointer-only resize locks out keyboard and assistive-tech users. Mirrors
// the clamp logic in onResizeMove (MIN_HEIGHT lower, measuredMaxHeight
// upper) so arrow/Home/End nudges obey the same bounds as mouse drags.
// Step sizes (10px default, 40px with Shift) match common separator-widget
// conventions for coarse vs. fine adjustment.
const onResizeKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? 40 : 10
const upperBound = measuredMaxHeight
const clamp = (value: number): number => {
const lowered = Math.max(MIN_HEIGHT, value)
return upperBound !== null ? Math.min(upperBound, lowered) : lowered
}
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
setHeight((prev) => clamp(prev + step))
break
case 'ArrowDown':
event.preventDefault()
setHeight((prev) => clamp(prev - step))
break
case 'Home':
event.preventDefault()
setHeight(MIN_HEIGHT)
break
case 'End':
if (upperBound !== null) {
event.preventDefault()
setHeight(upperBound)
}
break
default:
break
}
},
[measuredMaxHeight]
)
const onResizeEnd = useCallback(() => {
resizeStateRef.current = null
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('mousemove', onResizeMove)
window.removeEventListener('mouseup', onResizeEnd)
}, [onResizeMove])
const onResizeStart = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault()
// Why: no `collapsed` guard here — the resize handle is only rendered
// when `!collapsed` (see below), so this callback is unreachable while
// collapsed. Keeping a `setCollapsed(false)` branch would be dead code
// and would mislead future readers into thinking the handle can fire
// in the collapsed state.
// Why: cap expansion so the dashboard can't push the active panel
// content to a zero-height strip. Leave 160px for the panel above.
const sidebarEl = containerRef.current?.parentElement
const sidebarHeight = sidebarEl?.getBoundingClientRect().height ?? 800
const maxHeight = Math.max(MIN_HEIGHT, sidebarHeight - RESERVED_ABOVE_PX)
resizeStateRef.current = {
startY: event.clientY,
// Why: use the CLAMPED height, not the raw persisted value. If the
// stored height exceeds the current sidebar max (e.g. reopening in a
// smaller window), the panel renders at `maxHeight` but drag math
// would start from the raw value, so the handle appears unresponsive
// until the cursor travels the difference. Clamping here keeps the
// drag feel 1:1 with what's on screen.
startHeight: Math.min(heightRef.current, maxHeight),
maxHeight
}
document.body.style.cursor = 'row-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onResizeMove)
window.addEventListener('mouseup', onResizeEnd)
},
// Why: `height` intentionally omitted — we read it via `heightRef` so the
// callback identity stays stable during a drag. Including `height` would
// recreate this callback on every mousemove (see heightRef declaration).
[onResizeMove, onResizeEnd]
)
useEffect(() => {
return () => {
window.removeEventListener('mousemove', onResizeMove)
window.removeEventListener('mouseup', onResizeEnd)
// Why: if the component unmounts mid-drag (e.g. user hides the
// dashboard from settings while dragging, or a hot-reload swaps the
// tree), onResizeEnd never fires. Without this restore, document.body
// would stay stuck on `row-resize` with text selection disabled
// app-wide until the next full reload.
if (resizeStateRef.current !== null) {
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}
}, [onResizeMove, onResizeEnd])
// Why: complements the lower-bound clamp in loadPersistedState. At load
// time we can't compute the sidebar's max height because the parent isn't
// measured yet, so a persisted `height` of e.g. 99999 (from a prior absurd
// drag, a browser dimension change, or tampering) would render taller than
// the entire sidebar, pushing the active panel (Explorer/Search/
// SourceControl) to zero height and placing the resize handle off-screen
// where the user can't easily recover.
//
// CRITICAL: we measure to clamp for RENDERING only, and NEVER overwrite
// the persisted `height` preference. An earlier version called
// `setHeight((prev) => Math.min(prev, max))` here, which caused a nasty
// regression: a user who had dragged to 500px in a large window and later
// opened the app in a smaller window would have their stored preference
// silently shrunk (via the debounced localStorage-write effect) to whatever
// `max` happened to be. Resizing the window back up would NOT restore the
// original 500px — the preference was gone. By storing the measurement in
// a separate `measuredMaxHeight` state and clamping only at render time,
// the user's intent survives every window-size change.
//
// useLayoutEffect (not useEffect) runs before first paint to avoid a
// visual flash of an oversized panel. The window-resize listener keeps
// the clamp adaptive when the user shrinks the window after mount; it
// MUST only update `measuredMaxHeight`, never `height`.
useLayoutEffect(() => {
const measure = (): void => {
const sidebarEl = containerRef.current?.parentElement
const sidebarHeight = sidebarEl?.getBoundingClientRect().height
if (typeof sidebarHeight !== 'number' || !Number.isFinite(sidebarHeight)) {
return
}
const max = Math.max(MIN_HEIGHT, sidebarHeight - RESERVED_ABOVE_PX)
setMeasuredMaxHeight(max)
}
measure()
window.addEventListener('resize', measure)
return () => {
window.removeEventListener('resize', measure)
}
}, [])
const effectiveHeight = collapsed
? HEADER_HEIGHT
: measuredMaxHeight !== null
? Math.min(height, measuredMaxHeight)
: height
return (
<div
ref={containerRef}
className="relative flex shrink-0 flex-col border-t border-border bg-sidebar"
style={{ height: effectiveHeight }}
>
{/* Resize handle hidden while collapsed so the user must expand first.
Why: exposed as role="separator" with keyboard support (Arrow keys,
Home/End) so keyboard and assistive-tech users can resize the panel.
Without tabIndex + onKeyDown a mouse-only drag handle is an a11y gap:
sighted keyboard users (and screen-reader users navigating widgets)
would have no way to adjust the split. aria-value* advertises the
current height and its bounds to assistive tech. */}
{!collapsed && (
<div
role="separator"
tabIndex={0}
aria-orientation="horizontal"
// Why: advertise the clamped rendered height, not the raw persisted value — otherwise valuenow can exceed valuemax when the persisted preference is larger than the current sidebar.
aria-valuenow={Math.round(effectiveHeight)}
aria-valuemin={MIN_HEIGHT}
// Why: WAI-ARIA best practice is to always expose a finite range
// alongside aria-valuenow so assistive tech can announce a
// consistent bound. `measuredMaxHeight` is null until the
// useLayoutEffect runs on mount, so fall back to the current
// rendered `height` — it's guaranteed finite and correctly
// represents the max the user can currently observe (valuenow ==
// valuemax pre-measurement), avoiding an omitted upper bound.
aria-valuemax={measuredMaxHeight ?? height}
className="absolute left-0 right-0 z-10 -mt-[3px] h-[6px] cursor-row-resize transition-colors hover:bg-ring/20 active:bg-ring/30"
onMouseDown={onResizeStart}
onKeyDown={onResizeKeyDown}
aria-label="Resize dashboard panel"
/>
)}
{/* Header: title + collapse toggle (click anywhere to toggle).
Why: the entire header is a single <button> rather than a <div>
wrapping a nested <button>. Nesting interactive elements is invalid
HTML and breaks screen readers previously the inner button had no
onClick of its own and relied on click bubbling to the div, so
assistive tech announced a button that appeared to do nothing. */}
<button
type="button"
className="flex w-full shrink-0 select-none items-center gap-1 px-2 text-left"
style={{ height: HEADER_HEIGHT }}
onClick={() => setCollapsed((prev) => !prev)}
aria-expanded={!collapsed}
aria-label={collapsed ? 'Expand dashboard' : 'Collapse dashboard'}
>
<span className="flex h-5 w-5 items-center justify-center text-muted-foreground">
{collapsed ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</span>
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Agents
</span>
</button>
{/* Body: full AgentDashboard */}
{!collapsed && (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<AgentDashboard />
</div>
)}
</div>
)
}

View File

@ -21,7 +21,6 @@ import FileExplorer from './FileExplorer'
import SourceControl from './SourceControl'
import SearchPanel from './Search'
import ChecksPanel from './ChecksPanel'
import DashboardBottomPanel from './DashboardBottomPanel'
import PortsPanel from './PortsPanel'
const MIN_WIDTH = 220
@ -124,16 +123,6 @@ function RightSidebarInner(): React.JSX.Element {
const checksStatus = useAppStore(getActiveChecksStatus)
const activityBarPosition = useAppStore((s) => s.activityBarPosition)
const setActivityBarPosition = useAppStore((s) => s.setActivityBarPosition)
// Why: the bottom-docked agent dashboard is opt-out via Settings → Agents,
// AND gated behind the experimental opt-in setting. Users who prefer a
// quieter sidebar can hide the panel without losing any in-terminal agent
// status — the per-tab status indicators remain. While settings are still
// loading, render the panel so it doesn't flash in once settings arrive.
const showAgentDashboard = useAppStore((s) => s.settings?.showAgentDashboard !== false)
const dashboardExperimentEnabled = useAppStore(
(s) => s.settings?.experimentalAgentDashboard === true
)
// Why: source control and checks are meaningless for non-git folders.
// Hide those tabs so the activity bar only shows relevant actions.
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
@ -245,10 +234,11 @@ function RightSidebarInner(): React.JSX.Element {
that froze the app for seconds on Windows. Each panel now reacts
to activeWorktreeId changes via store subscriptions and reset
effects, keeping the component instance alive across switches. */}
{/* Why: the active tab content takes the top of the sidebar. The agent
dashboard docks at the bottom regardless of which tab is selected,
so users keep a glanceable view of agent status while they browse
files, search, etc. */}
{/* Why: live agent activity now renders inline inside each workspace
card (WorktreeCardAgents, toggled by the 'inline-agents' card
property) rather than in a bottom-docked dashboard panel that
competed with file Explorer/Search for vertical space. The right
sidebar is back to tab-only content. */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{effectiveTab === 'explorer' && <FileExplorer />}
{effectiveTab === 'search' && <SearchPanel />}
@ -256,7 +246,6 @@ function RightSidebarInner(): React.JSX.Element {
{effectiveTab === 'checks' && <ChecksPanel />}
{effectiveTab === 'ports' && <PortsPanel />}
</div>
{dashboardExperimentEnabled && showAgentDashboard && <DashboardBottomPanel />}
</div>
)

View File

@ -246,50 +246,8 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
defaultAgent === null || (defaultAgent !== 'blank' && !detectedIds?.has(defaultAgent))
const isBlankDefault = defaultAgent === 'blank'
// Why: match right-sidebar's `s.settings?.showAgentDashboard !== false` read.
// Persisted settings from older versions may be missing this field — treating
// `undefined` as "on" here keeps the toggle's displayed state in sync with
// the sidebar's actual rendering, so the UI never contradicts itself.
const showDashboard = settings.showAgentDashboard !== false
return (
<div className="space-y-8">
{/* Dashboard visibility */}
{settings.experimentalAgentDashboard === true && (
<section>
<div className="flex items-center justify-between gap-4 px-1 py-2">
<div className="space-y-0.5">
<span id="show-agent-dashboard-label" className="text-sm font-medium">
Show Agent Dashboard
</span>
<p id="show-agent-dashboard-desc" className="text-xs text-muted-foreground">
Show the live agent activity panel docked at the bottom of the right sidebar
surfaces working, blocked, and done agents across all open worktrees.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={showDashboard}
aria-labelledby="show-agent-dashboard-label"
aria-describedby="show-agent-dashboard-desc"
onClick={() => updateSettings({ showAgentDashboard: !showDashboard })}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors',
showDashboard ? 'bg-foreground' : 'bg-muted-foreground/30'
)}
>
<span
className={cn(
'pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform',
showDashboard ? 'translate-x-4' : 'translate-x-0.5'
)}
/>
</button>
</div>
</section>
)}
{/* Default agent picker */}
<section className="space-y-4">
<div className="space-y-1">

View File

@ -13,8 +13,9 @@ import { AGENT_CATALOG, AgentIcon } from '@/lib/agent-catalog'
// Why: agents with a per-agent hook-service module under src/main that posts
// status to the shared agent-hooks server. Keep this list in sync with the
// hook-service.ts files — any agent without one will not light up the
// dashboard even when the experimental setting is on.
// hook-service.ts files — any agent without one will not appear in the inline
// per-workspace-card agent activity list even when the experimental setting
// is on.
const AGENT_DASHBOARD_SUPPORTED_AGENTS: readonly TuiAgent[] = [
'claude',
'codex',
@ -134,15 +135,17 @@ export function ExperimentalPane({
<div className="space-y-4">
{showAgentDashboard ? (
<SearchableSetting
title="Agent dashboard"
description="Live cross-worktree view of agent activity, plus retention of finished runs in the sidebar hover."
title="Detailed agent activity"
description="Shows each agents live status, prompt, and last message inside its workspace card."
keywords={[
'experimental',
'agent',
'dashboard',
'status',
'activity',
'worktree',
'status',
'live',
'workspace',
'card',
'inline',
'hook',
'claude',
'codex',
@ -153,11 +156,11 @@ export function ExperimentalPane({
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-1.5">
<Label>Agent dashboard</Label>
<Label>Detailed agent activity</Label>
<p className="text-xs text-muted-foreground">
Adds a cross-worktree dashboard and hover cards showing each agent&apos;s live
status. Requires an app restart, and tracks agents started in new terminals opened
after the restart.
Shows each agent&apos;s live status, current prompt, and last message inline inside
its workspace card. Requires an app restart, and tracks agents started in new
terminals opened after the restart.
</p>
<SupportedAgentsDisclaimer />
</div>
@ -190,8 +193,8 @@ export function ExperimentalPane({
</p>
<p className="text-xs text-muted-foreground">
{settings.experimentalAgentDashboard
? 'Restart Orca to finish enabling the agent dashboard.'
: 'Restart Orca to finish disabling the agent dashboard.'}
? 'Restart Orca to finish enabling detailed agent activity.'
: 'Restart Orca to finish disabling detailed agent activity.'}
</p>
</div>
<Button

View File

@ -2,16 +2,18 @@ import type { SettingsSearchEntry } from './settings-search'
export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Agent dashboard',
title: 'Detailed agent activity',
description:
'Live cross-worktree view of agent activity, plus retention of finished runs in the sidebar hover. Experimental — managed hook installs require an app restart.',
'Shows each agents live status, prompt, and last message inside its workspace card. Experimental — managed hook installs require an app restart.',
keywords: [
'experimental',
'agent',
'dashboard',
'status',
'activity',
'worktree',
'status',
'live',
'workspace',
'card',
'inline',
'hook',
'claude',
'codex',

View File

@ -1,345 +0,0 @@
import React, { useCallback, useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'
import { useAppStore } from '@/store'
import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
import type { DashboardAgentRow as DashboardAgentRowType } from '@/components/dashboard/useDashboardData'
import { useNow } from '@/components/dashboard/useNow'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import type { TerminalTab } from '../../../../shared/types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
type AgentStatusHoverProps = {
worktreeId: string
children: React.ReactNode
}
// Why: stable empty-array references so narrow selectors return the same
// reference when there's nothing for this worktree. Without stable empties,
// zustand's shallow equality would see a new `[]` every render and trigger
// unnecessary re-renders — defeating the purpose of the narrow selector.
const EMPTY_TABS: TerminalTab[] = []
const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = []
const EMPTY_RETAINED: RetainedAgentEntry[] = []
// Why: the hovercard must render the exact same information the per-worktree
// dashboard card shows — hook-reported agents plus any retained "done"
// snapshots. We intentionally do NOT call useDashboardData() +
// enrichGroupsWithRetained() here, even though that would centralize the row-
// building logic. AgentStatusHover wraps every WorktreeCard, so reusing the
// full dashboard pipeline would mean every agent-status event recomputes the
// entire repo × worktree × tabs × agentStatus aggregation once per card on
// screen — O(worktrees²) work per update (render amplification). Instead we
// read the store's primitive maps via narrow selectors and do a focused
// per-worktree scan that mirrors buildAgentRowsForWorktree in
// useDashboardData.ts and the retained-row merge in useRetainedAgents.ts.
// Retention state itself is still hoisted into the store (see
// useRetainedAgentsSync wired at App level), so dismissing in the hover
// reflects in the dashboard and vice versa.
const AgentStatusHover = React.memo(function AgentStatusHover({
worktreeId,
children
}: AgentStatusHoverProps) {
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId])
// Why: narrow the store subscriptions to only THIS worktree's entries via
// useShallow. AgentStatusHover wraps every WorktreeCard, so subscribing to
// the whole agentStatusByPaneKey/retainedAgentsByPaneKey map would make every
// on-screen hovercard re-render on any agent-status update anywhere —
// O(worktrees²) render amplification. Pre-filtering here means the card only
// re-renders when something relevant to THIS worktree changes.
const entries = useAppStore(
useShallow((s) => {
const wtTabs = s.tabsByWorktree[worktreeId] ?? EMPTY_TABS
if (wtTabs.length === 0) {
return EMPTY_LIVE_ENTRIES
}
const tabIds = new Set(wtTabs.map((t) => t.id))
const out: AgentStatusEntry[] = []
for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
const sepIdx = paneKey.indexOf(':')
if (sepIdx <= 0) {
continue
}
const tabId = paneKey.slice(0, sepIdx)
if (!tabIds.has(tabId)) {
continue
}
out.push(entry)
}
return out.length > 0 ? out : EMPTY_LIVE_ENTRIES
})
)
const retained = useAppStore(
useShallow((s) => {
const out: RetainedAgentEntry[] = []
for (const ra of Object.values(s.retainedAgentsByPaneKey)) {
if (ra.worktreeId === worktreeId) {
out.push(ra)
}
}
return out.length > 0 ? out : EMPTY_RETAINED
})
)
// Why: agentStatusEpoch is included in the dependency array (but not in the
// computation itself) so the memo recomputes when freshness boundaries
// expire, even if no new PTY data arrives — same rationale as
// useDashboardData.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
const dropAgentStatus = useAppStore((s) => s.dropAgentStatus)
const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveTab = useAppStore((s) => s.setActiveTab)
const setActiveView = useAppStore((s) => s.setActiveView)
const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents)
const agents = useMemo<DashboardAgentRowType[]>(() => {
const rows: DashboardAgentRowType[] = []
const seenPaneKeys = new Set<string>()
// Why: Date.now() is read inside the memo (not as a dep) so stale-decay
// recalculates whenever agentStatusEpoch ticks — same pattern as
// useDashboardData.
const now = Date.now()
// Why: build a tabId -> entries index once instead of re-scanning every
// agent status entry inside the per-tab loop. paneKey is formatted as
// `${tabId}:${paneId}`; splitting on the first ':' lets us bucket entries
// by tab in a single O(N) pass, turning the per-worktree build from
// O(tabs × statuses) into O(tabs + statuses). Mirrors the same index
// built in useDashboardData.buildDashboardData. `entries` is already
// pre-filtered to this worktree by the narrow selector above, so this is
// O(M) where M is this-worktree-entries, not the global map.
const entriesByTabId = new Map<string, AgentStatusEntry[]>()
for (const entry of entries) {
const colonIndex = entry.paneKey.indexOf(':')
if (colonIndex === -1) {
continue
}
const tabId = entry.paneKey.slice(0, colonIndex)
const bucket = entriesByTabId.get(tabId)
if (bucket) {
bucket.push(entry)
} else {
entriesByTabId.set(tabId, [entry])
}
}
// Live rows — mirror buildAgentRowsForWorktree in useDashboardData.ts.
const worktreeTabs = tabs ?? []
for (const tab of worktreeTabs) {
const explicitEntries = entriesByTabId.get(tab.id) ?? []
for (const entry of explicitEntries) {
// Why: decay stale working/blocked/waiting entries to 'idle' when the
// hook stream has gone silent past AGENT_STATUS_STALE_AFTER_MS. Without
// this, an agent that exited without a final update would keep the
// hover's "Running agents" count and the dashboard filters inflated
// with dead work. `done` is terminal and must NOT decay to idle —
// retention (collectRetainedAgentsOnDisappear) only keeps rows whose
// prev state was 'done', so a stale done → idle would silently drop
// the completion signal. Mirrors useDashboardData.buildAgentRowsForWorktree.
const isFresh = isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
const shouldDecay =
!isFresh &&
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
rows.push({
paneKey: entry.paneKey,
entry,
tab,
agentType: entry.agentType ?? 'unknown',
state: shouldDecay ? 'idle' : entry.state,
// Why: the oldest stateHistory entry's startedAt is the agent's
// original "first seen" timestamp. When history is empty the entry
// has never transitioned state, so stateStartedAt (the moment the
// current — and only — state began) is the true first-seen
// timestamp. Do NOT fall back to updatedAt: it advances on every
// tool/prompt ping within the same state, which would corrupt
// oldest-first ordering and the "started … ago" display for
// long-running agents between state transitions. Matches
// useDashboardData's semantics exactly.
startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
})
seenPaneKeys.add(entry.paneKey)
}
}
// Retained rows — mirror enrichGroupsWithRetained: add a retained snapshot
// only if it belongs to THIS worktree and no live row already occupies its
// paneKey. `retained` is already pre-filtered to this worktree by the
// narrow selector above.
for (const ra of retained) {
if (seenPaneKeys.has(ra.entry.paneKey)) {
continue
}
rows.push({
paneKey: ra.entry.paneKey,
entry: ra.entry,
tab: ra.tab,
agentType: ra.agentType,
state: 'done',
startedAt: ra.startedAt
})
}
// Why: sort oldest-first to match useDashboardData ordering — stable list
// order keeps new agents from shoving the row the user is reading.
rows.sort((a, b) => a.startedAt - b.startedAt)
return rows
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabs, entries, retained, worktreeId, agentStatusEpoch])
// Why: mirror AgentDashboard.handleDismissAgent so dismissing in either
// surface has identical effect — removes the live store entry and the
// retained snapshot if either is present.
const handleDismissAgent = useCallback(
(paneKey: string) => {
dropAgentStatus(paneKey)
dismissRetainedAgent(paneKey)
},
[dropAgentStatus, dismissRetainedAgent]
)
// Why: clicking a row activates the specific tab the agent runs in. Retained
// rows can outlive their tab, so fall back to worktree-only activation when
// the tab is no longer present.
// Why: symmetric with the dashboard's handleActivateAgent — clicking a row
// through the sidebar hovercard should fade it to the visited weight
// immediately, instead of waiting for useAutoAckViewedAgent to catch up once
// the tab becomes active. Scoped to the single clicked paneKey so sibling
// rows remain bold.
const handleActivateAgentTab = useCallback(
(tabId: string, paneKey: string) => {
setActiveWorktree(worktreeId)
setActiveView('terminal')
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
if (tabs.some((t) => t.id === tabId)) {
setActiveTab(tabId)
}
acknowledgeAgents([paneKey])
},
[worktreeId, setActiveWorktree, setActiveTab, setActiveView, acknowledgeAgents]
)
return (
<HoverCard openDelay={300}>
<HoverCardTrigger asChild>{children}</HoverCardTrigger>
{/* Why: the shared HoverCard uses `border-border/50`, but `--border`
already carries very different alpha per theme (#e5e5e5 opaque in
light, rgb(255 255 255 / 0.07) in dark). At /50 the dark-mode edge
collapses to ~3% alpha and the card looks borderless. Override to
explicit light/dark tokens so the card outline reads the same in
both modes. */}
{/* Why: cap the card to the viewport and let its body scroll. When a row
is expanded (tool input, prompt, or assistant message unfurled), the
content can exceed the sidebar's vertical space; without a bounded
card the hover overflows off-screen with no way to reach the rows
below. `max-h-[85vh]` + `flex flex-col` keeps the card within the
viewport, and the inner list below owns the scroll so the "Agent
activity (N)" header stays pinned. */}
<HoverCardContent
side="right"
align="start"
className="flex w-72 max-h-[85vh] flex-col border-neutral-200 bg-popover p-3 text-xs dark:border-white/10"
>
<AgentStatusHoverContent
agents={agents}
onDismiss={handleDismissAgent}
onActivate={handleActivateAgentTab}
/>
</HoverCardContent>
</HoverCard>
)
})
type AgentStatusHoverContentProps = {
agents: DashboardAgentRowType[]
onDismiss: (paneKey: string) => void
onActivate: (tabId: string, paneKey: string) => void
}
// Why: split out so `useNow(30_000)` only runs while the hovercard body is
// actually mounted. AgentStatusHover wraps EVERY WorktreeCard in the sidebar
// and stays mounted regardless of whether the card is open, so placing the
// timer on the outer component would run one 30s interval per visible
// worktree for the entire session — strictly worse than pre-hoist, since the
// common path is that the user never opens the hovercard. HoverCardContent is
// portaled by Radix and only mounts while open, so rendering this child there
// naturally gates the timer: 0 intervals while closed, exactly 1 per open
// card. The outer component still owns the narrow store subscriptions and the
// `agents` memo so those don't re-run on every open/close, and to preserve
// the render-amplification protection that originally motivated the narrow
// selectors.
const AgentStatusHoverContent = React.memo(function AgentStatusHoverContent({
agents,
onDismiss,
onActivate
}: AgentStatusHoverContentProps) {
// Why: own one 30s tick per OPEN hovercard instance and thread it to every
// row we render. Previously each DashboardAgentRow ran its own setInterval,
// so an N-row hovercard fired N staggered re-renders every cycle. Scoping
// this to the inner content (which only mounts while the card is open)
// keeps the overhead bounded to the card the user is actually looking at.
const now = useNow(30_000)
// Why: mirrors DashboardWorktreeCard's isAgentUnvisited rule so the
// hovercard's weight signal stays consistent with the dashboard. Without
// this, previously-bold attention-needed states (done/waiting/blocked)
// render muted because DashboardAgentRow's weight is now driven exclusively
// by isUnvisited.
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
const paneKeys = useMemo(() => agents.map((a) => a.paneKey), [agents])
const ackByPaneKey = useMemo(() => {
const out: Record<string, number> = {}
for (const paneKey of paneKeys) {
out[paneKey] = acknowledgedAgentsByPaneKey[paneKey] ?? 0
}
return out
}, [paneKeys, acknowledgedAgentsByPaneKey])
const isAgentUnvisited = useCallback(
(paneKey: string, stateStartedAt: number) => {
const ackAt = ackByPaneKey[paneKey] ?? 0
return ackAt < stateStartedAt
},
[ackByPaneKey]
)
if (agents.length === 0) {
return <div className="py-1 text-center text-muted-foreground">No agent activity</div>
}
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* Why: "Agent activity" rather than "Running agents" the list
now includes retained 'done' snapshots and stale-decayed 'idle'
rows alongside live working/blocked/waiting agents, so
"running" would be semantically inaccurate. */}
<div className="mb-1 shrink-0 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
Agent activity ({agents.length})
</div>
{/* Why: same reason as the card border above `divide-border/60`
on dark `--border` (0.07 alpha) evaluates to ~4% alpha and
the row separators disappear. Pin explicit light/dark tokens
so the dividers stay legible in either mode.
Why scroll here (and not on HoverCardContent): keeping the header
pinned above a scrolling list preserves the row count as context
when one row is expanded and pushes the rest below the fold. */}
<div className="flex min-h-0 flex-1 flex-col divide-y divide-neutral-200 overflow-y-auto dark:divide-white/10">
{agents.map((agent) => (
<div key={agent.paneKey} className="py-1">
<DashboardAgentRow
agent={agent}
onDismiss={onDismiss}
onActivate={onActivate}
now={now}
isUnvisited={isAgentUnvisited(agent.paneKey, agent.entry.stateStartedAt)}
/>
</div>
))}
</div>
</div>
)
})
export default AgentStatusHover

View File

@ -73,7 +73,7 @@ const SearchBar = React.memo(function SearchBar() {
})()
return (
<div className="px-2 pb-4">
<div className="px-2 pb-1">
<div className="relative flex items-center">
<Search
className="absolute left-2.5 size-3.5 text-muted-foreground pointer-events-none"

View File

@ -29,7 +29,11 @@ const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [
{ id: 'ci', label: 'CI checks' },
{ id: 'issue', label: 'Linked issue' },
{ id: 'pr', label: 'Linked PR' },
{ id: 'comment', label: 'Comment' }
{ id: 'comment', label: 'Comment' },
// Why: toggles the inline "Agent activity" list rendered below each
// workspace card body (see WorktreeCard → WorktreeCardAgents). Off hides
// the list; there is no alternate surface.
{ id: 'inline-agents', label: 'Agent activity' }
]
const SORT_OPTIONS = [
@ -53,10 +57,18 @@ const SidebarHeader = React.memo(function SidebarHeader() {
const setSortBy = useAppStore((s) => s.setSortBy)
const groupBy = useAppStore((s) => s.groupBy)
const setGroupBy = useAppStore((s) => s.setGroupBy)
// Why: hide the 'Agents in card' checkbox entirely when the experimental
// live-agent-activity feature is off — toggling it is a no-op otherwise
// (WorktreeCard gates rendering on the same flag), so surfacing a dead
// checkbox is just misleading chrome.
const liveAgentsEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true)
const visiblePropertyOptions = liveAgentsEnabled
? PROPERTY_OPTIONS
: PROPERTY_OPTIONS.filter((opt) => opt.id !== 'inline-agents')
return (
<div className="flex h-8 items-center justify-between px-4 mt-1">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
<div className="flex h-8 items-center justify-between px-2 mt-1 gap-2">
<span className="px-2 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
Workspaces
</span>
<div className="flex items-center gap-1.5 shrink-0">
@ -126,7 +138,7 @@ const SidebarHeader = React.memo(function SidebarHeader() {
<DropdownMenuSeparator />
<DropdownMenuLabel>Show properties</DropdownMenuLabel>
{PROPERTY_OPTIONS.map((opt) => (
{visiblePropertyOptions.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.id}
checked={worktreeCardProperties.includes(opt.id)}

View File

@ -9,7 +9,7 @@ import StatusIndicator from './StatusIndicator'
import CacheTimer from './CacheTimer'
import WorktreeContextMenu from './WorktreeContextMenu'
import { SshDisconnectedDialog } from './SshDisconnectedDialog'
import AgentStatusHover from './AgentStatusHover'
import WorktreeCardAgents from './WorktreeCardAgents'
import { cn } from '@/lib/utils'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import {
@ -323,15 +323,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
const unreadTooltip = worktree.isUnread ? 'Mark read' : 'Mark unread'
// Why: the whole card is the hover target for the agent-status panel, not
// just the dot. Hovering any part of the workspace row reveals the panel
// to the right (HoverCardContent inside AgentStatusHover uses
// side="right"). A dot-sized target was too easy to miss; the card-level
// trigger preserves the dot as an at-a-glance cue while giving users a
// much larger surface to surface the "agent activity" detail. Gated by
// dashboardExperimentEnabled AND cardProps.includes('status') to match
// the previous scope — when the status dot is hidden, the hover panel
// stays hidden too.
const cardBody = (
<div
className={cn(
@ -374,12 +365,6 @@ const WorktreeCard = React.memo(function WorktreeCard({
{/* Status indicator on the left */}
{(cardProps.includes('status') || cardProps.includes('unread')) && (
<div className="flex flex-col items-center justify-start pt-[2px] gap-2 shrink-0">
{/* Why: the agent-status hovercard is now attached to the whole
card (see AgentStatusHover wrapper below), not just the dot,
so the dot renders as a plain visual indicator. A dot-sized
hover target was too easy to miss; hovering any part of the
card is a far more forgiving way to reveal the "agent
activity" panel that appears to the right of the card. */}
{cardProps.includes('status') && (
<>
<StatusIndicator status={status} aria-hidden="true" />
@ -544,19 +529,23 @@ const WorktreeCard = React.memo(function WorktreeCard({
)}
</div>
)}
{/* Why: inline agent list. Gated on the experimental setting so
managed hook data is only surfaced where the cockpit is enabled,
and on the 'inline-agents' card property so users can hide it.
Layout coupling: this block grows the card height dynamically
WorktreeList uses measureElement on each row, so the virtualizer
re-measures naturally when agents appear/disappear. */}
{dashboardExperimentEnabled && cardProps.includes('inline-agents') && (
<WorktreeCardAgents worktreeId={worktree.id} />
)}
</div>
</div>
)
return (
<>
<WorktreeContextMenu worktree={worktree}>
{dashboardExperimentEnabled && cardProps.includes('status') ? (
<AgentStatusHover worktreeId={worktree.id}>{cardBody}</AgentStatusHover>
) : (
cardBody
)}
</WorktreeContextMenu>
<WorktreeContextMenu worktree={worktree}>{cardBody}</WorktreeContextMenu>
{repo?.connectionId && (
<SshDisconnectedDialog

View File

@ -0,0 +1,166 @@
import React, { useCallback, useMemo } from 'react'
import { ChevronDown } from 'lucide-react'
import { useAppStore } from '@/store'
import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow'
import { useNow } from '@/components/dashboard/useNow'
import { useWorktreeAgentRows } from './useWorktreeAgentRows'
import { cn } from '@/lib/utils'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
type Props = {
worktreeId: string
/** Controls spacing from the card body above. Passed in so the parent can
* decide whether a divider is appropriate e.g. suppressed when the card
* chrome already provides visual separation. */
className?: string
}
/**
* Inline agent list rendered directly inside WorktreeCard when the
* 'inline-agents' card property is enabled. Gives persistent per-card
* visibility of each agent's live state, prompt, and last message.
*
* Reuses useWorktreeAgentRows + DashboardAgentRow so row layout and the
* derivation stay consistent with the inline agent activity on each card.
*/
const WorktreeCardAgents = React.memo(function WorktreeCardAgents({
worktreeId,
className
}: Props) {
const agents = useWorktreeAgentRows(worktreeId)
if (agents.length === 0) {
return null
}
// Why: gate the 30s tick behind non-empty rows by mounting the inner body
// only when there's something to show. The setInterval lives in the inner
// component's useNow, so idle worktrees don't pay per-card timer cost.
return <WorktreeCardAgentsBody worktreeId={worktreeId} agents={agents} className={className} />
})
type BodyProps = {
worktreeId: string
agents: DashboardAgentRowData[]
className?: string
}
const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
worktreeId,
agents,
className
}: BodyProps) {
const dropAgentStatus = useAppStore((s) => s.dropAgentStatus)
const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const setActiveTab = useAppStore((s) => s.setActiveTab)
const setActiveView = useAppStore((s) => s.setActiveView)
const acknowledgeAgents = useAppStore((s) => s.acknowledgeAgents)
// Why: per-worktree collapse is session-only UI state. Single-primitive
// subscription so the card only re-renders when THIS worktree's collapsed
// flag flips — not on any other worktree's toggle.
const isCollapsed = useAppStore((s) => s.collapsedInlineAgentsByWorktreeId[worktreeId] === true)
const toggleInlineAgentsCollapsed = useAppStore((s) => s.toggleInlineAgentsCollapsed)
// Why: subscribe to the ack map reference (Object.is equality) and derive
// per-agent unvisited flags locally. Keeps the inline list's bold/mute
// behavior consistent with how acks flow elsewhere — rows bold on first
// appearance and mute once the user has visited the agent's tab
// (useAutoAckViewedAgent acks automatically on terminal focus). Without
// this, all inline rows stayed muted regardless of attention state.
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
const unvisitedByPaneKey = useMemo(() => {
const out: Record<string, boolean> = {}
for (const a of agents) {
const ackAt = acknowledgedAgentsByPaneKey[a.paneKey] ?? 0
out[a.paneKey] = ackAt < a.entry.stateStartedAt
}
return out
}, [agents, acknowledgedAgentsByPaneKey])
const handleDismissAgent = useCallback(
(paneKey: string) => {
dropAgentStatus(paneKey)
dismissRetainedAgent(paneKey)
},
[dropAgentStatus, dismissRetainedAgent]
)
const handleActivateAgentTab = useCallback(
(tabId: string, paneKey: string) => {
acknowledgeAgents([paneKey])
setActiveWorktree(worktreeId)
setActiveView('terminal')
const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? []
if (tabs.some((t) => t.id === tabId)) {
setActiveTab(tabId)
}
},
[worktreeId, setActiveWorktree, setActiveTab, setActiveView, acknowledgeAgents]
)
const handleToggleCollapsed = useCallback(
(e: React.MouseEvent) => {
// Why: the header is inside WorktreeCard, whose outer click handler
// activates the worktree. Stop propagation so expanding/collapsing the
// list doesn't also navigate away — the user's intent is clearly the
// toggle, not a worktree switch.
e.stopPropagation()
toggleInlineAgentsCollapsed(worktreeId)
},
[toggleInlineAgentsCollapsed, worktreeId]
)
// Why: own one 30s tick per non-empty inline list. Cards with zero agents
// never mount this component (see WorktreeCardAgents), so idle worktrees
// don't pay any timer cost.
const now = useNow(30_000)
return (
<div
className={cn('flex flex-col mt-1', className)}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{/* Why: clickable header toggles the section open/closed. Using a real
<button> keeps keyboard + a11y semantics correct (Enter/Space
activate, proper focus ring, aria-expanded for screen readers). */}
<button
type="button"
onClick={handleToggleCollapsed}
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? 'Expand agent activity' : 'Collapse agent activity'}
className="flex items-center gap-1 mb-0.5 px-0.5 text-[9px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<ChevronDown
className={cn('size-2.5 transition-transform duration-150', isCollapsed && '-rotate-90')}
/>
<span>Agents ({agents.length})</span>
</button>
{!isCollapsed && (
<div className="flex flex-col divide-y divide-border/30">
{agents.map((agent) => (
<div key={agent.paneKey} className="py-0.5">
<DashboardAgentRow
agent={agent}
onDismiss={handleDismissAgent}
onActivate={handleActivateAgentTab}
now={now}
// Why: bold an agent row until the user has visited its tab.
// useAutoAckViewedAgent acks automatically when the user
// focuses the agent's tab, which mutes the row in lockstep.
isUnvisited={unvisitedByPaneKey[agent.paneKey] ?? false}
// Why: inline rows pack tighter than a full-panel layout;
// 'md' reads as a second ~12px glyph users confuse with the
// agent identity icon right next to it. 'sm' keeps the two
// distinguishable at a glance.
stateDotSize="sm"
/>
</div>
))}
</div>
)}
</div>
)
})
export default WorktreeCardAgents

View File

@ -566,7 +566,7 @@ const WorktreeList = React.memo(function WorktreeList() {
// O(N × E × T) per sortEpoch bump. Only smart mode uses the score map;
// other modes ignore it.
// Why: smart-sort only weighs live agent status when the experimental
// Agent Dashboard is opted in — that's the surface that populates
// agent-activity feature is opted in — that's what populates
// agentStatusByPaneKey via hooks. With the setting off, pass undefined
// so the comparator falls back to the persisted-sortOrder + title
// heuristics instead of scoring against an empty map.

View File

@ -52,7 +52,6 @@ function Sidebar(): React.JSX.Element {
<SidebarHeader />
<SearchBar />
{/* Virtualized scrollable list */}
<WorktreeList />
{/* Fixed bottom toolbar */}

View File

@ -0,0 +1,152 @@
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
import type { RetainedAgentEntry } from '@/store/slices/agent-status'
import type { TerminalTab } from '../../../../shared/types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry
} from '../../../../shared/agent-status-types'
// Why: stable empty-array references so narrow selectors return the same
// reference when there's nothing for this worktree. Without stable empties,
// zustand's shallow equality would see a new `[]` every render and trigger
// unnecessary re-renders — defeating the purpose of the narrow selector.
const EMPTY_TABS: TerminalTab[] = []
const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = []
const EMPTY_RETAINED: RetainedAgentEntry[] = []
// Why: stable empty-array reference returned when the experimental
// feature is off, so reference equality across ticks prevents
// downstream re-renders on flag-disabled runs.
const EMPTY_ROWS: DashboardAgentRow[] = []
/**
* Narrow per-worktree agent row hook used by the WorktreeCard inline agents
* list. Produces live hook-reported agents plus retained "done" snapshots,
* stale-decayed to 'idle' when the hook stream has gone quiet.
*
* Uses per-worktree selectors rather than reusing useDashboardData's
* cross-worktree aggregate that pipeline is O(repos × worktrees × agents)
* and would recompute once per sidebar card on every agent-status event.
* Scoped selectors keep the cost O(this-worktree-entries) per card.
*/
export function useWorktreeAgentRows(worktreeId: string): DashboardAgentRow[] {
const dashboardEnabled = useAppStore((s) => s.settings?.experimentalAgentDashboard === true)
const tabs = useAppStore((s) => s.tabsByWorktree[worktreeId])
// Why: narrow the subscriptions to only THIS worktree's entries via
// useShallow. Subscribing to the whole agentStatusByPaneKey map would make
// every on-screen card re-render on any agent-status update anywhere —
// O(worktrees²) render amplification. Pre-filtering here means the card
// only re-renders when something relevant to THIS worktree changes.
const entries = useAppStore(
useShallow((s) => {
const wtTabs = s.tabsByWorktree[worktreeId] ?? EMPTY_TABS
if (wtTabs.length === 0) {
return EMPTY_LIVE_ENTRIES
}
const tabIds = new Set(wtTabs.map((t) => t.id))
const out: AgentStatusEntry[] = []
for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) {
const sepIdx = paneKey.indexOf(':')
if (sepIdx <= 0) {
continue
}
const tabId = paneKey.slice(0, sepIdx)
if (!tabIds.has(tabId)) {
continue
}
out.push(entry)
}
return out.length > 0 ? out : EMPTY_LIVE_ENTRIES
})
)
const retained = useAppStore(
useShallow((s) => {
const out: RetainedAgentEntry[] = []
for (const ra of Object.values(s.retainedAgentsByPaneKey)) {
if (ra.worktreeId === worktreeId) {
out.push(ra)
}
}
return out.length > 0 ? out : EMPTY_RETAINED
})
)
// Why: agentStatusEpoch is included in the dependency array (but not in the
// computation itself) so the memo recomputes when freshness boundaries
// expire, even if no new PTY data arrives — same rationale as
// useDashboardData.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
return useMemo<DashboardAgentRow[]>(() => {
// Why: belt-and-suspenders gate. The only current caller
// (WorktreeCardAgents inside WorktreeCard) already gates on the
// experimental flag, but keeping the check here prevents a future
// caller from silently leaking per-worktree agent-status
// subscriptions to users who have the feature off.
if (!dashboardEnabled) {
return EMPTY_ROWS
}
const rows: DashboardAgentRow[] = []
const seenPaneKeys = new Set<string>()
// Why: Date.now() is read inside the memo (not as a dep) so stale-decay
// recalculates whenever agentStatusEpoch ticks — same pattern as
// useDashboardData.
const now = Date.now()
const entriesByTabId = new Map<string, AgentStatusEntry[]>()
for (const entry of entries) {
const colonIndex = entry.paneKey.indexOf(':')
if (colonIndex === -1) {
continue
}
const tabId = entry.paneKey.slice(0, colonIndex)
const bucket = entriesByTabId.get(tabId)
if (bucket) {
bucket.push(entry)
} else {
entriesByTabId.set(tabId, [entry])
}
}
const worktreeTabs = tabs ?? []
for (const tab of worktreeTabs) {
const explicitEntries = entriesByTabId.get(tab.id) ?? []
for (const entry of explicitEntries) {
const isFresh = isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)
const shouldDecay =
!isFresh &&
(entry.state === 'working' || entry.state === 'blocked' || entry.state === 'waiting')
rows.push({
paneKey: entry.paneKey,
entry,
tab,
agentType: entry.agentType ?? 'unknown',
state: shouldDecay ? 'idle' : entry.state,
startedAt: entry.stateHistory[0]?.startedAt ?? entry.stateStartedAt
})
seenPaneKeys.add(entry.paneKey)
}
}
for (const ra of retained) {
if (seenPaneKeys.has(ra.entry.paneKey)) {
continue
}
rows.push({
paneKey: ra.entry.paneKey,
entry: ra.entry,
tab: ra.tab,
agentType: ra.agentType,
state: 'done',
startedAt: ra.startedAt
})
}
rows.sort((a, b) => a.startedAt - b.startedAt)
return rows
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardEnabled, tabs, entries, retained, worktreeId, agentStatusEpoch])
}

View File

@ -119,9 +119,9 @@ export function getVisibleWorktreeIds(): string[] {
let sortedIds: string[]
// Why: matches WorktreeList's gate — when the experimental Agent Dashboard
// is off, the agent-status map is not populated, so fall back to the
// non-status sort heuristics instead of scoring against an empty map.
// Why: matches WorktreeList's gate — when the experimental agent-activity
// feature is off, the agent-status map is not populated, so fall back to
// the non-status sort heuristics instead of scoring against an empty map.
const agentStatusForSort =
state.settings?.experimentalAgentDashboard === true ? state.agentStatusByPaneKey : undefined
if (state.sortBy === 'smart') {

View File

@ -65,12 +65,13 @@ export function useAutoAckViewedAgent(): void {
return
}
// Why: mirror the dashboard's visibility gate — if the experimental
// agent dashboard is off, nothing in the UI reads the ack map, so
// accumulating entries for unseen agents is wasted memory and the
// Object.entries scan below is pure overhead. The subscribe callback
// fires on any store change, so flipping the setting naturally
// re-evaluates this guard without a separate subscription.
// Why: mirror the inline agents' visibility gate — when the
// experimental agent-activity feature is off, nothing in the UI reads
// the ack map, so accumulating entries for unseen agents is wasted
// memory and the Object.entries scan below is pure overhead. The
// subscribe callback fires on any store change, so flipping the
// setting naturally re-evaluates this guard without a separate
// subscription.
if (s.settings?.experimentalAgentDashboard !== true) {
return
}
@ -80,10 +81,10 @@ export function useAutoAckViewedAgent(): void {
}
// Why: the auto-ack represents "the user saw this row" — but tab-active is
// only a proxy. If the OS window is hidden, minimized, or another app has
// focus, the user is demonstrably not looking at the dashboard even with the
// terminal tab set. Without this gate, an agent finishing while the user is
// away silently clears the bold-until-viewed signal and the user returns to
// a dashboard with no indication anything transitioned.
// focus, the user is demonstrably not looking at the inline agents list
// even with the terminal tab set. Without this gate, an agent finishing
// while the user is away silently clears the bold-until-viewed signal and
// the user returns to a card with no indication anything transitioned.
if (typeof document !== 'undefined') {
if (document.visibilityState !== 'visible') {
return
@ -100,8 +101,8 @@ export function useAutoAckViewedAgent(): void {
// or feature gate caused an early return, we must leave the refs stale so
// the next call (e.g. triggered by the focus listener on return) sees a
// diff and actually runs the scan. Updating refs before the gates would
// consume the diff silently and leave the user returning to a dashboard
// whose bold-until-viewed rows stay bold until some unrelated store change
// consume the diff silently and leave the user returning to cards whose
// bold-until-viewed rows stay bold until some unrelated store change
// happens to bump the refs again.
lastActiveView = s.activeView
lastActiveTabId = s.activeTabId
@ -119,7 +120,7 @@ export function useAutoAckViewedAgent(): void {
// within the same state don't re-trigger ack work on every event —
// acknowledgeAgents short-circuits anyway when the value is
// unchanged, but keeping the comparison in sync with the
// "is-unvisited" rule in DashboardWorktreeCard avoids a stutter
// "is-unvisited" rule in WorktreeCardAgents avoids a stutter
// where we ack on an updatedAt-bump that didn't cross a state
// transition.
if (ackAt < entry.stateStartedAt) {

View File

@ -107,7 +107,7 @@ describe('acknowledgedAgentsByPaneKey cleanup on teardown', () => {
const newEntry = store.getState().agentStatusByPaneKey['tab-1:0']
const ackAt = store.getState().acknowledgedAgentsByPaneKey['tab-1:0'] ?? 0
// Why: the unvisited rule (DashboardWorktreeCard's isAgentUnvisited) is
// Why: the unvisited rule (WorktreeCardAgents' unvisitedByPaneKey) is
// `ackAt < stateStartedAt`. A leaked session-1 ack would still be
// greater than the second paneKey's fresh stateStartedAt only by
// accident of wall-clock ordering, but more robustly: after cleanup,

View File

@ -405,7 +405,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
// Why: mirrors removeAgentStatus — dropping a live working/blocked
// agent changes its contribution to the worktree sort score, so the
// sidebar smart-sort must recompute. Without this bump, a user-
// initiated dismissal via the dashboard/hovercard would leave the
// initiated dismissal from the inline agents list would leave the
// sidebar ordering stale until some unrelated event repaired it.
sortEpoch: hasLive ? s.sortEpoch + 1 : s.sortEpoch
}
@ -505,7 +505,7 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
set((s) => {
// Why: skip the allocation + set(...) entirely when every input entry
// is already present by reference. Consumers of retainedAgentsByPaneKey
// select on its identity (dashboard + hovercard), so a spurious map
// select on its identity (the inline agents list), so a spurious map
// reallocation forces re-renders even when nothing changed. Mirrors
// the identity-preservation pattern used by pruneRetainedAgents and
// clearRetentionSuppressedPaneKeys.

View File

@ -528,7 +528,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
// Why: sweep live AND retained agent-status entries for this tab — closing
// the tab is the user telling us "I'm done with this session", so any
// completion snapshots it left behind (in the sidebar/hovercard) must go
// completion snapshots it left behind (in the inline agents list) must go
// too. Use dropAgentStatusByTabPrefix (not removeAgentStatusByTabPrefix)
// so retention suppressors are planted: a live→gone transition inside the
// same frame as the tab close cannot re-snapshot a row we just dropped.

View File

@ -82,6 +82,12 @@ export type UISlice = {
* first visit after launch is a legitimate "need to see" moment. */
acknowledgedAgentsByPaneKey: Record<string, number>
acknowledgeAgents: (paneKeys: string[]) => void
/** Per-worktree collapsed state for the inline agents section shown inside
* each workspace card. Session-only a restart defaults back to expanded,
* which matches the expected default (people rarely want agents hidden
* across launches). */
collapsedInlineAgentsByWorktreeId: Record<string, boolean>
toggleInlineAgentsCollapsed: (worktreeId: string) => void
activeView: 'terminal' | 'settings' | 'tasks'
previousViewBeforeTasks: 'terminal' | 'settings'
previousViewBeforeSettings: 'terminal' | 'tasks'
@ -251,6 +257,18 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
}
return next ? { acknowledgedAgentsByPaneKey: next } : s
}),
collapsedInlineAgentsByWorktreeId: {},
toggleInlineAgentsCollapsed: (worktreeId) =>
set((s) => {
const current = s.collapsedInlineAgentsByWorktreeId[worktreeId] === true
const next = { ...s.collapsedInlineAgentsByWorktreeId }
if (current) {
delete next[worktreeId]
} else {
next[worktreeId] = true
}
return { collapsedInlineAgentsByWorktreeId: next }
}),
activeView: 'terminal',
previousViewBeforeTasks: 'terminal',

View File

@ -67,7 +67,12 @@ export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
'ci',
'issue',
'pr',
'comment'
'comment',
// Why: agent activity is the primary reason users opt into the feature, so
// show it inline on each card by default. Unchecking this from the
// Workspaces view options hides the inline list entirely — there is no
// alternative agent-activity surface in the sidebar.
'inline-agents'
]
export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
@ -155,7 +160,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
openLinksInApp: true,
rightSidebarOpenByDefault: true,
showTitlebarAgentActivity: true,
showAgentDashboard: true,
showTaskProviderIcons: true,
notifications: getDefaultNotificationSettings(),
diffDefaultView: 'inline',

View File

@ -919,16 +919,6 @@ export type GlobalSettings = {
rightSidebarOpenByDefault: boolean
/** Whether to show the live agent activity count badge in the titlebar. */
showTitlebarAgentActivity: boolean
/** Whether to show the Agent Dashboard panel at the bottom of the right sidebar.
* Why: optional because readers use the `settings?.showAgentDashboard !== false`
* idiom (right-sidebar/index.tsx, AgentsPane.tsx) which presumes the field may
* be undefined e.g. on first hydrate before main-process defaults apply, or
* when migrating settings persisted before this field existed. A required
* `boolean` here would make those readers' fallback branches dead-on-paper
* while still being reached at runtime, which is exactly the kind of type
* hack that drifts silently. Aligning the declaration with reader intent
* keeps the contract honest. */
showAgentDashboard?: boolean
/** Why: the Tasks sidebar label can be kept cleaner for users who do not
* actively use the GitHub/Linear integrations behind it. */
showTaskProviderIcons: boolean
@ -1014,14 +1004,13 @@ export type GlobalSettings = {
* detection, so no visible behavior change. Then we flip this flag to true
* and never migrate again. */
terminalMacOptionAsAltMigrated: boolean
/** Experimental: live Agent Dashboard a bottom-docked right-sidebar panel
* that aggregates working/blocked/done agents across all worktrees, plus
* the sidebar AgentStatusHover surface, retention of "done" rows, and the
* hook-driven status slice that feeds them. Opt-in because the surface is
* still in preview: managed hook installation (Claude/Codex/Gemini) only
* runs when this is true, so toggling it on takes effect on the next app
* launch. The in-pane status indicators and the cursor-agent hook path are
* unaffected by this toggle. */
/** Experimental: live agent activity inline per-workspace-card agent
* rows showing state, prompt, and last message, plus retention of "done"
* rows and the hook-driven status slice that feeds them. Opt-in because
* the surface is still in preview: managed hook installation
* (Claude/Codex/Gemini) only runs when this is true, so toggling it on
* takes effect on the next app launch. The in-pane status indicators and
* the cursor-agent hook path are unaffected by this toggle. */
experimentalAgentDashboard: boolean
}
@ -1050,7 +1039,20 @@ export type NotificationDispatchResult = {
reason?: 'disabled' | 'source-disabled' | 'suppressed-focus' | 'cooldown' | 'not-supported'
}
export type WorktreeCardProperty = 'status' | 'unread' | 'ci' | 'issue' | 'pr' | 'comment'
export type WorktreeCardProperty =
| 'status'
| 'unread'
| 'ci'
| 'issue'
| 'pr'
| 'comment'
// Why: inline list of agent activity rendered directly inside each
// workspace card when the experimental agent-activity feature is on. On by
// default (see DEFAULT_WORKTREE_CARD_PROPERTIES in shared/constants.ts) —
// live agent activity is the primary reason users opt into the feature.
// Users who prefer a compact sidebar can uncheck it from the Workspaces
// view options.
| 'inline-agents'
export type StatusBarItem =
| 'claude'