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
co-authored by Orca
parent 89f41bfda2
commit c1620496cf
37 changed files with 548 additions and 2055 deletions
@@ -64,7 +64,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
openLinksInApp: false,
rightSidebarOpenByDefault: true,
showTitlebarAgentActivity: true,
showAgentDashboard: true,
showTaskProviderIcons: true,
diffDefaultView: 'inline',
notifications: {
-1
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: {
+9 -9
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 [
+1 -1
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. */
@@ -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' }))
+32 -12
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.
@@ -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
@@ -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}>
@@ -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
@@ -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
@@ -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.
@@ -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,
@@ -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
}
}
@@ -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
}
@@ -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.
@@ -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 }>
@@ -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>
)
}
@@ -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>
)
@@ -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">
@@ -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
@@ -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',
@@ -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
@@ -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"
@@ -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)}
@@ -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
@@ -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
@@ -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.
@@ -52,7 +52,6 @@ function Sidebar(): React.JSX.Element {
<SidebarHeader />
<SearchBar />
{/* Virtualized scrollable list */}
<WorktreeList />
{/* Fixed bottom toolbar */}
@@ -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])
}
@@ -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') {
+14 -13
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) {
@@ -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,
@@ -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.
+1 -1
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.
+18
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',
+6 -2
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',
+21 -19
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'