mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user