feat(agent-dashboard): choose in-window board or pop-out window (#10243)

* feat(agent-dashboard): choose in-window screen popover or pop-out window

The experimental Agent Dashboard opened only as a separate pop-out
window. Add an "Open as" mode under the experimental toggle so it can
open as an in-window screen popover (new default) or a pop-out window
(prior behavior). The mode row appears only when the feature is on.

- New setting `experimentalAgentDashboardMode: 'in-window' | 'popout'`
  (default in-window); sidebar entry branches on it.
- In-window: AgentDashboardOverlay renders the shared AgentKanbanBoard
  in a near-fullscreen dialog, snapshot built locally via
  useLiveDashboardSnapshot (the pop-out relays over IPC; in-window has
  no relay). Ack/reveal act on the local store — the pop-out IPC
  handlers are gated to the pop-out renderer.
- AgentKanbanBoard gains containerClassName/onAckAgent/onRevealAgent/
  onClose props; defaults preserve the pop-out behavior.
- Extracted AgentDashboardExperimentalSetting to keep ExperimentalPane
  under the max-lines cap.

* fix(agent-dashboard): admit main renderer to terminal-preview IPC for in-window dialog

The terminalPreview:* handlers gated every channel to the pop-out
renderer, so the in-window overlay's terminal dialog (running in the
main renderer) got { snapshot: null } from connect and falsely showed
"No live terminal — this agent's pane has closed." for live agents.
Accept the trusted UI renderer too — it already has full PTY access
through the regular terminal channels, so this adds no reach.

* fix(agent-dashboard): sync locale catalogs for new mode/close keys

verify:localization-catalog (part of lint CI) fails when en.json keys are
missing from the other locale catalogs; run sync:localization-catalog so
the six new agent-dashboard keys exist everywhere (English fallback text;
translated copy remains the documented follow-up).

* feat(agent-dashboard): present in-window mode as a companion board sheet

The in-window dashboard now uses the same non-modal left sheet as the
workspace kanban board — anchored to the sidebar edge, chrome/status-bar
bounds, sidebar stays interactive — instead of a near-fullscreen modal
dialog. Both companion boards are mutually exclusive; the sidebar entry
toggles the drawer. Removes the modal focus-restore timing coupling on
reveal.

* fix(agent-dashboard): ignore Radix dismiss requests like the workspace board

Non-modal Radix layers also request dismissal for interactions the drawer's
outside guards cannot classify — focus moving outside carries no pointer
coordinates, and clicks in the status bar / top chrome fall outside the
right-side dismiss band. Forward only open requests from the Sheet, matching
WorkspaceKanbanDrawer, so only the drawer's own escape/outside/close paths
close it.

* fix(agent-dashboard): guard reveal relay and refresh stale mode copy

CodeRabbit review: revealAgent lacked the ?. HMR-skew guard its sibling
ackAgent has (both channels shipped together, so a stale dev preload
lacks both). The es/ja/ko/zh catalogs also still described the dashboard
as pop-out-only in stale English, contradicting the new in-window
default; refreshed to the current English source.

* feat(agent-dashboard): add board settings menu to the in-window header

Mirrors the workspace board's settings gear: an Open as segmented control
in the board header so the mode is changeable without opening Settings.
Switching to pop-out hands the surface over (closes the drawer, opens the
window) instead of leaving a board the setting says should be a window.
In-window only via an optional headerActions slot - the pop-out renderer
has no store to drive it.

* fix(agent-dashboard): reset the settings-menu flag when the drawer closes

The pop-out hand-off closes the sheet while the menu is still open, so the
menu unmounts without Radix reporting onOpenChange(false). The stale
menuOpen=true then blocked outside-dismiss permanently on the next
in-window open. Mirror closeWorkspaceBoard by resetting the flag in close.

* fix(agent-dashboard): reset the menu flag on store-driven drawer closes

Cmd+B sidebar collapse and the workspace-board exclusivity effect close
the drawer via setAgentDashboardDrawerOpen directly, bypassing close();
a settings menu open at that moment unmounted without Radix reporting
onOpenChange(false), leaving menuOpen stuck true and outside-dismiss
disabled on the next open. Sync the flag to the open state so every
close path resets it.
This commit is contained in:
Brennan Benson
2026-07-24 12:48:18 -07:00
committed by GitHub
parent b71f71904f
commit d56e2fbbe4
23 changed files with 795 additions and 107 deletions
+38 -11
View File
@@ -1,21 +1,26 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { handlers, ipcMainMock, isDashboardPopoutRendererMock } = vi.hoisted(() => {
const map = new Map<string, (...args: unknown[]) => unknown>()
return {
handlers: map,
ipcMainMock: {
removeHandler: vi.fn(),
handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn)
},
isDashboardPopoutRendererMock: vi.fn(() => true)
}
})
const { handlers, ipcMainMock, isDashboardPopoutRendererMock, isTrustedUIRendererMock } =
vi.hoisted(() => {
const map = new Map<string, (...args: unknown[]) => unknown>()
return {
handlers: map,
ipcMainMock: {
removeHandler: vi.fn(),
handle: (channel: string, fn: (...args: unknown[]) => unknown) => map.set(channel, fn)
},
isDashboardPopoutRendererMock: vi.fn(() => true),
isTrustedUIRendererMock: vi.fn(() => false)
}
})
vi.mock('electron', () => ({ ipcMain: ipcMainMock }))
vi.mock('../window/dashboard-popout-window', () => ({
isDashboardPopoutRenderer: isDashboardPopoutRendererMock
}))
vi.mock('./ui', () => ({
isTrustedUIRenderer: isTrustedUIRendererMock
}))
import { registerTerminalPreviewHandlers } from './terminal-preview'
@@ -82,6 +87,7 @@ describe('registerTerminalPreviewHandlers', () => {
beforeEach(() => {
handlers.clear()
isDashboardPopoutRendererMock.mockReturnValue(true)
isTrustedUIRendererMock.mockReturnValue(false)
})
afterEach(() => {
vi.clearAllMocks()
@@ -281,6 +287,27 @@ describe('registerTerminalPreviewHandlers', () => {
expect(runtime.writeTerminalPreviewInput).not.toHaveBeenCalled()
})
// The in-window dashboard overlay hosts the preview dialog from the main
// renderer, which is trusted but is not the popout window.
it('admits the trusted main renderer when it is not the popout', async () => {
const runtime = makeRuntime()
registerTerminalPreviewHandlers(runtime as never)
const sender = makeSender()
isDashboardPopoutRendererMock.mockReturnValue(false)
isTrustedUIRendererMock.mockReturnValue(true)
await expect(
handlers.get('terminalPreview:connect')!(eventFor(sender), { ptyId: 'p1' })
).resolves.toEqual({
snapshot: { data: 'screen', cols: 80, rows: 20, seq: 5 },
replay: []
})
await expect(
handlers.get('terminalPreview:input')!(eventFor(sender), { ptyId: 'p1', data: 'x' })
).resolves.toBe(true)
expect(runtime.writeTerminalPreviewInput).toHaveBeenCalledWith('p1', 'x')
})
it('pushes a resync only when the PTY grid dimensions change', async () => {
const runtime = makeRuntime()
registerTerminalPreviewHandlers(runtime as never)
+13 -6
View File
@@ -5,6 +5,7 @@ import type {
} from '../../shared/terminal-preview'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { isDashboardPopoutRenderer } from '../window/dashboard-popout-window'
import { isTrustedUIRenderer } from './ui'
import {
TERMINAL_PREVIEW_OUTPUT_BATCH_MAX_BYTES,
TerminalPreviewOutputStream
@@ -16,6 +17,12 @@ function isValidPtyId(value: unknown): value is string {
return typeof value === 'string' && value.length > 0 && value.length <= PREVIEW_ID_MAX_LENGTH
}
// Why: the preview dialog has two hosts — the pop-out window and the main
// renderer's in-window overlay. The trusted UI renderer already has full PTY
// access through the regular terminal channels, so admitting it adds no reach.
function isTerminalPreviewRenderer(sender: WebContents): boolean {
return isDashboardPopoutRenderer(sender) || isTrustedUIRenderer(sender)
}
/** Pop-out terminal transport with an atomic snapshot/live boundary. */
export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): void {
ipcMain.removeHandler('terminalPreview:connect')
@@ -27,7 +34,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo
const subscriptionsByContents = new Map<number, Map<string, TerminalPreviewOutputStream>>()
// Why: the preview dialog claims the PTY grid through the remote-desktop
// viewer registry so the main-window pane parks and later reclaims its own
// geometry. Claims are tracked per popout webContents so an explicit
// geometry. Claims are tracked per viewer webContents so an explicit
// unsubscribe or a destroyed window always releases the size floor.
const fitClaimsByContents = new Map<number, Map<string, symbol>>()
@@ -83,7 +90,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo
event,
args: { ptyId?: unknown; opts?: { scrollbackRows?: unknown } }
): Promise<TerminalPreviewConnectResult> => {
if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) {
if (!isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId)) {
return { snapshot: null, replay: [] }
}
const ptyId = args.ptyId
@@ -159,7 +166,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo
'terminalPreview:input',
(event, args: { ptyId?: unknown; data?: unknown }): Promise<boolean> => {
if (
!isDashboardPopoutRenderer(event.sender) ||
!isTerminalPreviewRenderer(event.sender) ||
!isValidPtyId(args?.ptyId) ||
typeof args.data !== 'string'
) {
@@ -173,7 +180,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo
'terminalPreview:ack',
(event, args: { ptyId?: unknown; bytes?: unknown }): void => {
if (
!isDashboardPopoutRenderer(event.sender) ||
!isTerminalPreviewRenderer(event.sender) ||
!isValidPtyId(args?.ptyId) ||
typeof args.bytes !== 'number' ||
!Number.isFinite(args.bytes) ||
@@ -197,7 +204,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo
args: { ptyId?: unknown; cols?: unknown; rows?: unknown }
): Promise<{ cols: number; rows: number } | null> => {
if (
!isDashboardPopoutRenderer(event.sender) ||
!isTerminalPreviewRenderer(event.sender) ||
!isValidPtyId(args?.ptyId) ||
typeof args.cols !== 'number' ||
typeof args.rows !== 'number' ||
@@ -244,7 +251,7 @@ export function registerTerminalPreviewHandlers(runtime: OrcaRuntimeService): vo
)
ipcMain.handle('terminalPreview:unsubscribe', (event, args: { ptyId?: unknown }): void => {
if (!isDashboardPopoutRenderer(event.sender) || !isValidPtyId(args?.ptyId)) {
if (!isTerminalPreviewRenderer(event.sender) || !isValidPtyId(args?.ptyId)) {
return
}
subscriptionsByContents.get(event.sender.id)?.get(args.ptyId)?.dispose()
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { XIcon } from 'lucide-react'
import {
DASHBOARD_BUCKET_ORDER,
type DashboardBucket,
@@ -8,10 +9,24 @@ import {
import { cn } from '@/lib/utils'
import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval'
import { AgentKanbanCard } from './AgentKanbanCard'
import { AgentTerminalDialog } from './AgentTerminalDialog'
import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog'
import './agent-board-transitions.css'
import { translate } from '@/i18n/i18n'
/** Ack an agent in the pop-out window: relayed over IPC to the main renderer.
* ?. shields dialog-opening from dev-HMR preload skew (renderer updates hot,
* the preload only on app restart) — acks just no-op until restart. */
function ackAgentViaPopoutRelay(paneKey: string): void {
void window.api.dashboard.ackAgent?.(paneKey)
}
/** Reveal an agent from the pop-out window: raise the main window and route it
* to the agent's pane via IPC. Same `?.` HMR-skew guard as the ack relay —
* both channels ship together, so a stale preload lacks both. */
function revealAgentViaPopoutRelay(args: AgentRevealArgs): void {
void window.api.dashboard.revealAgent?.(args)
}
function bucketLabel(bucket: DashboardBucket): string {
switch (bucket) {
case 'attention':
@@ -87,8 +102,36 @@ function KanbanColumn({
)
}
/** The pop-out agent board: status columns fed by the relayed snapshot. */
export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }): React.JSX.Element {
type AgentKanbanBoardProps = {
snapshot: DashboardSnapshot
/** Sizing for the outermost container. The pop-out fills the window
* (h-screen w-screen); the in-window drawer fills its host (h-full w-full). */
containerClassName?: string
/** Marks an agent as seen. Defaults to the pop-out IPC relay; the in-window
* host acks the store directly. */
onAckAgent?: (paneKey: string) => void
/** Focuses the agent's pane. Defaults to the pop-out IPC relay; the in-window
* host activates the worktree/pane locally and closes the overlay. */
onRevealAgent?: (args: AgentRevealArgs) => void
/** When provided, renders a close control in the header (in-window mode). The
* pop-out relies on its native window controls, so it omits this. */
onClose?: () => void
/** Header controls rendered before the close button. The in-window host
* passes its settings menu; the pop-out renderer has no store to drive it. */
headerActions?: React.ReactNode
}
/** The agent board: status columns fed by a snapshot. Shared by the pop-out
* window and the in-window drawer — the two differ only in sizing and
* how ack/reveal are routed. */
export function AgentKanbanBoard({
snapshot,
containerClassName = 'h-screen w-screen',
onAckAgent = ackAgentViaPopoutRelay,
onRevealAgent = revealAgentViaPopoutRelay,
onClose,
headerActions
}: AgentKanbanBoardProps): React.JSX.Element {
const grouped = useMemo(() => groupByBucket(snapshot.cards), [snapshot.cards])
const hasRelativeTimestamps = useMemo(
() => snapshot.cards.some((card) => (card.finishedAt ?? card.startedAt) > 0),
@@ -131,24 +174,25 @@ export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }):
}, [])
// Seen-state is the app-wide ack map (same signal as the sidebar's bold/mute
// rows): opening a dialog acks the agent in the main renderer via the relay,
// and the next snapshot comes back with unseen=false.
// ?. shields dialog-opening from dev-HMR preload skew (renderer updates
// hot, the preload only on app restart) — acks just no-op until restart.
const handleOpenTerminal = useCallback((card: DashboardCard) => {
void window.api.dashboard.ackAgent?.(card.paneKey)
setOpenedCard(card)
}, [])
// rows): opening a dialog acks the agent, and the next snapshot comes back
// with unseen=false.
const handleOpenTerminal = useCallback(
(card: DashboardCard) => {
onAckAgent(card.paneKey)
setOpenedCard(card)
},
[onAckAgent]
)
// Watching the open dialog counts as seeing state changes as they happen —
// without this, an agent finishing while you watch would re-flag its card.
useEffect(() => {
if (dialogCard?.unseen) {
void window.api.dashboard.ackAgent?.(dialogCard.paneKey)
onAckAgent(dialogCard.paneKey)
}
}, [dialogCard?.unseen, dialogCard?.paneKey])
}, [dialogCard?.unseen, dialogCard?.paneKey, onAckAgent])
return (
<div className="flex h-screen w-screen flex-col bg-background text-foreground">
<div className={cn('flex flex-col bg-background text-foreground', containerClassName)}>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-[13px] font-semibold">
{translate('dashboardPopout.title', 'Agents')}
@@ -158,6 +202,21 @@ export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }):
count: snapshot.cards.length
})}
</span>
{headerActions || onClose ? (
<div className="ml-auto flex items-center gap-1">
{headerActions}
{onClose ? (
<button
type="button"
onClick={onClose}
aria-label={translate('dashboardPopout.close', 'Close dashboard')}
className="rounded-sm p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<XIcon className="size-4" />
</button>
) : null}
</div>
) : null}
</div>
<div className="scrollbar-sleek flex min-h-0 flex-1 overflow-x-auto p-3">
{/* Why: columns share the window width up to a readable cap; mx-auto
@@ -176,7 +235,11 @@ export function AgentKanbanBoard({ snapshot }: { snapshot: DashboardSnapshot }):
))}
</div>
</div>
<AgentTerminalDialog card={dialogCard} onOpenChange={handleDialogOpenChange} />
<AgentTerminalDialog
card={dialogCard}
onOpenChange={handleDialogOpenChange}
onReveal={onRevealAgent}
/>
</div>
)
}
@@ -9,10 +9,21 @@ import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { AgentTerminalPreview } from './AgentTerminalPreview'
import { translate } from '@/i18n/i18n'
/** Routing payload for focusing an agent's pane in the main window. */
export type AgentRevealArgs = {
repoId: string
worktreeId: string
tabId: string
leafId: string | null
}
type AgentTerminalDialogProps = {
/** The agent shown in the dialog; null renders the dialog closed. */
card: DashboardCard | null
onOpenChange: (open: boolean) => void
/** Focus the agent's pane. The pop-out relays over IPC; the in-window host
* activates the worktree/pane locally. */
onReveal: (args: AgentRevealArgs) => void
}
/**
@@ -24,19 +35,20 @@ type AgentTerminalDialogProps = {
*/
export function AgentTerminalDialog({
card,
onOpenChange
onOpenChange,
onReveal
}: AgentTerminalDialogProps): React.JSX.Element {
const reveal = useCallback(() => {
if (!card) {
return
}
void window.api.dashboard.revealAgent({
onReveal({
repoId: card.repoId,
worktreeId: card.worktreeId,
tabId: card.tabId,
leafId: card.leafId
})
}, [card])
}, [card, onReveal])
return (
<Dialog open={card !== null} onOpenChange={onOpenChange}>
@@ -0,0 +1,231 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard'
import type { AgentRevealArgs } from '../dashboard-popout/AgentTerminalDialog'
import {
isWorkspaceBoardKeepOpenTarget,
useWorkspaceKanbanOutsideDismiss
} from '../sidebar/use-workspace-kanban-outside-dismiss'
import {
STATUS_BAR_RESERVE_HEIGHT,
WORKSPACE_TOP_CHROME_HEIGHT
} from '../sidebar/workspace-chrome-metrics'
import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu'
import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
import { translate } from '@/i18n/i18n'
// Why: Escape should dismiss interactive nested overlays (e.g. the terminal
// preview dialog) before this companion sheet, which is excluded by its own
// data attribute because Radix marks it role="dialog" as well.
const AGENT_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR = [
'[data-slot="dropdown-menu-content"][data-state="open"]',
'[data-slot="context-menu-content"][data-state="open"]',
'[data-slot="popover-content"][data-state="open"]',
'[role="dialog"][data-state="open"]:not([data-agent-dashboard-sheet])',
'[role="alertdialog"][data-state="open"]',
'[role="menu"][data-state="open"]',
'[role="listbox"][data-state="open"]'
].join(', ')
/** The in-window Agent Dashboard body. Mounted only while open so the live
* snapshot derivation stays off the hot path when the drawer is closed. */
function AgentDashboardDrawerBody({
onClose,
onMenuOpenChange
}: {
onClose: () => void
onMenuOpenChange: (open: boolean) => void
}): React.JSX.Element {
const snapshot = useLiveDashboardSnapshot()
// In-window ack/reveal act on the local store directly — the pop-out's IPC
// relay is gated to the pop-out renderer and would reject calls from here.
const handleAckAgent = useCallback((paneKey: string) => {
useAppStore.getState().acknowledgeAgents([paneKey])
}, [])
const handleRevealAgent = useCallback(
(args: AgentRevealArgs) => {
useAppStore.getState().setActiveWorktree(args.worktreeId)
activateTabAndFocusPane(args.tabId, args.leafId, { flashFocusedPane: true })
onClose()
},
[onClose]
)
// Switching to pop-out from the board hands the surface over rather than
// leaving an in-window board that the setting says should be a window.
const handleSwitchToPopout = useCallback(() => {
onClose()
void window.api.dashboard.openPopout?.()
}, [onClose])
return (
<AgentKanbanBoard
snapshot={snapshot}
// Why: bg-transparent lets the sheet's worktree-sidebar surface through
// so the board reads as the same companion panel as the workspace board.
containerClassName="h-full w-full bg-transparent"
onAckAgent={handleAckAgent}
onRevealAgent={handleRevealAgent}
onClose={onClose}
headerActions={
<AgentDashboardSettingsMenu
onSwitchToPopout={handleSwitchToPopout}
onOpenChange={onMenuOpenChange}
/>
}
/>
)
}
type AgentDashboardDrawerProps = {
leftSidebarStyle?: React.CSSProperties
statusBarVisible: boolean
}
/**
* The in-window Agent Dashboard surface: the same board as the pop-out window,
* presented like the workspace kanban board — a non-modal companion sheet that
* expands from the sidebar edge and keeps the rest of the app interactive.
*/
export function AgentDashboardDrawer({
leftSidebarStyle,
statusBarVisible
}: AgentDashboardDrawerProps): React.JSX.Element {
const open = useAppStore((s) => s.agentDashboardDrawerOpen)
const setOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
const [menuOpen, setMenuOpen] = useState(false)
// Why: like closeWorkspaceBoard, reset the menu flag on close — Radix never
// reports close for a menu unmounted with the sheet (e.g. the pop-out
// hand-off), and a stale true would block outside-dismiss on reopen.
const close = useCallback(() => {
setMenuOpen(false)
setOpen(false)
}, [setOpen])
// Why: sidebar collapse (Cmd+B) and workspace-board exclusivity close the
// drawer through the store setter, bypassing close(); sync the flag so a
// menu unmounted that way can't block outside-dismiss on the next open.
useEffect(() => {
if (!open) {
setMenuOpen(false)
}
}, [open])
const handleSheetOpenChange = useCallback(
(nextOpen: boolean) => {
// Why: Radix also requests dismissal for unguardable interactions (focus
// moving outside has no pointer coordinates), so like the workspace board
// only the drawer's own escape/outside/close paths may close it.
if (nextOpen) {
setOpen(true)
}
},
[setOpen]
)
const boardRef = useRef<HTMLDivElement | null>(null)
useWorkspaceKanbanOutsideDismiss({
open,
boardRef,
preserveOpenForMenu: menuOpen,
onOpenChange: setOpen
})
useEffect(() => {
if (!open) {
return
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') {
return
}
if (document.querySelector(AGENT_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR)) {
return
}
event.preventDefault()
close()
}
// Why: the board is a non-modal companion panel, so focus may be outside
// the sheet when Escape should still dismiss it.
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [close, open])
const drawerLeft = sidebarOpen ? sidebarWidth : 0
const drawerLeftCss = sidebarOpen
? `var(--workspace-sidebar-live-width, ${sidebarWidth}px)`
: '0px'
// Why: App reserves a bottom status row while visible; the portalled board
// must share that viewport bound instead of covering the status controls.
const drawerBottom = `${statusBarVisible ? STATUS_BAR_RESERVE_HEIGHT : 0}px`
const guardSidebarInteraction = (
event: CustomEvent<{ originalEvent: PointerEvent | FocusEvent }>
): void => {
const originalEvent = event.detail.originalEvent
if (menuOpen || isWorkspaceBoardKeepOpenTarget(originalEvent.target)) {
// Why: the first outside click should close a board menu, not also
// dismiss the board that owns it.
event.preventDefault()
return
}
const liveDrawerLeft =
boardRef.current?.closest<HTMLElement>('[data-slot="sheet-content"]')?.getBoundingClientRect()
.left ?? drawerLeft
const pointerX =
'clientX' in originalEvent && typeof originalEvent.clientX === 'number'
? originalEvent.clientX
: null
if (pointerX !== null && pointerX < liveDrawerLeft) {
// Why: keep the workspace sidebar interactive while the companion board stays open.
event.preventDefault()
}
}
return (
<Sheet open={open} onOpenChange={handleSheetOpenChange} modal={false}>
<SheetContent
side="left"
showCloseButton={false}
aria-describedby={undefined}
className="workspace-kanban-sheet-content bg-worktree-sidebar p-0 sm:max-w-none"
overlayStyle={{
top: WORKSPACE_TOP_CHROME_HEIGHT,
bottom: drawerBottom,
left: drawerLeftCss,
pointerEvents: 'none'
}}
style={
{
...leftSidebarStyle,
// Why: the board is a companion to the workspace sidebar, so it
// expands from the sidebar edge instead of covering the sidebar.
left: drawerLeftCss,
top: WORKSPACE_TOP_CHROME_HEIGHT,
bottom: drawerBottom,
height: 'auto',
width: `min(calc(100vw - ${drawerLeftCss}), 1294px)`
} as React.CSSProperties
}
data-agent-dashboard-sheet=""
onOpenAutoFocus={(event) => {
// Why: Radix focuses the first header button on open, which shows
// hover-style affordances without hover and makes the drawer noisy.
event.preventDefault()
}}
onPointerDownOutside={guardSidebarInteraction}
onInteractOutside={guardSidebarInteraction}
>
<SheetTitle className="sr-only">{translate('dashboardPopout.title', 'Agents')}</SheetTitle>
{/* Radix unmounts SheetContent while closed, so the live snapshot
derivation in the body stays off the closed path. */}
<div ref={boardRef} className="flex min-h-0 flex-1 flex-col">
<AgentDashboardDrawerBody onClose={close} onMenuOpenChange={setMenuOpen} />
</div>
</SheetContent>
</Sheet>
)
}
@@ -0,0 +1,110 @@
import { Settings } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { SettingsSegmentedControl } from '../settings/SettingsFormControls'
import type { AgentDashboardMode } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
type AgentDashboardSettingsMenuProps = {
/** Called after the mode switches to pop-out so the host can hand the board
* over to the pop-out window instead of leaving a stale in-window board. */
onSwitchToPopout: () => void
/** Lets the host keep the companion board open while this menu owns the
* next outside click, matching the workspace board's menu handling. */
onOpenChange: (open: boolean) => void
}
/** Board-header settings for the in-window Agent Dashboard, mirroring the
* workspace board's settings menu. In-window only — the pop-out renderer has
* no store access, so it never mounts this. */
export function AgentDashboardSettingsMenu({
onSwitchToPopout,
onOpenChange
}: AgentDashboardSettingsMenuProps): React.JSX.Element {
const mode = useAppStore((s) => s.settings?.experimentalAgentDashboardMode ?? 'in-window')
const updateSettings = useAppStore((s) => s.updateSettings)
const handleModeChange = (next: AgentDashboardMode): void => {
if (next === mode) {
return
}
updateSettings({ experimentalAgentDashboardMode: next })
if (next === 'popout') {
onSwitchToPopout()
}
}
return (
<DropdownMenu modal={false} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
aria-label={translate('dashboardPopout.settings', 'Agent Dashboard settings')}
className="text-muted-foreground"
>
<Settings className="size-3.5" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate('dashboardPopout.settingsTooltip', 'Board settings')}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" sideOffset={8} collisionPadding={8} className="w-72 p-2">
<div className="flex items-start justify-between gap-3 rounded-md px-1.5 py-1.5">
<span className="min-w-0 space-y-0.5">
<span className="block text-[12px] font-medium leading-4 text-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeLabel',
'Open as'
)}
</span>
<span className="block text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy',
'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.'
)}
</span>
</span>
</div>
<div className="px-1.5 pb-1">
<SettingsSegmentedControl
value={mode}
onChange={handleModeChange}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeAriaLabel',
'Agent Dashboard open mode'
)}
size="sm"
equalWidth
options={[
{
value: 'in-window',
label: translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeInWindow',
'In-window'
)
},
{
value: 'popout',
label: translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modePopout',
'Pop-out'
)
}
]}
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,66 @@
import { useMemo } from 'react'
import { useAppStore } from '@/store'
import type { DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
import { buildDashboardSnapshot } from './build-dashboard-snapshot'
/**
* Builds the dashboard snapshot directly from the live renderer store for the
* in-window screen popover. The pop-out window can't read this store, so it
* relays a serialized snapshot instead (useDashboardSnapshot); in-window there
* is no relay, so we derive it here from the same builder the bridge uses.
*/
export function useLiveDashboardSnapshot(): DashboardSnapshot {
const repos = useAppStore((s) => s.repos)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey)
const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey)
const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId)
const runtimeAgentOrchestrationByPaneKey = useAppStore(
(s) => s.runtimeAgentOrchestrationByPaneKey
)
const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId)
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId)
const acknowledgedAgentsByPaneKey = useAppStore((s) => s.acknowledgedAgentsByPaneKey)
// Why: freshness can flip a bucket without any backing map changing; the epoch
// ticks on the freshness boundary so the memo re-derives stale-decayed cards.
const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch)
return useMemo(
// Why: Date.now() is read inside the memo (not a dep) so stale-decay
// recalculates whenever agentStatusEpoch ticks, matching useDashboardData.
() =>
buildDashboardSnapshot(
{
repos,
worktreesByRepo,
tabsByWorktree,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
migrationUnsupportedByPtyId,
runtimeAgentOrchestrationByPaneKey,
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey
},
Date.now()
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
repos,
worktreesByRepo,
tabsByWorktree,
agentStatusByPaneKey,
retainedAgentsByPaneKey,
migrationUnsupportedByPtyId,
runtimeAgentOrchestrationByPaneKey,
terminalLayoutsByTabId,
ptyIdsByTabId,
runtimePaneTitlesByTabId,
acknowledgedAgentsByPaneKey,
agentStatusEpoch
]
)
}
@@ -0,0 +1,105 @@
import type { GlobalSettings } from '../../../../shared/types'
import { translate } from '@/i18n/i18n'
import { Label } from '../ui/label'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSegmentedControl, SettingsSwitch } from './SettingsFormControls'
import { getExperimentalSearchEntry } from './experimental-search'
type AgentDashboardExperimentalSettingProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
}
export function AgentDashboardExperimentalSetting({
settings,
updateSettings
}: AgentDashboardExperimentalSettingProps): React.JSX.Element {
const enabled = settings.experimentalAgentDashboardPopout === true
const mode = settings.experimentalAgentDashboardMode ?? 'in-window'
return (
<SearchableSetting
title={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.title',
'Agent Dashboard'
)}
description={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.description',
'Kanban board for monitoring agents across worktrees, in-window or as a pop-out.'
)}
keywords={getExperimentalSearchEntry().agentDashboard.keywords}
className="space-y-3 py-2"
id="experimental-agent-dashboard"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.title',
'Agent Dashboard'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.copy',
'Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.'
)}
</p>
</div>
<SettingsSwitch
checked={enabled}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
'Toggle Agent Dashboard'
)}
onChange={() => updateSettings({ experimentalAgentDashboardPopout: !enabled })}
/>
</div>
{enabled ? (
<div className="ml-4 border-l border-border pl-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeLabel',
'Open as'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeCopy',
'Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.'
)}
</p>
</div>
<SettingsSegmentedControl
value={mode}
onChange={(next) => updateSettings({ experimentalAgentDashboardMode: next })}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeAriaLabel',
'Agent Dashboard open mode'
)}
size="sm"
options={[
{
value: 'in-window',
label: translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modeInWindow',
'In-window'
)
},
{
value: 'popout',
label: translate(
'auto.components.settings.ExperimentalPane.agentDashboard.modePopout',
'Pop-out'
)
}
]}
/>
</div>
</div>
) : null}
</SearchableSetting>
)
}
@@ -8,6 +8,7 @@ import { HiddenExperimentalGroup } from './HiddenExperimentalGroup'
import { NumberField, SettingsSwitch } from './SettingsFormControls'
import { translate } from '@/i18n/i18n'
import { NativeChatExperimentalSetting } from './NativeChatExperimentalSetting'
import { AgentDashboardExperimentalSetting } from './AgentDashboardExperimentalSetting'
import { EphemeralVmsExperimentalSetting } from './EphemeralVmsExperimentalSetting'
import {
MAX_AGENT_HIBERNATION_IDLE_MS,
@@ -152,49 +153,7 @@ export function ExperimentalPane({
) : null}
{showAgentDashboard ? (
<SearchableSetting
title={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.title',
'Agent Dashboard'
)}
description={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.description',
'Pop-out Kanban board for monitoring agents across worktrees.'
)}
keywords={getExperimentalSearchEntry().agentDashboard.keywords}
className="space-y-3 py-2"
id="experimental-agent-dashboard"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.title',
'Agent Dashboard'
)}
</Label>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.settings.ExperimentalPane.agentDashboard.copy',
'Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents in a separate window and jump into their live terminals.'
)}
</p>
</div>
<SettingsSwitch
checked={settings.experimentalAgentDashboardPopout === true}
ariaLabel={translate(
'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
'Toggle Agent Dashboard'
)}
onChange={() =>
updateSettings({
experimentalAgentDashboardPopout:
settings.experimentalAgentDashboardPopout !== true
})
}
/>
</div>
</SearchableSetting>
<AgentDashboardExperimentalSetting settings={settings} updateSettings={updateSettings} />
) : null}
{showNativeChat ? (
@@ -102,7 +102,7 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog(
),
description: translate(
'auto.components.settings.experimental.search.agentDashboard.description',
'Pop-out Kanban board for monitoring agents across worktrees.'
'Kanban board for monitoring agents across worktrees, in-window or as a pop-out.'
),
keywords: [
...translateSearchKeyword(
@@ -125,6 +125,14 @@ export const getExperimentalPaneSearchEntries = createLocalizedCatalog(
'auto.components.settings.experimental.search.agentDashboard.popout',
'pop-out'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.board',
'board'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.inWindow',
'in-window'
),
...translateSearchKeyword(
'auto.components.settings.experimental.search.agentDashboard.worktrees',
'worktrees'
@@ -93,6 +93,8 @@ import Sidebar from './index'
function setSidebarState(settings: GlobalSettings, statusBarVisible = true): void {
mocks.state = {
activeModal: null,
agentDashboardDrawerOpen: false,
setAgentDashboardDrawerOpen: vi.fn(),
fetchAllWorktrees: vi.fn(),
repos: [],
setSidebarWidth: vi.fn(),
@@ -40,6 +40,14 @@ export function shouldShowAgentDashboardButton(
return settings?.experimentalAgentDashboardPopout === true
}
// Why: in-window is the default surface; only an explicit 'popout' choice opens
// the separate OS window.
function isAgentDashboardPopoutMode(
settings: Pick<GlobalSettings, 'experimentalAgentDashboardMode'> | null | undefined
): boolean {
return settings?.experimentalAgentDashboardMode === 'popout'
}
export function shouldShowMobileButton(
settings: Pick<GlobalSettings, 'showMobileButton'> | null | undefined
): boolean {
@@ -101,12 +109,21 @@ function DashboardBucketCounts({
// agent-status churn only updates this opt-in row, not the full navigation.
function AgentDashboardSidebarEntry(): React.JSX.Element {
const dashboardBucketCounts = useAgentBucketCounts()
const openAsPopout = useAppStore((s) => isAgentDashboardPopoutMode(s.settings))
const drawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen)
const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
return (
<button
type="button"
onClick={() => {
void window.api.dashboard.openPopout()
if (openAsPopout) {
void window.api.dashboard.openPopout()
} else {
// Why: like the workspace board trigger, the entry toggles its
// companion drawer — sidebar clicks do not auto-dismiss it.
setAgentDashboardDrawerOpen(!drawerOpen)
}
}}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] font-medium tracking-tight transition-colors',
@@ -40,6 +40,7 @@ import {
} from './worktree-manual-order'
import type { WorkspaceStatus, WorktreeMeta } from '../../../../shared/types'
import { makeWorkspaceStatusId } from '../../../../shared/workspace-statuses'
import { STATUS_BAR_RESERVE_HEIGHT, WORKSPACE_TOP_CHROME_HEIGHT } from './workspace-chrome-metrics'
import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour'
import { translate } from '@/i18n/i18n'
@@ -53,9 +54,6 @@ type WorkspaceKanbanDrawerProps = {
onMenuOpenChange: (open: boolean) => void
}
const WORKSPACE_TOP_CHROME_HEIGHT = 36
const STATUS_BAR_RESERVE_HEIGHT = 24
function formatTaskStatusSyncMessage(message: WorkspaceBoardTaskStatusSyncMessage): string {
switch (message.kind) {
case 'issue-read-failed':
@@ -8,6 +8,7 @@ import SetupScriptPromptCard from './SetupScriptPromptCard'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
import { AgentDashboardDrawer } from '@/components/dashboard/AgentDashboardDrawer'
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
import { cn } from '@/lib/utils'
import { FolderPlus, Loader2 } from 'lucide-react'
@@ -131,6 +132,26 @@ function Sidebar({
}
}, [closeWorkspaceBoard, sidebarOpen, workspaceBoardRenderedOpen])
const agentDashboardDrawerOpen = useAppStore((s) => s.agentDashboardDrawerOpen)
const setAgentDashboardDrawerOpen = useAppStore((s) => s.setAgentDashboardDrawerOpen)
useEffect(() => {
if (!sidebarOpen && agentDashboardDrawerOpen) {
setAgentDashboardDrawerOpen(false)
}
}, [agentDashboardDrawerOpen, setAgentDashboardDrawerOpen, sidebarOpen])
// Why: both companion boards expand into the same space beside the sidebar,
// so the most recently opened one dismisses the other.
useEffect(() => {
if (agentDashboardDrawerOpen) {
closeWorkspaceBoard()
}
}, [agentDashboardDrawerOpen, closeWorkspaceBoard])
useEffect(() => {
if (workspaceBoardRenderedOpen) {
setAgentDashboardDrawerOpen(false)
}
}, [setAgentDashboardDrawerOpen, workspaceBoardRenderedOpen])
const { containerRef, onResizeStart, isResizing } = useSidebarResize<HTMLDivElement>({
isOpen: sidebarOpen,
width: sidebarWidth,
@@ -232,6 +253,12 @@ function Sidebar({
onMenuOpenChange={setWorkspaceBoardMenuOpen}
/>
) : null}
{sidebarOpen && settings?.experimentalAgentDashboardPopout === true ? (
<AgentDashboardDrawer
leftSidebarStyle={leftSidebarStyle}
statusBarVisible={statusBarVisible}
/>
) : null}
</TooltipProvider>
)
}
@@ -0,0 +1,4 @@
// Why: companion board sheets portal to document.body, so they cannot inherit
// these bounds from layout — they must reserve the window chrome explicitly.
export const WORKSPACE_TOP_CHROME_HEIGHT = 36
export const STATUS_BAR_RESERVE_HEIGHT = 24
+12 -4
View File
@@ -5563,9 +5563,14 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents in a separate window and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard"
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard",
"modeLabel": "Open as",
"modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.",
"modeAriaLabel": "Agent Dashboard open mode",
"modeInWindow": "In-window",
"modePopout": "Pop-out"
}
},
"FloatingWorkspacePane": {
@@ -7796,7 +7801,7 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees."
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out."
}
}
},
@@ -13797,6 +13802,9 @@
},
"title": "Agents",
"total": "{{count}} total",
"close": "Close dashboard",
"settings": "Agent Dashboard settings",
"settingsTooltip": "Board settings",
"card": {
"you": "You",
"time": {
+13 -5
View File
@@ -5540,9 +5540,14 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents in a separate window and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard"
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard",
"modeLabel": "Open as",
"modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.",
"modeAriaLabel": "Agent Dashboard open mode",
"modeInWindow": "In-window",
"modePopout": "Pop-out"
}
},
"FloatingWorkspacePane": {
@@ -7736,7 +7741,7 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees."
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out."
}
}
},
@@ -13787,7 +13792,10 @@
"closed": "No live terminal — this agent's pane has closed.",
"focusWorktree": "Focus worktree",
"close": "Close"
}
},
"close": "Close dashboard",
"settings": "Agent Dashboard settings",
"settingsTooltip": "Board settings"
},
"dashboard": {
"sidebar": {
+13 -5
View File
@@ -5525,9 +5525,14 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents in a separate window and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard"
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard",
"modeLabel": "Open as",
"modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.",
"modeAriaLabel": "Agent Dashboard open mode",
"modeInWindow": "In-window",
"modePopout": "Pop-out"
}
},
"FloatingWorkspacePane": {
@@ -7758,7 +7763,7 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees."
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out."
}
}
},
@@ -13787,7 +13792,10 @@
"closed": "No live terminal — this agent's pane has closed.",
"focusWorktree": "Focus worktree",
"close": "Close"
}
},
"close": "Close dashboard",
"settings": "Agent Dashboard settings",
"settingsTooltip": "Board settings"
},
"dashboard": {
"sidebar": {
+13 -5
View File
@@ -5525,9 +5525,14 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents in a separate window and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard"
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard",
"modeLabel": "Open as",
"modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.",
"modeAriaLabel": "Agent Dashboard open mode",
"modeInWindow": "In-window",
"modePopout": "Pop-out"
}
},
"FloatingWorkspacePane": {
@@ -7721,7 +7726,7 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees."
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out."
}
}
},
@@ -13787,7 +13792,10 @@
"closed": "No live terminal — this agent's pane has closed.",
"focusWorktree": "Focus worktree",
"close": "Close"
}
},
"close": "Close dashboard",
"settings": "Agent Dashboard settings",
"settingsTooltip": "Board settings"
},
"dashboard": {
"sidebar": {
+13 -5
View File
@@ -5525,9 +5525,14 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents in a separate window and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard"
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out.",
"copy": "Adds an Agent Dashboard entry to the left sidebar. Open it to monitor attention, working, and idle agents and jump into their live terminals.",
"toggleLabel": "Toggle Agent Dashboard",
"modeLabel": "Open as",
"modeCopy": "Show the dashboard as an in-window board beside the sidebar or a separate pop-out window.",
"modeAriaLabel": "Agent Dashboard open mode",
"modeInWindow": "In-window",
"modePopout": "Pop-out"
}
},
"FloatingWorkspacePane": {
@@ -7721,7 +7726,7 @@
},
"agentDashboard": {
"title": "Agent Dashboard",
"description": "Pop-out Kanban board for monitoring agents across worktrees."
"description": "Kanban board for monitoring agents across worktrees, in-window or as a pop-out."
}
}
},
@@ -13787,7 +13792,10 @@
"closed": "No live terminal — this agent's pane has closed.",
"focusWorktree": "Focus worktree",
"close": "Close"
}
},
"close": "Close dashboard",
"settings": "Agent Dashboard settings",
"settingsTooltip": "Board settings"
},
"dashboard": {
"sidebar": {
+5
View File
@@ -887,6 +887,9 @@ export type UISlice = {
setWorkspaceBoardColumnWidth: (width: number) => void
syncTaskStatusFromWorkspaceBoard: boolean
setSyncTaskStatusFromWorkspaceBoard: (enabled: boolean) => void
/** Transient: the in-window Agent Dashboard companion drawer is open. Not persisted. */
agentDashboardDrawerOpen: boolean
setAgentDashboardDrawerOpen: (open: boolean) => void
statusBarItems: StatusBarItem[]
toggleStatusBarItem: (item: StatusBarItem) => void
statusBarVisible: boolean
@@ -2144,6 +2147,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
return { statusBarItems: updated }
}),
agentDashboardDrawerOpen: false,
setAgentDashboardDrawerOpen: (open) => set({ agentDashboardDrawerOpen: open }),
statusBarVisible: true,
setStatusBarVisible: (v) => {
window.api.ui.set({ statusBarVisible: v }).catch(console.error)
+2
View File
@@ -339,6 +339,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
experimentalPet: false,
experimentalActivity: false,
experimentalAgentDashboardPopout: false,
// Why: in-window screen popover is the default surface; users opt into a separate pop-out window.
experimentalAgentDashboardMode: 'in-window',
experimentalActivityDefaultedOffForAllUsers: true,
experimentalTerminalAttention: false,
experimentalAgentHibernation: false,
+5
View File
@@ -2599,6 +2599,9 @@ export type HostSettingOverrides = {
defaultWorktreeLocation?: string
}
/** Presentation mode for the experimental Agent Dashboard. */
export type AgentDashboardMode = 'in-window' | 'popout'
export type GlobalSettings = {
workspaceDir: string
/** Per-host overrides keyed by ExecutionHostId. Effective value for a
@@ -2914,6 +2917,8 @@ export type GlobalSettings = {
experimentalActivity: boolean
/** Experimental: pop-out Kanban dashboard for monitoring and opening agent terminals across worktrees. */
experimentalAgentDashboardPopout?: boolean
/** How the Agent Dashboard opens: an in-window companion board or a separate pop-out window. Defaults to in-window. */
experimentalAgentDashboardMode?: AgentDashboardMode
/** One-shot migration guard for defaulting the Agents view off; later explicit opt-ins persist normally. */
experimentalActivityDefaultedOffForAllUsers?: boolean
/** Experimental: persistent terminal-pane attention ring for bell + agent-completion events. Opt-in while tuning signal/noise. */