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:
parent
89f41bfda2
commit
c1620496cf
|
|
@ -64,7 +64,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
openLinksInApp: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
showTitlebarAgentActivity: true,
|
||||
showAgentDashboard: true,
|
||||
showTaskProviderIcons: true,
|
||||
diffDefaultView: 'inline',
|
||||
notifications: {
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
openLinksInApp: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
showTitlebarAgentActivity: true,
|
||||
showAgentDashboard: true,
|
||||
showTaskProviderIcons: true,
|
||||
diffDefaultView: 'inline',
|
||||
notifications: {
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
|
|
|
|||
|
|
@ -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 agent’s 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's live
|
||||
status. Requires an app restart, and tracks agents started in new terminals opened
|
||||
after the restart.
|
||||
Shows each agent'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 agent’s 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') {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue