From c1620496cf13cb9bb9a56aa32f32e3b44d096ef9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:51:24 -0700 Subject: [PATCH] feat(agent-cockpit): replace bottom-panel dashboard with inline per-card agents list (#1251) Co-authored-by: Orca --- .../runtime-home-service.test.ts | 1 - src/main/codex-accounts/service.test.ts | 1 - src/main/index.ts | 18 +- src/main/ipc/app.ts | 2 +- .../rate-limits/gemini-usage-fetcher.test.ts | 4 +- src/renderer/src/App.tsx | 44 ++- .../components/dashboard/AgentDashboard.tsx | 334 ----------------- .../dashboard/DashboardAgentRow.tsx | 30 +- .../dashboard/DashboardFilterBar.tsx | 41 --- .../dashboard/DashboardWorktreeCard.tsx | 198 ---------- .../dashboard/RetainedAgentsSyncGate.tsx | 9 +- .../components/dashboard/useDashboardData.ts | 98 ++--- .../dashboard/useDashboardFilter.ts | 185 ---------- .../dashboard/useDashboardKeyboard.ts | 223 ----------- .../src/components/dashboard/useNow.ts | 4 +- .../components/dashboard/useRetainedAgents.ts | 137 +------ .../right-sidebar/DashboardBottomPanel.tsx | 340 ----------------- .../src/components/right-sidebar/index.tsx | 21 +- .../src/components/settings/AgentsPane.tsx | 42 --- .../components/settings/ExperimentalPane.tsx | 29 +- .../settings/experimental-search.ts | 12 +- .../components/sidebar/AgentStatusHover.tsx | 345 ------------------ .../src/components/sidebar/SearchBar.tsx | 2 +- .../src/components/sidebar/SidebarHeader.tsx | 20 +- .../src/components/sidebar/WorktreeCard.tsx | 35 +- .../components/sidebar/WorktreeCardAgents.tsx | 166 +++++++++ .../src/components/sidebar/WorktreeList.tsx | 2 +- src/renderer/src/components/sidebar/index.tsx | 1 - .../sidebar/useWorktreeAgentRows.ts | 152 ++++++++ .../components/sidebar/visible-worktrees.ts | 6 +- .../src/hooks/useAutoAckViewedAgent.ts | 27 +- .../slices/agent-status-ack-cleanup.test.ts | 2 +- src/renderer/src/store/slices/agent-status.ts | 4 +- src/renderer/src/store/slices/terminals.ts | 2 +- src/renderer/src/store/slices/ui.ts | 18 + src/shared/constants.ts | 8 +- src/shared/types.ts | 40 +- 37 files changed, 548 insertions(+), 2055 deletions(-) delete mode 100644 src/renderer/src/components/dashboard/AgentDashboard.tsx delete mode 100644 src/renderer/src/components/dashboard/DashboardFilterBar.tsx delete mode 100644 src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx delete mode 100644 src/renderer/src/components/dashboard/useDashboardFilter.ts delete mode 100644 src/renderer/src/components/dashboard/useDashboardKeyboard.ts delete mode 100644 src/renderer/src/components/right-sidebar/DashboardBottomPanel.tsx delete mode 100644 src/renderer/src/components/sidebar/AgentStatusHover.tsx create mode 100644 src/renderer/src/components/sidebar/WorktreeCardAgents.tsx create mode 100644 src/renderer/src/components/sidebar/useWorktreeAgentRows.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 3f4a4a883..d070b084c 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -64,7 +64,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInApp: false, rightSidebarOpenByDefault: true, showTitlebarAgentActivity: true, - showAgentDashboard: true, showTaskProviderIcons: true, diffDefaultView: 'inline', notifications: { diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 92c69a275..3c6020ef9 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -58,7 +58,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings openLinksInApp: false, rightSidebarOpenByDefault: true, showTitlebarAgentActivity: true, - showAgentDashboard: true, showTaskProviderIcons: true, diffDefaultView: 'inline', notifications: { diff --git a/src/main/index.ts b/src/main/index.ts index e73131da3..89c3a47d5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 [ diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 10c51f194..8173a170a 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -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. */ diff --git a/src/main/rate-limits/gemini-usage-fetcher.test.ts b/src/main/rate-limits/gemini-usage-fetcher.test.ts index 17ff0553b..5f4e7388a 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.test.ts @@ -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' })) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0f21ee4d2..35f6ac28c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(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 (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 (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 { } > - {/* 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. */} - + {/* 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 ? : 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. diff --git a/src/renderer/src/components/dashboard/AgentDashboard.tsx b/src/renderer/src/components/dashboard/AgentDashboard.tsx deleted file mode 100644 index 32d9f5484..000000000 --- a/src/renderer/src/components/dashboard/AgentDashboard.tsx +++ /dev/null @@ -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(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>(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(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 ( -
- {!hasRepos ? ( -
-
- No repos added. Add a repo to see agent activity. -
-
- ) : ( - <> -
-
- - 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 && ( - - )} -
-
- -
-
- -
- {hasResults ? ( -
- {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 ( -
- {/* 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. */} - - {!isCollapsed && - group.worktrees.map((card, i) => ( - - ))} -
- ) - })} -
- ) : ( -
-
- {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.'} -
- {showNoResults ? ( - - ) : ( - filter !== 'all' && ( - - ) - )} -
- )} -
- - )} -
- ) -}) - -export default AgentDashboard diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 748d40214..4321c9b9d 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -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
- // 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.
- + diff --git a/src/renderer/src/components/dashboard/DashboardFilterBar.tsx b/src/renderer/src/components/dashboard/DashboardFilterBar.tsx deleted file mode 100644 index 329d6201c..000000000 --- a/src/renderer/src/components/dashboard/DashboardFilterBar.tsx +++ /dev/null @@ -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 fires empty string when deselecting — keep current filter - if (v) { - onChange(v as DashboardFilter) - } - }} - variant="outline" - size="sm" - className="gap-0" - > - {FILTERS.map((f) => ( - - {f.label} - - ))} - - ) -}) - -export default DashboardFilterBar diff --git a/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx b/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx deleted file mode 100644 index 3a99343f6..000000000 --- a/src/renderer/src/components/dashboard/DashboardWorktreeCard.tsx +++ /dev/null @@ -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 = {} - 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 - - {/* Body: full AgentDashboard */} - {!collapsed && ( -
- -
- )} -
- ) -} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index d6f4b64c7..fcc14df66 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -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. */}
{effectiveTab === 'explorer' && } {effectiveTab === 'search' && } @@ -256,7 +246,6 @@ function RightSidebarInner(): React.JSX.Element { {effectiveTab === 'checks' && } {effectiveTab === 'ports' && }
- {dashboardExperimentEnabled && showAgentDashboard && }
) diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx index 843c586e3..980f713a5 100644 --- a/src/renderer/src/components/settings/AgentsPane.tsx +++ b/src/renderer/src/components/settings/AgentsPane.tsx @@ -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 (
- {/* Dashboard visibility */} - {settings.experimentalAgentDashboard === true && ( -
-
-
- - Show Agent Dashboard - -

- Show the live agent activity panel docked at the bottom of the right sidebar — - surfaces working, blocked, and done agents across all open worktrees. -

-
- -
-
- )} - {/* Default agent picker */}
diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 724a36d4e..4151e0689 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -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({
{showAgentDashboard ? (
- +

- 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.

@@ -190,8 +193,8 @@ export function ExperimentalPane({

{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.'}

+ {!isCollapsed && ( +
+ {agents.map((agent) => ( +
+ +
+ ))} +
+ )} +
+ ) +}) + +export default WorktreeCardAgents diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 27a323591..06857e456 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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. diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index d29ec8282..4bb1b0d44 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -52,7 +52,6 @@ function Sidebar(): React.JSX.Element { - {/* Virtualized scrollable list */} {/* Fixed bottom toolbar */} diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts new file mode 100644 index 000000000..35a905ba5 --- /dev/null +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -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(() => { + // 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() + // 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() + 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]) +} diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 5350f1452..1d6926f92 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -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') { diff --git a/src/renderer/src/hooks/useAutoAckViewedAgent.ts b/src/renderer/src/hooks/useAutoAckViewedAgent.ts index 646f0db07..3fe4d68ed 100644 --- a/src/renderer/src/hooks/useAutoAckViewedAgent.ts +++ b/src/renderer/src/hooks/useAutoAckViewedAgent.ts @@ -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) { diff --git a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts index 8759d34e9..1cdaa7a62 100644 --- a/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts +++ b/src/renderer/src/store/slices/agent-status-ack-cleanup.test.ts @@ -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, diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index db8000147..41203950b 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -405,7 +405,7 @@ export const createAgentStatusSlice: StateCreator { // 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. diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 803c7ebfa..49c24bdbc 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -528,7 +528,7 @@ export const createTerminalSlice: StateCreator }) // 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. diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 6adc7d3ec..879e6ee6c 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -82,6 +82,12 @@ export type UISlice = { * first visit after launch is a legitimate "need to see" moment. */ acknowledgedAgentsByPaneKey: Record 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 + toggleInlineAgentsCollapsed: (worktreeId: string) => void activeView: 'terminal' | 'settings' | 'tasks' previousViewBeforeTasks: 'terminal' | 'settings' previousViewBeforeSettings: 'terminal' | 'tasks' @@ -251,6 +257,18 @@ export const createUISlice: StateCreator = (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', diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 69d4332c6..57af1c534 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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', diff --git a/src/shared/types.ts b/src/shared/types.ts index d9c5d30ee..ab144fa5b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'