refactor: organize dashboard and popout modules by owner

This commit is contained in:
Neil
2026-11-13 21:49:19 -08:00
parent b5bfbf58c8
commit e69cafd4dd
141 changed files with 1086 additions and 4730 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
1 src/renderer/src/components/browser-pane/client-hosted-browser-load-failure.tsx
2 src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-context-menu.tsx
1 src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx
1 src/renderer/src/components/dashboard-popout/preview-terminal-links.ts
1 src/renderer/src/components/standalone-terminal/rendering/preview-terminal-links.ts
1 src/renderer/src/components/jira-connect-credential-guide.tsx
2 src/renderer/src/components/linear-api-key-dialog.tsx
1 src/renderer/src/components/native-chat/media/images/NativeChatMarkdownImage.tsx
+3 -3
View File
@@ -76,9 +76,9 @@
1 src/renderer/src/components/crash-report/CrashReportDialog.tsx
1 src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx
3 src/renderer/src/components/crash-report/use-crash-report-copy.ts
1 src/renderer/src/components/dashboard-popout/use-agent-kanban-lane-virtualizer.ts
1 src/renderer/src/components/dashboard/useAgentBucketCounts.ts
1 src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts
1 src/renderer/src/components/dashboard/board/use-agent-kanban-lane-virtualizer.ts
1 src/renderer/src/components/dashboard/bucket-counts/useAgentBucketCounts.ts
1 src/renderer/src/components/dashboard/snapshot/useLiveDashboardSnapshot.ts
2 src/renderer/src/components/diff-comments/DiffCommentCard.tsx
5 src/renderer/src/components/diff-comments/DiffCommentPopover.tsx
2 src/renderer/src/components/diff-comments/diff-comment-draft-zone.ts
+1 -13
View File
@@ -9775,7 +9775,7 @@
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-macos-keybinding-dict-trace.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-linux-fcitx4-trace-replay.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-xterm-resumed-preedit-visibility.test.ts src/renderer/src/components/terminal-pane/input/ime/terminal-ime-composer-placeholder-mask.test.ts src/shared/terminal/input/terminal-composer-draft.test.ts src/main/daemon/emulation/testing/terminal-cursor-line-context.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/main/daemon/providers/adapter/testing/daemon-pty-adapter.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts src/main/ipc/terminal-preview-output-stream.test.ts src/main/daemon/providers/adapter/testing/daemon-pty-adapter.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/input/keyboard/terminal-option-kitty-release.test.ts src/main/ipc/macos-keyboard-layout-change-notifications.test.ts src/renderer/src/web/web-preload-api-ui.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"pnpm run test:e2e -- tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts",
@@ -9793,7 +9793,6 @@
"src/renderer/src/components/terminal-pane/input/keyboard/terminal-option-kitty-release.test.ts",
"src/main/ipc/macos-keyboard-layout-change-notifications.test.ts",
"src/renderer/src/web/web-preload-api-ui.test.ts",
"src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts",
"src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts",
"src/main/ipc/terminal-preview-output-stream.test.ts",
"src/main/daemon/providers/adapter/testing/daemon-pty-adapter.test.ts",
@@ -9835,17 +9834,6 @@
"rapid input-source changes coalesce to one latest-generation refresh signal"
]
},
{
"file": "src/renderer/src/components/dashboard-popout/preview-terminal-ime-bridge-kitty-bytes.test.ts",
"assertions": [
"the real forwarder behind the Preview bridge encodes the commit from the snapshot-proven flags before any live output",
"flags 0/1 commit raw text, flags 2 add one CSI-u release, flags 8 emit one CSI-u press, and flags 10 emit a CSI-u press followed by exactly one release",
"both keydown->insertText->keyup and keydown->keyup->insertText orderings produce the same bytes",
"the release is emitted by the forwarder while xterm's internal kitty flags stay 0",
"the mutable flags getter is read exactly once, at commit time, never on keydown or keyup",
"auto-repeat, two-key rollover, a swallowed commit, a retired claim, blur, and disposal each emit at most one release and never synthesize one"
]
},
{
"file": "src/shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker.test.ts",
"assertions": [
+1 -1
View File
@@ -5,7 +5,7 @@
3 src/renderer/src/attention/notification-subject-owner.ts
1 src/renderer/src/components/activity/use-agent-pane-threads.ts
3 src/renderer/src/components/cmd-j/palette-live-status.tsx
2 src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts
2 src/renderer/src/components/dashboard/rows/worktree-agent-rows-cache.ts
1 src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-activity-status.ts
1 src/renderer/src/components/sidebar/workspace-card/activity/use-worktree-activity-statuses.ts
4 src/renderer/src/components/sidebar/workspace-card/activity/worktree-card-send-target-inputs.ts
+5 -5
View File
@@ -5,11 +5,11 @@
# fields leave the store they read undefined and silently fall back, so P5b-2 must move them.
# Prune after a fix: pnpm check:workspace-record-read-ratchet --prune
3 src/renderer/src/attention/notification-subject-owner.ts # P5b-2 must clear: loosely typed state param
4 src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts # P5b-2 must clear: optional state fields; memo compare goes stale when absent
3 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts # P5b-2 must clear: optional state fields default to empty
1 src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts # P5b-2 must clear: optional state field defaults to empty
4 src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts # P5b-2 must clear: change detection on optional fields never fires when absent
2 src/renderer/src/components/dashboard/useRetainedAgents.ts # reads a passed-in copy
4 src/renderer/src/components/dashboard/bucket-counts/build-dashboard-bucket-counts.ts # P5b-2 must clear: optional state fields; memo compare goes stale when absent
3 src/renderer/src/components/dashboard/snapshot/dashboard-card-terminal-input.ts # P5b-2 must clear: optional state fields default to empty
1 src/renderer/src/components/dashboard/snapshot/dashboard-snapshot-workspaces.ts # P5b-2 must clear: optional state field defaults to empty
4 src/renderer/src/components/dashboard/popout-bridge/useDashboardPopoutBridge.ts # P5b-2 must clear: change detection on optional fields never fires when absent
2 src/renderer/src/components/dashboard/retention/useRetainedAgents.ts # reads a passed-in copy
1 src/renderer/src/components/editor/markdown/documents/markdown-document-worktree-path-selector.ts # P5b-2 must clear: optional state field
2 src/renderer/src/components/right-sidebar/file-explorer/watching/useFileExplorerWatch.ts # P5b-2 must clear: optional state fields default to empty
1 src/renderer/src/components/sidebar/project-admission/completion/add-repo-skip-finalization.ts # P5b-2 must clear: loosely typed state param
@@ -2,7 +2,7 @@ import { Suspense } from 'react'
import { lazyWithRetry as lazy } from '@/app-shell/startup/lazy-with-retry'
import { AgentHibernationGate } from '../components/AgentHibernationGate'
import { AiVaultTabTitleSyncGate } from '../components/AiVaultTabTitleSyncGate'
import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGate'
import RetainedAgentsSyncGate from '../components/dashboard/retention/RetainedAgentsSyncGate'
import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner'
import { SavedHostScopeWidenGate } from '../components/sidebar/worktree-list/listing/SavedHostScopeWidenGate'
import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost'
@@ -14,7 +14,9 @@ import { StructuredAgentSessionStatusBridge } from '../components/native-chat/se
import { NativeChatVisualGuestThemePublisher } from '../components/native-chat/media/visuals/NativeChatVisualGuestThemePublisher'
import { usePairedMobileDevices } from '../components/mobile/paired-mobile-devices'
const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge'))
const DashboardPopoutBridge = lazy(
() => import('../components/dashboard/popout-bridge/DashboardPopoutBridge')
)
const AgentListVerdictPublisher = lazy(
() => import('../components/activity/agent-list-verdict-publisher')
)
@@ -40,7 +40,7 @@ import type { AppChromeLayout } from './use-app-chrome-layout'
import type { FloatingWorkspacePanelState } from './use-floating-workspace-panel'
import { addAppKeyListener, removeAppKeyListener } from '@/lib/keyboard/app-keyboard-router'
import { textInputOwnsComposition } from '../../../shared/text-input-shortcut-policy'
import { isAgentTerminalPreviewTarget } from '../components/dashboard-popout/agent-terminal-preview-target'
import { isAgentTerminalPreviewTarget } from '../components/dashboard/terminal-preview/agent-terminal-preview-target'
/** Register window listeners once; each key event reads the latest committed shortcut state through a ref. */
export function useGlobalKeybindings(args: {
@@ -3,7 +3,7 @@ import {
buildAgentRowLineageTree,
resolveAgentRowParentPaneKey,
type AgentLineageSourceRow
} from '../dashboard/agent-row-lineage-model'
} from '../dashboard/rows/agent-row-lineage-model'
import { isRunningActivityState } from './activity-event-state'
import type { ActivityLiveAgentState } from './activity-thread-types'
@@ -1,116 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { i18n } from '@/i18n/i18n'
import { AgentKanbanBoard } from './AgentKanbanBoard'
// Stub card/dialog internals while retaining the board’s dialog wiring.
vi.mock('./AgentKanbanCard', () => ({
AgentKanbanCard: ({
card,
repoIcon,
onOpenTerminal
}: {
card: DashboardCard
repoIcon?: RepoIcon | null
onOpenTerminal: (card: DashboardCard) => void
}) => (
<div
data-testid="card"
data-bucket={card.bucket}
data-unseen={card.unseen}
data-repo-icon={repoIcon === null ? 'none' : JSON.stringify(repoIcon)}
onClick={() => onOpenTerminal(card)}
>
{card.worktreeName}
</div>
)
}))
vi.mock('./AgentTerminalDialog', () => ({
AgentTerminalDialog: ({
card,
onOpenChange
}: {
card: DashboardCard | null
onOpenChange: (open: boolean) => void
}) => (
<div
data-testid="terminal-dialog"
data-open={card !== null}
data-bucket={card?.bucket}
data-pty-id={card?.ptyId ?? undefined}
>
<button data-testid="terminal-dialog-close" onClick={() => onOpenChange(false)} />
</div>
)
}))
function card(overrides: Partial<DashboardCard>): DashboardCard {
return {
paneKey: Math.random().toString(36),
ptyId: 'p1',
agentType: 'claude',
bucket: 'working',
dotState: 'working',
task: 't',
repoId: 'r1',
worktreeId: 'w1',
tabId: 'tab1',
leafId: 'l1',
repoName: 'Repo',
worktreeName: 'wt',
startedAt: 0,
finishedAt: null,
stateChangedAt: 0,
unseen: false,
...overrides
}
}
const ackAgent = vi.fn(async () => {})
describe('AgentKanbanBoard', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
localStorage.clear()
vi.stubGlobal(
'matchMedia',
vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }))
)
;(window as unknown as { api: unknown }).api = { dashboard: { ackAgent } }
// Give virtual lanes a viewport; happy-dom has no layout.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('keeps the terminal dialog open across bucket moves and card removal', () => {
const agent = card({ paneKey: 'pk-1', bucket: 'done', worktreeName: 'wt1' })
const { rerender } = render(
<AgentKanbanBoard snapshot={{ generatedAt: 1, cards: [agent], showIdle: true }} />
)
expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('false')
fireEvent.click(screen.getByTestId('card'))
expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true')
// Replying moves done agents to working; the dialog must survive the bucket change.
const moved = { ...agent, bucket: 'working' as const, dotState: 'working' as const }
rerender(<AgentKanbanBoard snapshot={{ generatedAt: 2, cards: [moved] }} />)
expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true')
expect(screen.getByTestId('terminal-dialog').dataset.bucket).toBe('working')
// Keep vanished-card dialogs open until dismissed, but clear stale live routing.
rerender(<AgentKanbanBoard snapshot={{ generatedAt: 3, cards: [] }} />)
expect(screen.getByTestId('terminal-dialog').dataset.open).toBe('true')
expect(screen.getByTestId('terminal-dialog').dataset.ptyId).toBeUndefined()
})
})
@@ -1,126 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { useState } from 'react'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { TooltipProvider } from '@/components/ui/tooltip'
import { i18n } from '@/i18n/i18n'
import { AgentKanbanLaneCardList } from './AgentKanbanLaneCardList'
function laneCards(count: number): DashboardCard[] {
return Array.from({ length: count }, (_, index) => ({
paneKey: `pane-${index}`,
ptyId: `pty-${index}`,
agentType: 'claude',
bucket: 'working',
dotState: 'working',
task: 'task',
repoId: 'r1',
worktreeId: `w${index}`,
tabId: 'tab1',
leafId: 'l1',
repoName: 'Repo',
worktreeName: `card-${String(index).padStart(2, '0')}`,
startedAt: 0,
finishedAt: null,
stateChangedAt: 0,
unseen: false,
subagents: index === 1 ? [{ id: 's1', name: 'helper', dotState: 'working' }] : undefined
}))
}
function Lane({ cards }: { cards: DashboardCard[] }): React.JSX.Element {
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
return (
<TooltipProvider>
<div ref={setScrollElement} data-testid="scroller">
<AgentKanbanLaneCardList
cards={cards}
scrollElement={scrollElement}
repoIconsByRepoId={undefined}
now={0}
dialogPaneKey={null}
onOpenTerminal={vi.fn()}
/>
</div>
</TooltipProvider>
)
}
function card(position: number): HTMLElement {
const item = screen
.getAllByRole('listitem')
.find((row) => row.getAttribute('aria-posinset') === String(position))
if (!item) {
throw new Error(`Expected card ${position} to be mounted`)
}
return item
}
function scrollTo(top: number): void {
const scroller = screen.getByTestId('scroller')
act(() => {
scroller.scrollTop = top
fireEvent.scroll(scroller)
})
}
describe('AgentKanbanLaneCardList', () => {
beforeEach(async () => {
await i18n.changeLanguage('en')
// Layout stub: a 300px lane of 100px cards.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
return this.dataset.testid === 'scroller' ? 300 : this.hasAttribute('data-index') ? 100 : 0
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('tabs to the logical neighbour card instead of the next mounted card', () => {
render(<Lane cards={laneCards(40)} />)
expect(screen.queryByText('card-20')).toBeNull()
// Card 9 is offscreen while the last card is mounted next in DOM order.
const lastControl = within(card(9)).getAllByRole('button').at(-1)!
lastControl.focus()
fireEvent.keyDown(lastControl, { key: 'Tab' })
expect(document.activeElement).toHaveTextContent('card-09')
const lastCardFirstControl = within(card(40)).getAllByRole('button')[0]
lastCardFirstControl.focus()
fireEvent.keyDown(lastCardFirstControl, { key: 'Tab', shiftKey: true })
expect(document.activeElement).toBe(within(card(39)).getAllByRole('button').at(-1))
// Mounted logical neighbours keep native traversal.
const firstCardLastControl = within(card(1)).getAllByRole('button').at(-1)!
expect(fireEvent.keyDown(firstCardLastControl, { key: 'Tab' })).toBe(true)
})
it('keeps subagent disclosure while scrolled away and resets it when the card leaves the lane', () => {
const cards = laneCards(40)
const { rerender } = render(<Lane cards={cards} />)
fireEvent.click(screen.getByRole('button', { name: '1 subagent' }))
expect(screen.getByText('helper')).toBeInTheDocument()
scrollTo(2000)
expect(screen.queryByText('card-01')).toBeNull()
scrollTo(0)
expect(screen.getByRole('button', { name: '1 subagent' })).toHaveAttribute(
'aria-expanded',
'true'
)
rerender(<Lane cards={cards.filter((laneCard) => laneCard.paneKey !== 'pane-1')} />)
rerender(<Lane cards={cards} />)
expect(screen.getByRole('button', { name: '1 subagent' })).toHaveAttribute(
'aria-expanded',
'false'
)
})
})
@@ -1,125 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type {
DashboardCard,
DashboardCardTerminalInput
} from '../../../../shared/dashboard-snapshot'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { AgentTerminalDialog, AgentTerminalPanel } from './AgentTerminalDialog'
// Stub xterm/IPC to inspect the routing props passed to the preview.
vi.mock('./AgentTerminalPreview', () => ({
AgentTerminalPreview: ({
ptyId,
terminalInput,
className,
onBrowserForeground
}: {
ptyId: string
terminalInput?: DashboardCardTerminalInput | null
className?: string
onBrowserForeground?: () => void
}) => (
<div
data-testid="preview"
data-pty-id={ptyId}
data-terminal-input={terminalInput === null ? 'null' : JSON.stringify(terminalInput)}
className={className}
>
{onBrowserForeground ? (
<button onClick={onBrowserForeground}>Completed browser handoff</button>
) : null}
</div>
)
}))
function card(overrides: Partial<DashboardCard> = {}): DashboardCard {
return {
paneKey: 'tab1:leaf1',
ptyId: 'pty-1',
agentType: 'claude',
bucket: 'working',
dotState: 'working',
task: 'task',
repoId: 'r1',
worktreeId: 'w1',
tabId: 'tab1',
leafId: 'leaf1',
repoName: 'Repo',
worktreeName: 'wt',
startedAt: 0,
finishedAt: null,
stateChangedAt: 0,
unseen: false,
...overrides
}
}
afterEach(() => {
cleanup()
})
// This prop is the only path carrying relayed host-input facts into the preview; omission silently falls back to client-OS routing.
describe('AgentTerminalDialog', () => {
it('lets a nested Radix layer consume Escape before closing the panel', () => {
const onOpenChange = vi.fn()
render(
<>
<AgentTerminalPanel card={card()} onOpenChange={onOpenChange} onReveal={() => {}} />
<Popover defaultOpen>
<PopoverTrigger>Details</PopoverTrigger>
<PopoverContent>Worktree details</PopoverContent>
</Popover>
</>
)
fireEvent.keyDown(screen.getByText('Worktree details'), { key: 'Escape' })
expect(screen.queryByText('Worktree details')).not.toBeInTheDocument()
expect(onOpenChange).not.toHaveBeenCalled()
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})
it.each([false, true])(
'restores dialog focus only without a confirmed host handoff (%s)',
async (handoff) => {
function Host() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Launch preview</button>
<button id="browser-destination">Browser destination</button>
<AgentTerminalDialog
card={open ? card() : null}
onOpenChange={setOpen}
onReveal={() => {}}
onBrowserForeground={
handoff ? () => document.getElementById('browser-destination')?.focus() : undefined
}
/>
</>
)
}
render(<Host />)
const launch = screen.getByRole('button', { name: 'Launch preview' })
launch.focus()
fireEvent.click(launch)
await waitFor(() => expect(screen.getByTestId('preview')).toBeInTheDocument())
fireEvent.click(
screen.getByRole('button', { name: handoff ? 'Completed browser handoff' : 'Close' })
)
await waitFor(() => expect(screen.queryByTestId('preview')).not.toBeInTheDocument())
await waitFor(() =>
expect(document.activeElement).toBe(
handoff ? document.getElementById('browser-destination') : launch
)
)
}
)
@@ -1,234 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import type { ITerminalOptions } from '@xterm/xterm'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@/components/terminal-pane/input/paste/terminal-bracketed-paste'
import { dispatchAppMenuPasteEvent } from '@/lib/clipboard/menu/app-menu-paste'
const terminalHarness = vi.hoisted(() => ({
instances: [] as {
write: ReturnType<typeof vi.fn>
writeCallbacks: (() => void)[]
onDataListener: ((data: string) => void) | null
dispose: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
paste: ReturnType<typeof vi.fn>
input: ReturnType<typeof vi.fn>
scrollToTop: ReturnType<typeof vi.fn>
scrollToBottom: ReturnType<typeof vi.fn>
selectAll: ReturnType<typeof vi.fn>
modes: { bracketedPasteMode: boolean }
selectionText: string
customKeyHandler: ((event: KeyboardEvent) => boolean) | null
}[],
userInputListener: null as (() => void) | null,
userInputDispose: vi.fn()
}))
const platformState = vi.hoisted(() => ({ value: 'linux' }))
const storeState = vi.hoisted(() => ({
settings: null as { terminalRightClickToPaste?: boolean } | null,
keybindings: {} as Record<string, string[]>
}))
const imeHarness = vi.hoisted(() => ({
forwarders: [] as {
claimKeyEvent: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
sendInput: (data: string) => void
getKittyKeyboardFlags: () => number
}[],
trackers: [] as { dispose: ReturnType<typeof vi.fn> }[],
claimResult: false
}))
vi.mock('@xterm/xterm', () => ({
Terminal: class {
cols = 80
rows = 24
buffer = { active: { cursorY: 0 } }
writeCallbacks: (() => void)[] = []
onDataListener: ((data: string) => void) | null = null
customKeyHandler: ((event: KeyboardEvent) => boolean) | null = null
selectionText = ''
write = vi.fn((_data: string, callback?: () => void) => {
if (callback) {
this.writeCallbacks.push(callback)
}
})
open = vi.fn()
focus = vi.fn()
dispose = vi.fn()
resize = vi.fn()
reset = vi.fn()
modes = { bracketedPasteMode: false }
paste = vi.fn((data: string) => {
terminalHarness.userInputListener?.()
this.onDataListener?.(data)
})
input = vi.fn((data: string) => {
terminalHarness.userInputListener?.()
this.onDataListener?.(data)
})
element = document.createElement('div')
unicode = { activeVersion: '6', versions: ['6', '11'], register: vi.fn() }
loadAddon = vi.fn()
options: ITerminalOptions = {}
attachCustomWheelEventHandler = vi.fn()
scrollToTop = vi.fn()
scrollToBottom = vi.fn()
selectAll = vi.fn()
getSelection = vi.fn(() => this.selectionText)
hasSelection = vi.fn(() => this.selectionText !== '')
attachCustomKeyEventHandler = vi.fn((handler: (event: KeyboardEvent) => boolean) => {
this.customKeyHandler = handler
})
onData = vi.fn((listener: (data: string) => void) => {
this.onDataListener = listener
return { dispose: vi.fn() }
})
constructor() {
terminalHarness.instances.push(this)
}
}
}))
vi.mock(import('@/lib/pane-manager/pane-terminal-options'), async (importOriginal) => ({
...(await importOriginal()),
buildDefaultTerminalOptions: () => ({})
}))
vi.mock('@/components/terminal-pane/input/terminal-user-input-signal', () => ({
subscribeToTerminalUserInput: (_terminal: unknown, listener: () => void) => {
terminalHarness.userInputListener = listener
return { dispose: terminalHarness.userInputDispose }
}
}))
vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({
useSystemPrefersDark: () => false
}))
vi.mock('@/lib/keyboard/shortcut-platform', () => ({
getShortcutPlatform: () => platformState.value
}))
vi.mock('@/components/terminal-pane/input/ime/terminal-ime-native-text-forwarder', () => ({
installTerminalImeNativeTextForwarder: (args: {
sendInput: (data: string) => void
getKittyKeyboardFlags?: () => number
}) => {
const forwarder = {
claimKeyEvent: vi.fn(() => imeHarness.claimResult),
dispose: vi.fn(),
sendInput: args.sendInput,
// Read the same live keyboard-mode mirror the real commit forwarder receives.
getKittyKeyboardFlags: args.getKittyKeyboardFlags ?? ((): number => 0)
}
imeHarness.forwarders.push(forwarder)
return forwarder
}
}))
vi.mock('@/components/terminal-pane/input/ime/terminal-ime-composition-tracker', () => ({
installTerminalImeCompositionTracker: () => {
const tracker = { isActive: () => false, dispose: vi.fn() }
imeHarness.trackers.push(tracker)
return tracker
}
}))
vi.mock('@/store', () => {
const useAppStore = (selector: (s: typeof storeState) => unknown): unknown => selector(storeState)
useAppStore.getState = (): typeof storeState => storeState
return { useAppStore }
})
vi.mock('./terminal-preview-api', () => ({
terminalPreviewApiFor: () => window.api.terminalPreview
}))
import { AgentTerminalPreview } from './AgentTerminalPreview'
describe('AgentTerminalPreview clipboard routes', () => {
const input = vi.fn(async (_ptyId: string, _data: string) => true)
const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows }))
const ack = vi.fn(async () => {})
const unsubscribe = vi.fn(async () => {})
const connect = vi.fn()
const readClipboardText = vi.fn(async () => 'clip-text')
const writeClipboardText = vi.fn(async () => {})
const writeTerminalClipboardText = vi.fn(async () => {})
const performNativeSelectionAction = vi.fn()
/** Focuses a stand-in for xterm's helper textarea inside the preview container. */
const focusInsidePreview = (container: HTMLElement, tagName = 'input'): HTMLElement => {
const host = container.querySelector<HTMLElement>('.origin-bottom-left')!
const focusTarget = document.createElement(tagName)
host.appendChild(focusTarget)
focusTarget.focus()
return focusTarget
}
beforeEach(() => {
terminalHarness.instances.length = 0
terminalHarness.userInputListener = null
platformState.value = 'linux'
storeState.keybindings = {}
imeHarness.forwarders.length = 0
imeHarness.trackers.length = 0
imeHarness.claimResult = false
storeState.settings = null
connect.mockResolvedValue({
snapshot: { data: '', cols: 80, rows: 24, seq: 1 },
replay: []
})
readClipboardText.mockResolvedValue('clip-text')
Object.assign(window, {
api: {
terminalPreview: {
connect,
input,
fit,
ack,
unsubscribe,
onData: () => vi.fn()
},
ui: {
readClipboardText,
writeClipboardText,
writeTerminalClipboardText,
performNativeSelectionAction
}
}
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('cancels an async paste when the preview loses focus', async () => {
let resolveClipboard!: (text: string) => void
readClipboardText.mockReturnValueOnce(
new Promise((resolve) => {
resolveClipboard = resolve
})
)
const view = render(<AgentTerminalPreview ptyId="pty-1" />)
await waitFor(() => expect(terminalHarness.instances).toHaveLength(1))
const terminal = terminalHarness.instances[0]!
focusInsidePreview(view.container)
const outsideInput = document.createElement('input')
view.container.appendChild(outsideInput)
act(() => {
dispatchAppMenuPasteEvent()
})
outsideInput.focus()
await act(async () => resolveClipboard('stale text'))
expect(terminal.paste).not.toHaveBeenCalled()
expect(input).not.toHaveBeenCalled()
})
})
@@ -1,229 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { ITerminalOptions } from '@xterm/xterm'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
import { useAppStore } from '@/store'
import { AgentTerminalPreview } from './AgentTerminalPreview'
type PreviewInstance = {
options: ITerminalOptions
container: HTMLElement | null
dispose: ReturnType<typeof vi.fn>
writeCallbacks: (() => void)[]
}
const harness = vi.hoisted(() => ({
instances: new Array<PreviewInstance>(),
deferWrites: false
}))
vi.mock('@xterm/xterm', () => ({
Terminal: class {
cols = 80
rows = 24
buffer = { active: { cursorY: 14 } }
container: HTMLElement | null = null
screen = document.createElement('div')
writeCallbacks: (() => void)[] = []
write = vi.fn((_data: string, callback?: () => void) => {
if (!callback) {
return
}
if (harness.deferWrites) {
this.writeCallbacks.push(callback)
} else {
callback()
}
})
focus = vi.fn()
resize = vi.fn()
reset = vi.fn()
onData = vi.fn(() => ({ dispose: vi.fn() }))
dispose = vi.fn(() => this.screen.remove())
constructor(public options: ITerminalOptions) {
harness.instances.push(this)
}
open(container: HTMLElement): void {
this.container = container
this.screen.className = 'xterm-screen'
Object.defineProperties(this.screen, {
offsetWidth: {
get: () =>
this.cols *
(this.options.fontSize === 18 ||
this.options.fontFamily?.includes('Fira Code') ||
this.options.fontWeight === 900 ||
this.options.fontWeightBold === 900
? 12
: 10)
},
offsetHeight: { get: () => this.rows * 16 * (this.options.lineHeight ?? 1) }
})
const box = container.parentElement
if (!box) {
throw new Error('Missing preview box')
}
Object.defineProperties(box, {
clientWidth: { configurable: true, value: 600 },
clientHeight: { configurable: true, value: 240 }
})
container.append(this.screen)
}
}
}))
vi.mock('@/components/terminal-pane/input/terminal-user-input-signal', () => ({
subscribeToTerminalUserInput: () => ({ dispose: vi.fn() })
}))
vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({
useSystemPrefersDark: () => false
}))
vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({
useEffectiveMacOptionAsAlt: (value: string) => value
}))
vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() }))
vi.mock('./preview-terminal-compatibility', () => ({
installPreviewTerminalCompatibility: () => vi.fn()
}))
vi.mock('./preview-terminal-ime-bridge', () => ({
installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() })
}))
vi.mock('./preview-terminal-key-handler', () => ({
installPreviewTerminalKeyHandler: () => vi.fn()
}))
vi.mock('@/components/terminal-pane/clipboard/terminal-native-copy-gutter', () => ({
installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() })
}))
vi.mock('./preview-terminal-app-menu-clipboard', () => ({
installPreviewTerminalAppMenuClipboard: () => vi.fn()
}))
vi.mock('./preview-terminal-right-click-paste', () => ({
installPreviewTerminalRightClickPaste: () => vi.fn()
}))
const initial = useAppStore.getInitialState()
const connect = vi.fn<Window['api']['terminalPreview']['connect']>()
const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows }))
const unsubscribe = vi.fn(async () => {})
let settings: GlobalSettings
let originalApi: PropertyDescriptor | undefined
const frames = new Map<number, FrameRequestCallback>()
let nextFrame = 0
const cancelFrame = vi.fn((id: number) => frames.delete(id))
function flushFrames(): void {
for (const [id, callback] of frames) {
frames.delete(id)
callback(16)
}
}
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
harness.instances.length = 0
harness.deferWrites = false
frames.clear()
nextFrame = 0
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextFrame++
frames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', cancelFrame)
// No resize notification or later output: replay must perform its own fit and grid claim.
vi.stubGlobal(
'ResizeObserver',
class {
observe = vi.fn()
disconnect = vi.fn()
}
)
settings = createGlobalSettingsFixture({
theme: 'dark',
activeRuntimeEnvironmentId: null,
terminalFontSize: 14,
terminalFontFamily: 'JetBrains Mono',
terminalFontWeight: 500,
terminalFontWeightBold: 700,
terminalLineHeight: 1,
terminalLigatures: 'off'
})
connect
.mockReset()
.mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] })
originalApi = Object.getOwnPropertyDescriptor(window, 'api')
Object.defineProperty(window, 'api', {
configurable: true,
value: {
settings: {
set: async (updates: Partial<GlobalSettings>) => {
settings = structuredClone({ ...settings, ...updates })
return settings
}
},
terminalPreview: { connect, fit, unsubscribe, onData: () => vi.fn() }
}
})
useAppStore.setState({ ...initial, settings }, true)
})
afterEach(() => {
cleanup()
for (const instance of harness.instances) {
expect(instance.dispose).toHaveBeenCalledOnce()
}
useAppStore.setState(initial, true)
if (originalApi) {
Object.defineProperty(window, 'api', originalApi)
} else {
Reflect.deleteProperty(window, 'api')
}
frames.clear()
vi.unstubAllGlobals()
vi.useRealTimers()
})
it('does not schedule an old parsed write after actual unmount', async () => {
harness.deferWrites = true
const view = render(<AgentTerminalPreview ptyId="ssh:host@@pty-late" />)
await act(async () => {})
const terminal = harness.instances[0]
expect(terminal).toBeDefined()
flushFrames()
view.unmount()
for (const callback of terminal?.writeCallbacks.splice(0) ?? []) {
callback()
}
expect(connect).toHaveBeenCalledOnce()
expect(unsubscribe).toHaveBeenCalledOnce()
expect(terminal?.dispose).toHaveBeenCalledOnce()
expect(frames.size).toBe(0)
})
it('retains the replacement frame on the same DOM and ignores retired parsed writes', async () => {
harness.deferWrites = true
render(<AgentTerminalPreview ptyId="pty-1" />)
await act(async () => {})
const old = harness.instances[0]
await act(async () => useAppStore.getState().updateSettings({ terminalFontSize: 18 }))
const replacement = harness.instances[1]
const pending = frames.size
for (const callback of old?.writeCallbacks.splice(0) ?? []) {
callback()
}
const afterOldWrites = frames.size
flushFrames()
await act(async () => {
await vi.advanceTimersByTimeAsync(200)
})
expect(connect).toHaveBeenCalledTimes(2)
expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('pty-1')
expect(old?.dispose).toHaveBeenCalledOnce()
expect(replacement?.container).toBe(old?.container)
expect(replacement?.container?.style.transform).toBe('scale(0.625)')
expect(replacement?.container?.style.transformOrigin).toBe('top left')
expect(fit).toHaveBeenCalledExactlyOnceWith('pty-1', 50, 15)
expect(frames.size).toBe(0)
expect([pending, afterOldWrites]).toEqual([1, 1])
})
@@ -1,167 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { ITerminalOptions } from '@xterm/xterm'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TerminalPreviewConnectResult } from '../../../../shared/terminal/replay/terminal-preview'
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
import { useAppStore } from '@/store'
import { AgentTerminalPreview } from './AgentTerminalPreview'
type PreviewInstance = {
options: ITerminalOptions
container: HTMLElement | null
dispose: ReturnType<typeof vi.fn>
}
const harness = vi.hoisted(() => ({ instances: new Array<PreviewInstance>() }))
vi.mock('@xterm/xterm', () => ({
Terminal: class {
cols = 80
rows = 24
buffer = { active: { cursorY: 14 } }
container: HTMLElement | null = null
screen = document.createElement('div')
write = vi.fn((_data: string, callback?: () => void) => callback?.())
focus = vi.fn()
resize = vi.fn()
reset = vi.fn()
onData = vi.fn(() => ({ dispose: vi.fn() }))
dispose = vi.fn(() => this.screen.remove())
constructor(public options: ITerminalOptions) {
harness.instances.push(this)
}
open(container: HTMLElement): void {
this.container = container
this.screen.className = 'xterm-screen'
Object.defineProperties(this.screen, {
offsetWidth: {
get: () => this.cols * (this.options.fontSize === 18 ? 12 : 10)
},
offsetHeight: { get: () => this.rows * 16 * (this.options.lineHeight ?? 1) }
})
const box = container.parentElement
if (!box) {
throw new Error('Missing preview box')
}
Object.defineProperties(box, {
clientWidth: { configurable: true, value: 600 },
clientHeight: { configurable: true, value: 240 }
})
container.append(this.screen)
}
}
}))
vi.mock('@/components/terminal-pane/input/terminal-user-input-signal', () => ({
subscribeToTerminalUserInput: () => ({ dispose: vi.fn() })
}))
vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({
useSystemPrefersDark: () => false
}))
vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({
useEffectiveMacOptionAsAlt: (value: string) => value
}))
vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() }))
vi.mock('./preview-terminal-compatibility', () => ({
installPreviewTerminalCompatibility: () => vi.fn()
}))
vi.mock('./preview-terminal-ime-bridge', () => ({
installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() })
}))
vi.mock('./preview-terminal-key-handler', () => ({
installPreviewTerminalKeyHandler: () => vi.fn()
}))
vi.mock('@/components/terminal-pane/clipboard/terminal-native-copy-gutter', () => ({
installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() })
}))
vi.mock('./preview-terminal-app-menu-clipboard', () => ({
installPreviewTerminalAppMenuClipboard: () => vi.fn()
}))
vi.mock('./preview-terminal-right-click-paste', () => ({
installPreviewTerminalRightClickPaste: () => vi.fn()
}))
const initial = useAppStore.getInitialState()
const connect = vi.fn<Window['api']['terminalPreview']['connect']>()
const fit = vi.fn(async (_ptyId: string, cols: number, rows: number) => ({ cols, rows }))
const unsubscribe = vi.fn(async () => {})
let settings: GlobalSettings
let originalApi: PropertyDescriptor | undefined
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
harness.instances.length = 0
// No resize notification or later output: replay must perform its own fit and grid claim.
vi.stubGlobal(
'ResizeObserver',
class {
observe = vi.fn()
disconnect = vi.fn()
}
)
settings = createGlobalSettingsFixture({
theme: 'dark',
activeRuntimeEnvironmentId: null,
terminalFontSize: 14,
terminalFontFamily: 'JetBrains Mono',
terminalFontWeight: 500,
terminalFontWeightBold: 700,
terminalLineHeight: 1,
terminalLigatures: 'off'
})
connect
.mockReset()
.mockResolvedValue({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] })
originalApi = Object.getOwnPropertyDescriptor(window, 'api')
Object.defineProperty(window, 'api', {
configurable: true,
value: {
settings: {
set: async (updates: Partial<GlobalSettings>) => {
settings = structuredClone({ ...settings, ...updates })
return settings
}
},
terminalPreview: { connect, fit, unsubscribe, onData: () => vi.fn() }
}
})
useAppStore.setState({ ...initial, settings }, true)
})
afterEach(() => {
cleanup()
for (const instance of harness.instances) {
expect(instance.dispose).toHaveBeenCalledOnce()
}
useAppStore.setState(initial, true)
if (originalApi) {
Object.defineProperty(window, 'api', originalApi)
} else {
Reflect.deleteProperty(window, 'api')
}
vi.unstubAllGlobals()
vi.useRealTimers()
})
async function settleGeometry(): Promise<void> {
await act(async () => {
await vi.advanceTimersByTimeAsync(250)
})
}
it('ignores a pending connection retired by a metric change', async () => {
const gate = Promise.withResolvers<TerminalPreviewConnectResult>()
connect.mockReturnValueOnce(gate.promise)
render(<AgentTerminalPreview ptyId="pty-1" />)
await act(async () => useAppStore.getState().updateSettings({ terminalFontSize: 18 }))
await settleGeometry()
await act(async () =>
gate.resolve({ snapshot: { data: '', cols: 80, rows: 24, seq: 1 }, replay: [] })
)
await settleGeometry()
expect(connect).toHaveBeenCalledTimes(2)
expect(harness.instances).toHaveLength(1)
expect(harness.instances[0]?.options.fontSize).toBe(18)
})
@@ -1,178 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ITerminalOptions } from '@xterm/xterm'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { TerminalPreviewConnectResult } from '../../../../shared/terminal/replay/terminal-preview'
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
import { useAppStore } from '@/store'
import { AgentTerminalPreview } from './AgentTerminalPreview'
type PreviewInstance = {
options: ITerminalOptions
dispose: ReturnType<typeof vi.fn>
reset: ReturnType<typeof vi.fn>
}
const harness = vi.hoisted(() => ({
instances: new Array<PreviewInstance>(),
systemPrefersDark: false
}))
vi.mock('@xterm/xterm', () => ({
Terminal: class {
cols = 80
rows = 24
buffer = { active: { cursorY: 0 } }
options: ITerminalOptions
write = vi.fn((_data: string, callback?: () => void) => callback?.())
open = vi.fn()
focus = vi.fn()
dispose = vi.fn()
resize = vi.fn()
reset = vi.fn()
onData = vi.fn(() => ({ dispose: vi.fn() }))
constructor(options: ITerminalOptions) {
this.options = options
harness.instances.push(this)
}
}
}))
vi.mock('@/components/terminal-pane/input/terminal-user-input-signal', () => ({
subscribeToTerminalUserInput: () => ({ dispose: vi.fn() })
}))
vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({
useSystemPrefersDark: () => harness.systemPrefersDark
}))
vi.mock('@/lib/keyboard-layout/use-effective-mac-option-as-alt', () => ({
useEffectiveMacOptionAsAlt: (value: string) => value
}))
vi.mock('./preview-grid-claim', () => ({
createPreviewGridClaim: () => ({ schedule: vi.fn(), dispose: vi.fn() })
}))
vi.mock('./preview-terminal-box-fit', () => ({
createPreviewBoxFit: () => ({ schedule: vi.fn(), dispose: vi.fn() })
}))
vi.mock('./preview-terminal-ligatures', () => ({ syncPreviewTerminalLigatures: vi.fn() }))
vi.mock('./preview-terminal-compatibility', () => ({
installPreviewTerminalCompatibility: () => vi.fn()
}))
vi.mock('./preview-terminal-ime-bridge', () => ({
installPreviewImeBridge: () => ({ claimKeyEvent: () => false, dispose: vi.fn() })
}))
vi.mock('./preview-terminal-key-handler', () => ({
installPreviewTerminalKeyHandler: () => vi.fn()
}))
vi.mock('@/components/terminal-pane/clipboard/terminal-native-copy-gutter', () => ({
installTerminalNativeCopyGutterTrim: () => ({ dispose: vi.fn() })
}))
vi.mock('./preview-terminal-app-menu-clipboard', () => ({
installPreviewTerminalAppMenuClipboard: () => vi.fn()
}))
vi.mock('./preview-terminal-right-click-paste', () => ({
installPreviewTerminalRightClickPaste: () => vi.fn()
}))
const initial = useAppStore.getInitialState()
const connect = vi.fn<Window['api']['terminalPreview']['connect']>()
const unsubscribe = vi.fn(async () => {})
const offData = vi.fn()
const onData = vi.fn(() => offData)
const connection: TerminalPreviewConnectResult = {
snapshot: { data: 'agent prompt', cols: 80, rows: 24, seq: 1 },
replay: []
}
let settings: GlobalSettings
let originalApi: PropertyDescriptor | undefined
async function updateSettings(updates: Partial<GlobalSettings>): Promise<void> {
await act(async () => useAppStore.getState().updateSettings(updates))
}
function terminal(index = 0): PreviewInstance {
const instance = harness.instances[index]
if (!instance) {
throw new Error(`Missing terminal ${index}`)
}
return instance
}
beforeEach(() => {
vi.clearAllMocks()
harness.instances.length = 0
harness.systemPrefersDark = false
settings = createGlobalSettingsFixture({ theme: 'dark', activeRuntimeEnvironmentId: null })
connect.mockReset().mockResolvedValue(connection)
originalApi = Object.getOwnPropertyDescriptor(window, 'api')
Object.defineProperty(window, 'api', {
configurable: true,
value: {
settings: {
set: async (updates: Partial<GlobalSettings>) => {
settings = structuredClone({ ...settings, ...updates })
return settings
}
},
terminalPreview: {
connect,
unsubscribe,
onData
}
}
})
useAppStore.setState({ ...initial, settings }, true)
})
afterEach(() => {
cleanup()
for (const instance of harness.instances) {
expect(instance.dispose).toHaveBeenCalledOnce()
}
expect(offData).toHaveBeenCalledTimes(onData.mock.calls.length)
useAppStore.setState(initial, true)
if (originalApi) {
Object.defineProperty(window, 'api', originalApi)
} else {
Reflect.deleteProperty(window, 'api')
}
})
describe('preview terminal settings lifetime', () => {
it('keeps one preview connection across ten unrelated settings snapshots', async () => {
render(<AgentTerminalPreview ptyId="pty-1" />)
await act(async () => {})
const theme = terminal().options.theme
for (let index = 1; index <= 10; index += 1) {
await updateSettings({ editorAutoSaveDelayMs: 1000 + index })
}
expect(connect).toHaveBeenCalledExactlyOnceWith('pty-1', { scrollbackRows: 24 })
expect(harness.instances).toHaveLength(1)
expect(terminal().options.theme).toBe(theme)
expect(unsubscribe).not.toHaveBeenCalled()
})
it('uses current live options when an unrelated update arrives during connect', async () => {
const gate = Promise.withResolvers<TerminalPreviewConnectResult>()
connect.mockReturnValueOnce(gate.promise)
render(<AgentTerminalPreview ptyId="pty-1" />)
await updateSettings({ terminalCursorStyle: 'bar' })
expect(connect).toHaveBeenCalledOnce()
await act(async () => gate.resolve(connection))
expect(harness.instances).toHaveLength(1)
expect(terminal().options.cursorStyle).toBe('bar')
})
it('does not install a terminal after unmounting a pending connection', async () => {
const gate = Promise.withResolvers<TerminalPreviewConnectResult>()
connect.mockReturnValueOnce(gate.promise)
const view = render(<AgentTerminalPreview ptyId="ssh:host@@pty-1" />)
await updateSettings({ editorAutoSaveDelayMs: 1234 })
view.unmount()
await act(async () => gate.resolve(connection))
expect(harness.instances).toHaveLength(0)
expect(connect).toHaveBeenCalledOnce()
expect(unsubscribe).toHaveBeenCalledExactlyOnceWith('ssh:host@@pty-1')
})
})
@@ -1,8 +1,8 @@
import { useAppMenuPaste } from '@/hooks/clipboard/menu/useAppMenuPaste'
import { useAppMenuSelectionActions } from '@/hooks/app-commands/useAppMenuSelectionActions'
import { AgentKanbanBoard } from './AgentKanbanBoard'
import { useDashboardSnapshot } from './useDashboardSnapshot'
import { useAgentDashboardTelemetry } from './use-agent-dashboard-telemetry'
import { AgentKanbanBoard } from '../dashboard/board/AgentKanbanBoard'
import { useDashboardSnapshot } from './snapshot/useDashboardSnapshot'
import { useAgentDashboardTelemetry } from '../dashboard/board/use-agent-dashboard-telemetry'
export function DashboardPopoutRoot(): React.JSX.Element {
// The pop-out has no App shell to translate Edit-menu IPC into terminal ownership events.
@@ -1,123 +0,0 @@
import type { PreviewTerminalLinkHandler } from './preview-terminal-links'
import { vi, type Mock } from 'vitest'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker'
import {
openStandaloneTerminal,
type StandaloneTerminal
} from '../standalone-terminal/open-standalone-terminal'
import { installDashboardPreviewLinkInteraction } from './dashboard-preview-link-interaction'
import type { DashboardPreviewLinkSource } from './dashboard-preview-http-actions'
/** Model the browser layout boundary; xterm still measures, renders and hit-tests its own cells. */
export function installPreviewDomFixture(): () => void {
const style = document.createElement('style')
style.textContent = '.xterm, .xterm-screen { padding: 0px; }'
document.head.appendChild(style)
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(() => {
const context: CanvasRenderingContext2D = Object.create(null)
context.font = '13px monospace'
context.measureText = (text) => Object.assign(Object.create(null), { width: text.length * 10 })
context.clearRect = vi.fn()
context.fillRect = vi.fn()
return context
})
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement
) {
return new DOMRect(0, 0, this.offsetWidth, this.offsetHeight)
})
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (
this: HTMLElement
) {
return this.classList.contains('xterm-char-measure-element')
? 320
: Number.parseFloat(this.style.width) || 800
})
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (
this: HTMLElement
) {
return this.classList.contains('xterm-char-measure-element')
? 20
: Number.parseFloat(this.style.height) || 160
})
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(800)
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(160)
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
return () => style.remove()
}
export function mountPreviewLinkInteraction(
source: DashboardPreviewLinkSource,
readSettings: () => GlobalSettings | null,
trackInstance: (instance: ReturnType<typeof openStandaloneTerminal>) => void,
withInteraction = true
): {
instance: StandaloneTerminal
install: () => ReturnType<typeof installDashboardPreviewLinkInteraction>
sink: Mock<(data: string) => boolean | Promise<boolean>>
getSource: Mock<() => DashboardPreviewLinkSource | undefined>
getSettings: Mock<() => GlobalSettings | null>
request: Mock<Parameters<typeof installDashboardPreviewLinkInteraction>[1]['request']>
container: HTMLDivElement
} {
const container = document.createElement('div')
document.body.appendChild(container)
let interaction: ReturnType<typeof installDashboardPreviewLinkInteraction> | undefined
let defaultHandlers: PreviewTerminalLinkHandler | undefined
const sink = vi.fn<(data: string) => boolean | Promise<boolean>>(() => true)
const instance = openStandaloneTerminal({
container,
options: { cols: 80, rows: 8, allowProposedApi: true },
kittyKeyboardModes: new TerminalKittyKeyboardModeTracker({ kittyKeyboard: true }),
sessionKey: source.ptyId,
sendInput: (data) => (interaction ? interaction.sendInput(data) : sink(data)),
answersQueries: false,
getSettings: readSettings,
getMacOptionAsAlt: () => 'false',
getTerminalInput: () => null,
...(withInteraction
? {
linkHandlers: (defaults: PreviewTerminalLinkHandler): PreviewTerminalLinkHandler => {
defaultHandlers = defaults
return {
activate: (e, uri, range) => interaction?.handlers.activate(e, uri, range),
hover: (e, uri, range) => interaction?.handlers.hover?.(e, uri, range),
leave: (e, uri, range) => interaction?.handlers.leave?.(e, uri, range)
}
}
}
: {})
})
if (!instance) {
throw new Error('Actual standalone constructor failed')
}
const request = vi.fn<Parameters<typeof installDashboardPreviewLinkInteraction>[1]['request']>()
const getSource = vi.fn<() => DashboardPreviewLinkSource | undefined>(() => source)
const getSettings = vi.fn(readSettings)
const install = () => {
interaction = installDashboardPreviewLinkInteraction(instance.terminal, {
getSource,
getSettings,
defaultHandlers,
onForegroundOpen: () => undefined,
request,
sendInput: sink
})
return interaction
}
const dispose = () => {
interaction?.dispose()
instance.dispose()
container.remove()
}
trackInstance({ ...instance, dispose })
return { instance, install, sink, getSource, getSettings, request, container }
}
@@ -1,322 +0,0 @@
// @vitest-environment happy-dom
import {
act,
cleanup,
fireEvent,
render as renderWithoutApp,
waitFor
} from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import type { RemoteRuntimeMultiplexedTerminalCallbacks } from '@/runtime/hosted-terminals/streams/remote-terminal-stream-contracts'
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
import { resetRemoteRuntimeTerminalPreviewSessionsForTests } from './remote-runtime-terminal-preview-api'
import {
installPreviewDomFixture,
mountPreviewLinkInteraction
} from './dashboard-preview-dom-test-fixture'
import type { openStandaloneTerminal } from '../standalone-terminal/open-standalone-terminal'
import { getDefaultSettings } from '../../../../shared/constants'
import { createStoreCascadesMockApi } from '@/store/testing/app-store-cascade-api.test-fixture'
import {
createBrowserLinkTestStore,
WORKSPACE,
URL_TO_OPEN
} from '@/lib/browser/navigation/browser-link-open.test-fixture'
import {
registerHttpLinkStoreAccessor,
registerWorkspaceHttpLinkBrowserOpener
} from '@/lib/browser/navigation/http-link-routing'
import { openWorkspaceBrowserTabForLink } from '@/lib/browser/workspace/workspace-browser-tab-open'
import { terminalPreviewApiFor } from './terminal-preview-api'
import { AgentKanbanBoard } from './AgentKanbanBoard'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
const mocks = vi.hoisted(() => ({
getState: vi.fn(),
subscribe: vi.fn(),
remote: new Map<string, RemoteRuntimeMultiplexedTerminalCallbacks>()
}))
vi.mock(
'@/runtime/hosted-terminals/streams/registry/remote-runtime-terminal-multiplexer-registry',
() => ({
getRemoteRuntimeTerminalMultiplexer: (environmentId: string) => ({
subscribeTerminal: async ({
terminal,
callbacks
}: {
terminal: string
callbacks: RemoteRuntimeMultiplexedTerminalCallbacks
}) => {
const ptyId = `remote:${environmentId}@@${terminal}`
mocks.remote.set(ptyId, callbacks)
queueMicrotask(() => callbacks.onSnapshot('', { cols: 80, rows: 8, seq: 1 }))
return {
sendInput: (data: string) => input(ptyId, data),
claimViewport: () => true,
serializeBufferOutcome: async () => ({
availability: { kind: 'snapshot' },
snapshot: { data: '', cols: 80, rows: 8, seq: 1 }
}),
close: () => {
mocks.remote.delete(ptyId)
}
}
}
})
})
)
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: ReturnType<typeof mocks.getState>) => unknown) => selector(mocks.getState()),
{ getState: mocks.getState }
)
}))
vi.mock('@/hooks/theme/use-system-prefers-dark', () => ({
useSystemPrefersDark: () => false
}))
vi.mock('@/lib/keyboard/shortcut-platform', () => ({ getShortcutPlatform: () => 'linux' }))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }
}))
const domWindow = window
const api = createStoreCascadesMockApi()
Object.assign(globalThis, { window: domWindow })
Object.assign(window, { api })
let store: ReturnType<typeof createBrowserLinkTestStore>
const instances: ReturnType<typeof openStandaloneTerminal>[] = []
const input = vi.fn(async (_pty: string, _data: string) => true)
const openUrl = vi.fn(async (_url: string) => {})
let onData: ((payload: unknown) => void) | undefined
let disposeDom: () => void
const source = {
worktreeId: WORKSPACE,
executionHostId: 'local',
tabId: 'source',
ptyId: 'pty-1'
} as const
beforeEach(() => {
store = createBrowserLinkTestStore()
store.setState((state) => ({
settings: {
...state.settings,
...getDefaultSettings('/home/test'),
terminalLigatures: 'auto',
terminalLinkClickBehavior: 'actions',
terminalRightClickToPaste: false,
openLinksInApp: false
} satisfies GlobalSettings
}))
mocks.getState.mockImplementation(() => store.getState())
mocks.subscribe.mockImplementation((listener) => vi.fn(store.subscribe(listener)))
registerHttpLinkStoreAccessor(() => store.getState(), mocks.subscribe)
registerWorkspaceHttpLinkBrowserOpener(async (request) => {
if (!request.linkContext) {
throw new Error('Expected a genuine link request')
}
const outcome = await openWorkspaceBrowserTabForLink({
...request,
linkContext: request.linkContext
})
return outcome.status === 'opened' ? outcome.result.browserPageId : null
})
disposeDom = installPreviewDomFixture()
Object.assign(window.api, {
shell: { ...window.api.shell, openUrl },
dashboard: {
...window.api.dashboard,
ackAgent: vi.fn(async (_pane: string, _intent: 'view' | 'explicit') => {})
},
terminalPreview: {
connect: vi.fn(async () => ({
snapshot: { data: '', cols: 80, rows: 8, seq: 1 },
replay: []
})),
onData: (listener: typeof onData) => {
onData = listener
return () => {
onData = undefined
}
},
input,
ack: vi.fn(async () => {}),
fit: vi.fn(async () => ({ cols: 80, rows: 8 })),
unsubscribe: vi.fn(async () => {})
},
ui: {
...window.api.ui,
readClipboardText: vi.fn(async () => 'clipboard'),
writeClipboardText: vi.fn(async () => {}),
writeTerminalClipboardText: vi.fn(async () => {})
}
})
})
afterEach(() => {
cleanup()
for (const instance of instances.splice(0)) {
instance?.dispose()
}
registerWorkspaceHttpLinkBrowserOpener(null)
resetRemoteRuntimeTerminalPreviewSessionsForTests()
disposeDom()
vi.restoreAllMocks()
vi.clearAllMocks()
vi.unstubAllGlobals()
})
function screenFor(container: HTMLElement): HTMLElement {
const screen = container.querySelector<HTMLElement>('.xterm-screen')
if (!screen) {
throw new Error('Actual xterm screen did not mount')
}
return screen
}
function pointer(screen: HTMLElement, type: string, options: MouseEventInit = {}): MouseEvent {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
buttons: type === 'mouseup' ? 0 : 1,
clientX: 7,
clientY: 7,
...options
})
screen.dispatchEvent(event)
return event
}
async function output(data: string, ptyId = 'pty-1') {
const ack = vi.spyOn(terminalPreviewApiFor(ptyId), 'ack').mockClear()
act(() => {
if (parseRemoteRuntimePtyId(ptyId)) {
const callbacks = mocks.remote.get(ptyId)
if (!callbacks) {
throw new Error('Missing selected remote preview subscription')
}
callbacks.onData(data)
} else {
onData?.({ type: 'data', ptyId, data, bytes: data.length })
}
})
await waitFor(() => expect(ack).toHaveBeenCalledExactlyOnceWith(ptyId, data.length))
}
const osc = `\x1b]8;;${URL_TO_OPEN}\x07hidden label\x1b]8;;\x07`
function mountedInteraction() {
return mountPreviewLinkInteraction(
source,
() => store.getState().settings,
(instance) => instances.push(instance)
)
}
it('retains actual boolean/Promise forwarding results and flushes unclaimed reports in order, with finite overflow and cleanup', async () => {
const preview = mountedInteraction()
const interaction = preview.install()
preview.sink.mockReturnValueOnce(false)
expect(interaction.sendInput('refused-key')).toBe(false)
const promise = Promise.resolve(true)
preview.sink.mockReturnValueOnce(promise)
expect(interaction.sendInput('async-key')).toBe(promise)
await new Promise<void>((resolve) => preview.instance.write(osc, { live: true, onDone: resolve }))
const screen = screenFor(preview.container)
const reports = Array.from({ length: 65 }, (_, i) => `\x1b[<0;${i + 1};1M`)
// Protocol fixture feeds qualified input during a real event dispatch; native timing remains QA's separate obligation.
const emitReports = () => {
preview.instance.terminal.input(reports[0]!)
expect(preview.sink).toHaveBeenCalledTimes(2)
for (const report of reports.slice(1)) {
preview.instance.terminal.input(report)
}
}
screen.addEventListener('mousedown', emitReports)
pointer(screen, 'mousedown')
screen.removeEventListener('mousedown', emitReports)
pointer(screen, 'mouseup')
expect(preview.sink.mock.calls.slice(2).map(([data]) => data)).toEqual(reports)
expect(preview.request).not.toHaveBeenCalled()
interaction.dispose()
expect(preview.sink.mock.calls.slice(2).map(([data]) => data)).toEqual(reports)
})
function cards(count: number): DashboardCard[] {
return Array.from({ length: count }, (_, i) => ({
paneKey: `pane-${i}`,
ptyId: `pty-${i}`,
agentType: 'claude',
bucket: 'working',
dotState: 'working',
task: 'task',
repoId: 'repo1',
worktreeId: WORKSPACE,
tabId: `tab-${i}`,
leafId: `leaf-${i}`,
repoName: 'Repo',
worktreeName: `Workspace ${i}`,
startedAt: 0,
finishedAt: null,
stateChangedAt: 0,
unseen: false
}))
}
it('refuses real selected/drag/nonlink reports and retains the real in-window Board chooser and Settings handoff', async () => {
const preview = mountedInteraction()
const interaction = preview.install()
await new Promise<void>((resolve) =>
preview.instance.write(`\x1b[?1000h\x1b[?1006h${URL_TO_OPEN}`, { live: true, onDone: resolve })
)
const terminal = preview.instance.terminal
const screen = screenFor(preview.container)
terminal.select(0, 0, 5)
pointer(screen, 'mousedown')
pointer(screen, 'mouseup')
expect(preview.request).not.toHaveBeenCalled()
terminal.clearSelection()
pointer(screen, 'mousedown')
pointer(screen, 'mousemove', { clientX: 30 })
pointer(screen, 'mouseup', { clientX: 30 })
expect(preview.request).not.toHaveBeenCalled()
pointer(screen, 'mousedown', { clientX: 700 })
pointer(screen, 'mouseup', { clientX: 700 })
expect(preview.request).not.toHaveBeenCalled()
const reports = expect.poll(() => preview.sink.mock.calls.map(([data]) => data))
await reports.toEqual([
'\x1b[<0;1;1M',
'\x1b[<0;1;1m',
'\x1b[<0;1;1M',
'\x1b[<0;4;1m',
'\x1b[<0;71;1M',
'\x1b[<0;71;1m'
])
interaction.dispose()
preview.instance.dispose()
preview.container.remove()
const foreground = vi.fn()
const view = renderWithoutApp(
<AgentKanbanBoard
snapshot={{ generatedAt: 1, cards: [{ ...cards(1)[0]!, ...source }] }}
onBrowserForeground={foreground}
/>
)
fireEvent.click(view.getByRole('button', { name: /Workspace 0/ }))
await waitFor(() => screenFor(document.body))
await output(osc)
const realScreen = screenFor(document.body)
pointer(realScreen, 'mousedown')
pointer(realScreen, 'mouseup')
const settings = await view.findByRole('button', { name: 'Terminal link settings' })
expect(document.querySelector('[data-terminal-link-destination]')?.textContent).toBe(URL_TO_OPEN)
expect(view.getByRole('button', { name: /System Browser/ })).toBeTruthy()
fireEvent.click(settings)
expect(foreground).toHaveBeenCalledOnce()
expect(store.getState().activeView).toBe('settings')
await waitFor(() => expect(document.querySelector('.xterm')).toBeNull())
})
@@ -1,104 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { Terminal } from '@xterm/xterm'
import { createPreviewBoxFit } from './preview-terminal-box-fit'
const frames = new Map<number, FrameRequestCallback>()
const cancelFrame = vi.fn((id: number) => frames.delete(id))
let nextFrame = 0
const terminals: Terminal[] = []
beforeEach(() => {
nextFrame = 0
frames.clear()
vi.clearAllMocks()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextFrame++
frames.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', cancelFrame)
})
afterEach(() => {
for (const terminal of terminals.splice(0)) {
terminal.dispose()
}
vi.unstubAllGlobals()
})
function harness(getTerminal?: () => null) {
const box = document.createElement('div')
const container = document.createElement('div')
const screen = document.createElement('div')
screen.className = 'xterm-screen'
box.append(container)
container.append(screen)
Object.defineProperties(box, {
clientWidth: { value: 600 },
clientHeight: { value: 240 }
})
Object.defineProperties(screen, {
offsetWidth: { value: 800 },
offsetHeight: { value: 384 }
})
const terminal = new Terminal({ rows: 24, cols: 80, allowProposedApi: true })
terminals.push(terminal)
const read = vi.fn(getTerminal ?? (() => terminal))
return { ...createPreviewBoxFit({ container, getTerminal: read }), box, container, read }
}
function flush(id = 0): void {
const callback = frames.get(id)
frames.delete(id)
callback?.(16)
}
function thrown(operation: () => void): unknown {
try {
operation()
} catch (error) {
return error
}
throw new Error('Expected an error')
}
it('cancels frame zero once and suppresses captured or later callbacks after disposal', () => {
const owner = harness()
owner.schedule()
const captured = frames.get(0)
owner.dispose()
owner.dispose()
captured?.(16)
owner.schedule()
expect(owner.read).not.toHaveBeenCalled()
expect(owner.container.style.transform).toBe('')
expect(frames.size).toBe(0)
expect(cancelFrame).toHaveBeenCalledExactlyOnceWith(0)
})
it('preserves a fit error and allows the next live request after completion', () => {
const error = new Error('terminal read failed')
const owner = harness()
owner.read.mockImplementationOnce(() => {
throw error
})
owner.schedule()
expect(thrown(() => flush())).toBe(error)
owner.schedule()
flush(1)
expect(owner.read).toHaveBeenCalledTimes(2)
owner.dispose()
expect(cancelFrame).not.toHaveBeenCalled()
})
it('does not cancel another owner when both use the same container', () => {
const old = harness()
const current = createPreviewBoxFit({ container: old.container, getTerminal: () => null })
old.schedule()
current.schedule()
old.dispose()
expect([...frames.keys()]).toEqual([1])
current.dispose()
expect(cancelFrame.mock.calls).toEqual([[0], [1]])
})
@@ -1,168 +0,0 @@
// @vitest-environment happy-dom
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installPreviewImeBridge } from './preview-terminal-ime-bridge'
vi.mock('@/lib/keyboard/shortcut-platform', () => ({
getShortcutPlatform: () => 'darwin'
}))
const COMMA = { key: ',', code: 'Comma', keyCode: 188 }
type Session = {
emitted: string[]
setFlags: (flags: number) => void
keydown: (init?: { shiftKey?: boolean; repeat?: boolean; key?: string; code?: string }) => void
keyup: (init?: { shiftKey?: boolean; key?: string; code?: string }) => void
commit: (text: string) => void
dispose: () => void
}
function open(initialFlags: number): Session {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal()
terminal.open(container)
const textarea = terminal.textarea!
let flags = initialFlags
const bridge = installPreviewImeBridge(terminal, {
getKittyKeyboardFlags: () => flags
})!
terminal.attachCustomKeyEventHandler((event) => !bridge.claimKeyEvent(event))
const emitted: string[] = []
terminal.onData((data) => emitted.push(data))
const key = (
type: string,
init: { key?: string; code?: string; shiftKey?: boolean; repeat?: boolean }
): void => {
const event = new KeyboardEvent(type, {
key: init.key ?? COMMA.key,
code: init.code ?? COMMA.code,
shiftKey: init.shiftKey === true,
repeat: init.repeat === true,
bubbles: true,
cancelable: true
})
Object.defineProperty(event, 'keyCode', { value: COMMA.keyCode })
textarea.dispatchEvent(event)
}
const input = (data: string | null): void => {
for (const type of ['beforeinput', 'input']) {
const event = new InputEvent(type, { bubbles: true })
Object.defineProperty(event, 'inputType', { value: 'insertText' })
Object.defineProperty(event, 'data', { value: data })
textarea.dispatchEvent(event)
}
}
return {
emitted,
setFlags: (next) => {
flags = next
},
keydown: (init = {}) => key('keydown', init),
keyup: (init = {}) => key('keyup', init),
commit: (text) => input(text),
dispose: () => {
bridge.dispose()
terminal.dispose()
}
}
}
describe('preview IME bridge outbound bytes', () => {
beforeEach(() => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(() => {
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('encodes the release from the keyup state when Shift is released first', () => {
const session = open(10)
// `!` on Digit1: the press reports the base key with the Shift modifier.
session.keydown({ key: '!', code: 'Digit1', shiftKey: true })
session.commit('!')
session.keyup({ key: '1', code: 'Digit1', shiftKey: false })
// 49 is `1`; the release carries no modifier because none was held at release.
expect(session.emitted.join('')).toBe('\x1b[49;2u\x1b[49;1:3u')
session.dispose()
})
it('emits one release for a held key across auto-repeat', () => {
const session = open(10)
session.keydown()
session.commit(',')
session.keydown({ repeat: true })
session.commit(',')
session.keyup()
// Press, repeat, then exactly one release for the single physical press.
expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44;1:2u\x1b[44;1:3u')
session.dispose()
})
it('keeps an owed release when a later repeat commits under flags that owe none', () => {
const session = open(10)
session.keydown()
session.commit(',')
session.setFlags(8)
session.keydown({ repeat: true })
session.commit(',')
session.setFlags(10)
session.keyup()
// A repeat’s new flags cannot erase the release owed by the originally delivered press.
expect(session.emitted.join('')).toBe('\x1b[44u\x1b[44u\x1b[44;1:3u')
session.dispose()
})
it('suppresses the owed release when event types are no longer negotiated at keyup', () => {
// Match xterm: once event reporting is popped, do not send CSI-u releases to an app or successor shell that no longer negotiates them.
const session = open(10)
session.keydown()
session.commit(',')
session.setFlags(0)
session.keyup()
expect(session.emitted.join('')).toBe('\x1b[44u')
session.dispose()
})
it('releases each key of a two-key rollover exactly once', () => {
const session = open(10)
session.keydown()
session.commit(',')
session.keydown({ key: '.', code: 'Period' })
session.commit('。')
session.keyup()
session.keyup({ key: '.', code: 'Period' })
expect(session.emitted.join('')).toBe('\x1b[44u\x1b[46u\x1b[44;1:3u\x1b[46;1:3u')
session.dispose()
})
it('leaves no release behind when a claim is retired without committing', () => {
const session = open(10)
session.keydown()
// The input source ate the first press; the next keydown retires it.
session.keydown({ key: '.', code: 'Period' })
session.commit('。')
session.keyup()
session.keyup({ key: '.', code: 'Period' })
expect(session.emitted.join('')).toBe('\x1b[46u\x1b[46;1:3u')
session.dispose()
})
it('synthesizes nothing on blur or disposal', () => {
const session = open(10)
session.keydown()
session.commit(',')
session.emitted.length = 0
document.querySelector('.xterm')?.dispatchEvent(new FocusEvent('blur', { bubbles: true }))
session.keyup()
session.dispose()
expect(session.emitted.join('')).toBe('')
})
})
@@ -1,15 +1,15 @@
import { agentVerdictDisplayMark } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import { interactivePromptLabel } from '../../../../shared/native-chat/input/native-chat-interactive-prompt'
import { sayNativeChatApprovalTranslated } from '../native-chat/prompts/native-chat-interactive-prompt'
import type { AgentStatusIpcPayload } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload'
import { agentVerdictDisplayMark } from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import { interactivePromptLabel } from '../../../../../shared/native-chat/input/native-chat-interactive-prompt'
import { sayNativeChatApprovalTranslated } from '../../native-chat/prompts/native-chat-interactive-prompt'
import type { AgentStatusIpcPayload } from '../../../../../shared/agent-status/contracts/agent-status-ipc-payload'
import {
dashboardCardBaseDisplayState,
type DashboardCard,
type DashboardCardSubagent,
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import { dashboardBucketForDotState } from '../dashboard/dashboard-card-bucket'
import { dashboardCardDotState } from '../dashboard/dashboard-row-bucket'
} from '../../../../../shared/dashboard-snapshot'
import { dashboardBucketForDotState } from '../../dashboard/snapshot/dashboard-card-bucket'
import { dashboardCardDotState } from '../../dashboard/rows/dashboard-row-bucket'
import { isAgentTurnUnacknowledged } from '@/attention/agent-attention-acknowledgement'
export type DashboardAgentStatusPatchResult = {
@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness'
import {
EMPTY_DASHBOARD_SNAPSHOT,
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
} from '../../../../../shared/dashboard-snapshot'
import { patchDashboardSnapshotFromAgentStatus } from './dashboard-agent-status-patch'
const TOPOLOGY_REFRESH_DEBOUNCE_MS = 250
@@ -1,192 +0,0 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import type { ComponentProps } from 'react'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard'
const mocks = vi.hoisted(() => ({
activateAndRevealWorkspace: vi.fn(() => true),
activateTabAndFocusPane: vi.fn(),
openPopout: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@/lib/workspace/activation/worktree-activation', () => ({
activateAndRevealWorkspace: mocks.activateAndRevealWorkspace
}))
vi.mock('@/lib/workspace/tabs/activate-tab-and-focus-pane', () => ({
activateTabAndFocusPane: mocks.activateTabAndFocusPane
}))
vi.mock('./useLiveDashboardSnapshot', () => ({
useLiveDashboardSnapshot: () => ({ generatedAt: 1, cards: [] })
}))
vi.mock('../dashboard-popout/AgentKanbanBoard', () => ({
AgentKanbanBoard: ({
onClose,
onRevealAgent,
headerActions
}: ComponentProps<typeof AgentKanbanBoard>) => (
<>
<input aria-label="Search agents" />
<button onClick={onClose}>Close dashboard</button>
<button
onClick={() =>
onRevealAgent?.({ repoId: 'repo', worktreeId: 'worktree', tabId: 'tab', leafId: 'leaf' })
}
>
Reveal agent
</button>
{headerActions}
</>
)
}))
vi.mock('./AgentDashboardSettingsMenu', () => ({
AgentDashboardSettingsMenu: ({ onSwitchToPopout }: { onSwitchToPopout: () => void }) => (
<button onClick={onSwitchToPopout}>Pop out</button>
)
}))
import { AgentDashboardDrawer } from './AgentDashboardDrawer'
const initialState = useAppStore.getInitialState()
beforeEach(() => {
vi.clearAllMocks()
mocks.activateAndRevealWorkspace.mockReturnValue(true)
useAppStore.setState({ agentDashboardDrawerOpen: false, sidebarOpen: true, sidebarWidth: 320 })
Object.defineProperty(window, 'api', {
configurable: true,
value: { dashboard: { openPopout: mocks.openPopout } }
})
})
afterEach(async () => {
cleanup()
// Radix schedules close autofocus after unmount, including cleanup.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
useAppStore.setState(initialState, true)
vi.restoreAllMocks()
})
function fixture(): ReturnType<typeof render> {
const open = (): void => useAppStore.getState().setAgentDashboardDrawerOpen(true)
return render(
<>
<button onClick={open}>Opener A</button>
<button onClick={open}>Opener B</button>
<input aria-label="Outside input" />
<AgentDashboardDrawer statusBarVisible />
</>
)
}
async function openFrom(name = 'Opener A'): Promise<HTMLElement> {
const opener = screen.getByRole('button', { name })
opener.focus()
await userEvent.keyboard('{Enter}')
expect(document.activeElement).toBe(opener)
screen.getByRole('textbox', { name: 'Search agents' }).focus()
return opener
}
async function closed(): Promise<void> {
await waitFor(() => expect(screen.queryByRole('textbox', { name: 'Search agents' })).toBeNull())
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
}
describe('AgentDashboardDrawer focus return', () => {
it.each(['Escape', 'Close dashboard'])('returns to the opener after %s', async (dismiss) => {
fixture()
const opener = await openFrom()
await (dismiss === 'Escape'
? userEvent.keyboard('{Escape}')
: userEvent.click(screen.getByRole('button', { name: dismiss })))
await closed()
expect(document.activeElement).toBe(opener)
})
it('keeps outside focus when the store closes the drawer', async () => {
fixture()
await openFrom()
const outside = screen.getByRole('textbox', { name: 'Outside input' })
outside.focus()
act(() => useAppStore.getState().setAgentDashboardDrawerOpen(false))
await closed()
expect(document.activeElement).toBe(outside)
})
it('lets the real outside pointerdown dismissal hand focus to its target', async () => {
fixture()
const opener = await openFrom()
const sheet = screen.getByRole('dialog', { name: 'Agents' })
vi.spyOn(sheet, 'getBoundingClientRect').mockReturnValue(new DOMRect(320, 40, 600, 600))
const outside = screen.getByRole('textbox', { name: 'Outside input' })
const returned: Event[] = []
opener.addEventListener('focus', (event) => returned.push(event))
await userEvent.pointer({
target: outside,
keys: '[MouseLeft]',
coords: { clientX: 1000, clientY: 100 }
})
await closed()
expect(document.activeElement).toBe(outside)
expect(returned).toHaveLength(0)
await userEvent.keyboard('focus-test')
expect(outside).toHaveValue('focus-test')
})
it('restores after store dismissal and captures the next opener', async () => {
fixture()
const first = await openFrom()
act(() => useAppStore.getState().setAgentDashboardDrawerOpen(false))
await closed()
expect(document.activeElement).toBe(first)
const second = await openFrom('Opener B')
await userEvent.keyboard('{Escape}')
await closed()
expect(document.activeElement).toBe(second)
})
it('does not let an old close callback take focus from a reopened drawer', async () => {
fixture()
await openFrom()
act(() => useAppStore.getState().setAgentDashboardDrawerOpen(false))
const second = screen.getByRole('button', { name: 'Opener B' })
second.focus()
act(() => useAppStore.getState().setAgentDashboardDrawerOpen(true))
const search = screen.getByRole('textbox', { name: 'Search agents' })
search.focus()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(document.activeElement).toBe(search)
await userEvent.keyboard('{Escape}')
await closed()
expect(document.activeElement).toBe(second)
})
it.each([true, false])('restores only when agent reveal fails (success: %s)', async (success) => {
fixture()
const opener = await openFrom()
mocks.activateAndRevealWorkspace.mockReturnValue(success)
await userEvent.click(screen.getByRole('button', { name: 'Reveal agent' }))
await closed()
expect(document.activeElement === opener).toBe(!success)
})
it('leaves focus handoff to the popout', async () => {
fixture()
const opener = await openFrom()
await userEvent.click(screen.getByRole('button', { name: 'Pop out' }))
await closed()
expect(mocks.openPopout).toHaveBeenCalledOnce()
expect(document.activeElement).not.toBe(opener)
})
})
@@ -1,101 +0,0 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
const mocks = vi.hoisted(() => ({
useLiveDashboardSnapshot: vi.fn(() => ({ generatedAt: 1, cards: [] })),
useAgentDashboardTelemetry: vi.fn(),
blockingOverlay: false,
boardProps: null as Record<string, unknown> | null,
activateTabAndFocusPane: vi.fn(),
activateAndRevealWorkspace: vi.fn(() => ({ primaryTabId: null }) as unknown)
}))
vi.mock('@/lib/workspace/tabs/activate-tab-and-focus-pane', () => ({
activateTabAndFocusPane: mocks.activateTabAndFocusPane
}))
vi.mock('@/lib/workspace/activation/worktree-activation', () => ({
activateAndRevealWorkspace: mocks.activateAndRevealWorkspace
}))
vi.mock('./useLiveDashboardSnapshot', () => ({
useLiveDashboardSnapshot: mocks.useLiveDashboardSnapshot
}))
vi.mock('../dashboard-popout/use-agent-dashboard-telemetry', () => ({
useAgentDashboardTelemetry: mocks.useAgentDashboardTelemetry
}))
vi.mock('../dashboard-popout/AgentKanbanBoard', () => ({
AgentKanbanBoard: (props: Record<string, unknown>) => {
mocks.boardProps = props
return mocks.blockingOverlay ? <section role="dialog" data-state="open" /> : null
}
}))
vi.mock('./AgentDashboardSettingsMenu', () => ({
AgentDashboardSettingsMenu: () => null
}))
vi.mock('../sidebar/workspace-board/surface/use-workspace-kanban-outside-dismiss', () => ({
isWorkspaceBoardKeepOpenTarget: () => false,
useWorkspaceKanbanOutsideDismiss: () => undefined
}))
import { AgentDashboardDrawer } from './AgentDashboardDrawer'
const initialState = useAppStore.getInitialState()
beforeEach(() => {
useAppStore.setState(
{
agentDashboardDrawerOpen: false,
sidebarOpen: true,
sidebarWidth: 320
},
false
)
mocks.useLiveDashboardSnapshot.mockClear()
mocks.useAgentDashboardTelemetry.mockClear()
mocks.activateTabAndFocusPane.mockClear()
mocks.activateAndRevealWorkspace.mockClear()
mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null })
mocks.blockingOverlay = false
mocks.boardProps = null
;(window as unknown as { api: unknown }).api = {
dashboard: { openPopout: vi.fn().mockResolvedValue(undefined) }
}
})
afterEach(() => {
cleanup()
document.body.replaceChildren()
useAppStore.setState(initialState, true)
})
describe('AgentDashboardDrawer', () => {
it('keeps the dismissal Escape from a focused terminal but not from other fields', () => {
const terminalInput = document.createElement('textarea')
terminalInput.className = 'xterm-helper-textarea'
const field = document.createElement('input')
document.body.append(terminalInput, field)
const terminalKeyDown = vi.fn()
const fieldKeyDown = vi.fn()
terminalInput.addEventListener('keydown', terminalKeyDown)
field.addEventListener('keydown', fieldKeyDown)
render(<AgentDashboardDrawer statusBarVisible />)
act(() => useAppStore.setState({ agentDashboardDrawerOpen: true }))
fireEvent.keyDown(terminalInput, { key: 'Escape' })
expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)
expect(terminalKeyDown).not.toHaveBeenCalled()
act(() => useAppStore.setState({ agentDashboardDrawerOpen: true }))
fireEvent.keyDown(field, { key: 'Escape' })
expect(useAppStore.getState().agentDashboardDrawerOpen).toBe(false)
expect(fieldKeyDown).toHaveBeenCalledTimes(1)
})
})
@@ -2,10 +2,10 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import { useAppStore } from '@/store'
import type { AgentSubjectReadIntent } from '@/attention/agent-subject-read-actions'
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet'
import { revealDashboardAgent } from './reveal-dashboard-agent'
import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard'
import { revealDashboardAgent } from './navigation/reveal-dashboard-agent'
import { AgentKanbanBoard } from './board/AgentKanbanBoard'
import { isXtermHelperTextarea } from '../terminal-pane/input/focus/regular-terminal-focus-ownership'
import type { AgentRevealArgs } from '../dashboard-popout/AgentTerminalDialog'
import type { DashboardRevealAgentArgs as AgentRevealArgs } from '../../../../shared/dashboard-snapshot'
import {
isWorkspaceBoardKeepOpenTarget,
useWorkspaceKanbanOutsideDismiss
@@ -14,9 +14,9 @@ import {
STATUS_BAR_RESERVE_HEIGHT,
WORKSPACE_TOP_CHROME_HEIGHT
} from '../sidebar/workspace-chrome-metrics'
import { AgentDashboardSettingsMenu } from './AgentDashboardSettingsMenu'
import { useLiveDashboardSnapshot } from './useLiveDashboardSnapshot'
import { useAgentDashboardTelemetry } from '../dashboard-popout/use-agent-dashboard-telemetry'
import { AgentDashboardSettingsMenu } from './settings/AgentDashboardSettingsMenu'
import { useLiveDashboardSnapshot } from './snapshot/useLiveDashboardSnapshot'
import { useAgentDashboardTelemetry } from './board/use-agent-dashboard-telemetry'
import { useTranslate } from '@/i18n/use-translate'
import { trackSidebarLiveWidth } from '@/components/sidebar/sidebar-live-width'
@@ -4,7 +4,7 @@ import {
opensClaudeParentConversation,
viewClaudeAgentFromRow
} from '@/lib/agent-session/navigation/claude-agent-row-view'
import { useClaudeAgentRowView } from './use-claude-agent-row-view'
import { useClaudeAgentRowView } from './rows/use-claude-agent-row-view'
import {
AgentStateDot,
agentStateLabel,
@@ -14,9 +14,9 @@ import {
import { AgentTypeIcon } from '@/components/agent/icons/agent-type-icon'
import { formatAgentTypeLabel } from '../../../../shared/agent-status/presentation/agent-type-label'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { DashboardAgentRowMessage } from './DashboardAgentRowMessage'
import { DashboardAgentRowTrailingControls } from './DashboardAgentRowTrailingControls'
import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
import { DashboardAgentRowMessage } from './rows/DashboardAgentRowMessage'
import { DashboardAgentRowTrailingControls } from './rows/DashboardAgentRowTrailingControls'
import { DashboardAgentRowToolStep } from './rows/DashboardAgentRowToolStep'
import { programStatusAttentionLine } from '../../../../shared/program-status/presentation'
import {
agentRowToolInput,
@@ -37,10 +37,10 @@ import {
agentVerdictDisplayMark
} from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import { agentVerdictStatusLine } from '@/lib/agent-status/presentation/agent-verdict-status-line'
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
import type { DashboardAgentRow as DashboardAgentRowData } from './rows/dashboard-agent-row'
import { getAgentRowPrimaryText } from '../../../../shared/agent-status/presentation/agent-row-primary-text'
import { useAgentRowConversationName } from './use-agent-row-conversation-name'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
import { useAgentRowConversationName } from './rows/use-agent-row-conversation-name'
import { lastEnteredDoneAt } from './rows/agent-finished-timestamp'
import {
agentChildRowMessageLine,
agentChildRowNoUpdateLabel
@@ -1,396 +0,0 @@
import type * as RuntimeRpcCallModule from '@/runtime/rpc/calls/runtime-rpc-call'
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner'
import {
makeCreatedAgentWorktree,
seedAlreadyActiveWorktree
} from '@/lib/workspace/activation/worktree-activation-created-agent.test-fixture'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import type { Tab } from '../../../../shared/tab-types'
import type * as WebRuntimeSessionEnvironment from '@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment'
import { AgentKanbanBoard } from '../dashboard-popout/AgentKanbanBoard'
import WorktreeCardAgents from '../sidebar/workspace-card/activity/WorktreeCardAgents'
import { TooltipProvider } from '@/components/ui/tooltip'
import { buildDashboardSnapshot } from './build-dashboard-snapshot'
import { revealDashboardAgent } from './reveal-dashboard-agent'
import { activateRetainedAgentRow } from '../sidebar/workspace-card/activity/activate-retained-agent-row'
import { activateWorktreeAgentPane } from '../sidebar/workspace-card/activity/use-worktree-agent-activation'
import { structuredAgentSessionPaneKey } from '../../../../shared/agent-session/view/structured-agent-session-projection'
const effects = vi.hoisted(() => ({
rpc: vi.fn(async () => ({ ok: true })),
composerFocus: vi.fn(),
environmentCall: vi.fn(async (_environmentId: string, _args: unknown) => ({
id: 'navigation-test',
ok: false as const,
error: { code: 'unavailable', message: 'Unit-test transport boundary' }
}))
}))
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeRpcCallModule>()),
callRuntimeRpc: effects.rpc
}))
vi.mock('@/components/native-chat/composer/focus/native-chat-composer-focus-intent', () => ({
requestNativeChatComposerFocus: effects.composerFocus
}))
vi.mock('../dashboard-popout/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null }))
vi.mock(
'@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment',
async (importOriginal) => ({
...(await importOriginal<typeof WebRuntimeSessionEnvironment>()),
captureRuntimeEnvironmentCall: (environmentId: string) => (args: unknown) =>
effects.environmentCall(environmentId, args)
})
)
const initialState = useAppStore.getState()
const LEAF = '11111111-1111-4111-8111-111111111111'
const WORKSPACE_HOST = 'runtime:host-A' as const
const TAB_HOST = 'runtime:host-B' as const
const NOW = 1_000_000_000
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
vi.clearAllMocks()
vi.restoreAllMocks()
})
describe('Dashboard and retained sidebar destination contract', () => {
it.each([
['worktree', 'native'],
['folder', 'native'],
['worktree', 'terminal'],
['folder', 'terminal']
] as const)(
'opens the exact %s %s destination and preserves reveal failure',
async (scope, surface) => {
const native = surface === 'native'
const owner = native ? TAB_HOST : WORKSPACE_HOST
const worktree = {
...makeCreatedAgentWorktree(),
hostId: WORKSPACE_HOST,
...(scope === 'folder' ? { id: folderWorkspaceKey('folder-1') } : {})
}
seedAlreadyActiveWorktree(worktree)
const seeded = useAppStore.getState()
const terminal = { ...seeded.tabsByWorktree[worktree.id][0], executionHostId: owner }
const tabId = native ? 'native-B' : terminal.id
const paneKey = makePaneKey(tabId, LEAF)
const group = seeded.groupsByWorktree[worktree.id][0]
const nativeTab: Tab = {
id: tabId,
worktreeId: worktree.id,
executionHostId: TAB_HOST,
contentType: 'agent-session',
entityId: 'session-B',
groupId: group.id,
label: 'Codex',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: NOW
}
const folder = {
id: 'folder-1',
projectGroupId: 'project-1',
name: 'Notes',
folderPath: '/workspace/notes',
executionHostId: WORKSPACE_HOST,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW,
createdAt: NOW,
updatedAt: NOW
}
useAppStore.setState({
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabType: 'editor',
agentActivityDisplayMode: 'compact',
repos: scope === 'folder' ? [] : seeded.repos,
worktreesByRepo:
scope === 'folder'
? {}
: {
[worktree.repoId]: [
worktree,
...(native ? [{ ...worktree, hostId: TAB_HOST }] : [])
]
},
folderWorkspaces:
scope === 'folder'
? [folder, ...(native ? [{ ...folder, executionHostId: TAB_HOST }] : [])]
: [],
getFreshFolderWorkspacePathStatus: () => ({ path: folder.folderPath, exists: true }),
tabsByWorktree: { [worktree.id]: [terminal] },
unifiedTabsByWorktree: {
[worktree.id]: [
...seeded.unifiedTabsByWorktree[worktree.id],
...(native ? [nativeTab] : [])
]
},
groupsByWorktree: {
[worktree.id]: [{ ...group, tabOrder: [...group.tabOrder, ...(native ? [tabId] : [])] }]
},
terminalLayoutsByTabId: native
? {}
: {
[tabId]: {
root: { type: 'leaf', leafId: LEAF },
activeLeafId: LEAF,
expandedLeafId: null
}
},
retainedAgentsByPaneKey: {
[paneKey]: {
entry: {
paneKey,
tabId,
worktreeId: worktree.id,
state: 'done',
agentType: 'codex',
prompt: 'Open this conversation',
updatedAt: NOW,
stateStartedAt: NOW,
stateHistory: []
},
worktreeId: worktree.id,
tab: { ...terminal, id: tabId, ptyId: native ? null : terminal.ptyId },
agentType: 'codex',
startedAt: NOW
}
}
})
const snapshot = buildDashboardSnapshot(
{ ...useAppStore.getState(), chatNames: new Map() },
NOW
)
const card = snapshot.cards.find((candidate) => candidate.executionHostId === WORKSPACE_HOST)
expect(card).toMatchObject({ worktreeId: worktree.id, tabId, ptyId: null })
if (!card) {
throw new Error('Expected a card from the production snapshot builder')
}
// Give the virtual lane a viewport; happy-dom has no layout.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
const view = render(
<TooltipProvider>
<AgentKanbanBoard
snapshot={{ ...snapshot, cards: [card] }}
onAckAgent={() => {}}
onRevealAgent={revealDashboardAgent}
/>
</TooltipProvider>
)
fireEvent.click(screen.getByRole('button', { name: /Open this conversation/ }))
fireEvent.click(screen.getByRole('button', { name: 'Open worktree' }))
const assertDestination = async (): Promise<void> => {
const state = useAppStore.getState()
expect(state.activeWorktreeId).toBe(worktree.id)
expect(getExecutionHostIdForWorktree(state, worktree.id)).toBe(owner)
expect(state.activeTabType).toBe(native ? 'agent-session' : 'terminal')
if (!native) {
expect(state.activeTabId).toBe(tabId)
}
expect(state.getActiveTab(worktree.id)?.id).toBe(tabId)
expect(state.groupsByWorktree[worktree.id][0].activeTabId).toBe(tabId)
if (native) {
expect(effects.composerFocus).toHaveBeenLastCalledWith(tabId)
expect(effects.rpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'host-B' },
'session.tabs.activate',
expect.objectContaining({ tabId: 'agent-session:session-B' })
)
} else {
await vi.waitFor(() =>
expect(effects.environmentCall).toHaveBeenCalledWith(
'host-A',
expect.objectContaining({
method: 'session.tabs.activate',
params: expect.objectContaining({ leafId: LEAF })
})
)
)
}
}
await assertDestination()
view.unmount()
useAppStore.setState({
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabType: 'editor',
activeTabId: null
})
const sidebar = render(
<TooltipProvider>
<WorktreeCardAgents worktreeId={worktree.id} />
</TooltipProvider>
)
const row = sidebar.container.querySelector('.compact-agent-row')
if (!row) {
throw new Error('Expected the retained sidebar row')
}
effects.environmentCall.mockClear()
fireEvent.click(row)
await assertDestination()
// Keep the exact tab alive while its workspace disappears: neither entry may focus or notify it.
cleanup()
useAppStore.setState({
worktreesByRepo: {},
folderWorkspaces: [],
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabType: 'editor',
activeTabId: null
})
effects.rpc.mockClear()
effects.composerFocus.mockClear()
effects.environmentCall.mockClear()
expect(revealDashboardAgent(card)).toBe(false)
const missing = render(
<TooltipProvider>
<WorktreeCardAgents worktreeId={worktree.id} />
</TooltipProvider>
)
const missingRow = missing.container.querySelector('.compact-agent-row')
if (!missingRow) {
throw new Error('Expected the retained row after workspace removal')
}
fireEvent.click(missingRow)
expect(useAppStore.getState()).toMatchObject({
activeWorktreeId: null,
activeTabId: null,
activeTabType: 'editor'
})
expect(effects.composerFocus).not.toHaveBeenCalled()
expect(effects.rpc).not.toHaveBeenCalled()
expect(effects.environmentCall).not.toHaveBeenCalled()
}
)
it('notifies the exact live retained terminal owner instead of the workspace default', async () => {
const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST }
seedAlreadyActiveWorktree(worktree, {
worktreesByRepo: { [worktree.repoId]: [worktree, { ...worktree, hostId: TAB_HOST }] },
activeWorkspaceExecutionHostId: WORKSPACE_HOST,
ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF },
activeLeafId: LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' }
}
}
})
activateRetainedAgentRow(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF))
expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(TAB_HOST)
await vi.waitFor(() =>
expect(effects.environmentCall).toHaveBeenCalledWith(
'host-B',
expect.objectContaining({
method: 'session.tabs.activate',
params: expect.objectContaining({ leafId: LEAF })
})
)
)
expect(
effects.environmentCall.mock.calls.every(([environmentId]) => environmentId === 'host-B')
).toBe(true)
expect(useAppStore.getState().getActiveTab(worktree.id)?.id).toBe('tab-1')
})
})
it('rejects a live terminal row on a different host before workspace activation', () => {
const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST }
seedAlreadyActiveWorktree(worktree, {
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabId: null,
ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF },
activeLeafId: LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' }
}
}
})
activateWorktreeAgentPane(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF), WORKSPACE_HOST)
expect(useAppStore.getState()).toMatchObject({
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabId: null
})
})
it.each(['live', 'retained'] as const)(
'opens a %s paired chat from its physical SSH workspace',
(source) => {
const worktree = {
...makeCreatedAgentWorktree(),
hostId: 'ssh:server' as const,
runtimeOwnerEnvironmentId: 'host-B'
}
seedAlreadyActiveWorktree(worktree, {
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabType: 'editor',
unifiedTabsByWorktree: {
[worktree.id]: [
{
id: 'native-B',
worktreeId: worktree.id,
executionHostId: TAB_HOST,
contentType: 'agent-session',
agentSessionAgent: 'claude',
entityId: 'session-B',
groupId: 'group-1',
label: 'Codex',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: NOW
}
]
}
})
const activate = source === 'live' ? activateWorktreeAgentPane : activateRetainedAgentRow
activate(
worktree.id,
'native-B',
structuredAgentSessionPaneKey('native-B', 'session-B'),
'ssh:server'
)
expect(useAppStore.getState()).toMatchObject({
activeWorktreeId: worktree.id,
activeWorkspaceExecutionHostId: 'ssh:server',
activeTabType: 'agent-session'
})
expect(effects.composerFocus).toHaveBeenCalledWith('native-B')
expect(effects.rpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'host-B' },
'session.tabs.activate',
expect.objectContaining({ tabId: 'agent-session:session-B' })
)
}
)
@@ -1,5 +1,5 @@
import { formatAgentTypeLabel } from '../../../../shared/agent-status/presentation/agent-type-label'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import { formatAgentTypeLabel } from '../../../../../shared/agent-status/presentation/agent-type-label'
import type { DashboardCard } from '../../../../../shared/dashboard-snapshot'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
@@ -12,8 +12,11 @@ import {
import { Input } from '@/components/ui/input'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import { getWorkspaceStatusVisualMeta } from '../sidebar/workspace-status/presentation/workspace-status'
import type { DashboardCard, DashboardFilterOptions } from '../../../../shared/dashboard-snapshot'
import { getWorkspaceStatusVisualMeta } from '../../sidebar/workspace-status/presentation/workspace-status'
import type {
DashboardCard,
DashboardFilterOptions
} from '../../../../../shared/dashboard-snapshot'
import {
activeDashboardFilterCount,
type DashboardFilters,
@@ -4,15 +4,16 @@ import {
DASHBOARD_BUCKET_ORDER,
type DashboardBucket,
type DashboardCard,
type DashboardSnapshot
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
type DashboardSnapshot,
type DashboardRevealAgentArgs as AgentRevealArgs
} from '../../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../../shared/repo-icon'
import { cn } from '@/lib/class-names'
import { TooltipProvider } from '@/components/ui/tooltip'
import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval'
import { AgentKanbanLaneCardList } from './AgentKanbanLaneCardList'
import { AgentDashboardToolbar } from './AgentDashboardToolbar'
import { AgentTerminalDialog, type AgentRevealArgs } from './AgentTerminalDialog'
import { AgentTerminalDialog } from '../terminal-preview/AgentTerminalDialog'
import {
EMPTY_DASHBOARD_FILTERS,
filterDashboardCards,
@@ -16,8 +16,8 @@ import { cn } from '@/lib/class-names'
import {
dashboardCardDisplayState,
type DashboardCard
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
} from '../../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../../shared/repo-icon'
import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { DashboardHostBadge } from './DashboardHostBadge'
@@ -1,6 +1,6 @@
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import type { DashboardCard } from '../../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../../shared/repo-icon'
import { AgentKanbanCard } from './AgentKanbanCard'
import {
focusAgentKanbanCardEdge,
@@ -3,8 +3,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { translate } from '@/i18n/i18n'
import { useTranslate, type Translate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import type { DashboardCardHostKind } from '../../../../../shared/dashboard-snapshot'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../../shared/execution-host'
type DashboardHostBadgeProps = {
hostKind?: DashboardCardHostKind
@@ -1,4 +1,4 @@
import type { DashboardCard, DashboardCardReview } from '../../../../shared/dashboard-snapshot'
import type { DashboardCard, DashboardCardReview } from '../../../../../shared/dashboard-snapshot'
export type DashboardReviewFilter = DashboardCardReview['state'] | 'none'
@@ -1,7 +1,7 @@
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import { translateWorkspaceBoardStatusLabel } from '@/components/sidebar/workspace-board-status-label'
import type { DashboardCard, DashboardFilterOption } from '../../../../shared/dashboard-snapshot'
import type { DashboardCard, DashboardFilterOption } from '../../../../../shared/dashboard-snapshot'
import type { DashboardReviewFilter } from './agent-board-filtering'
export type FilterOption = { id: string; label: string; count: number; color?: string }
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import type { AgentDashboardSurface } from '../../../../shared/telemetry-agent-dashboard-event-schemas'
import type { AgentDashboardSurface } from '../../../../../shared/telemetry-agent-dashboard-event-schemas'
import { getConsentState, track, trackWithResult } from '@/diagnostics/telemetry/telemetry'
import { installWindowVisibilityInterval } from '@/lib/window-activity/window-visibility-interval'
import { useAppStore } from '@/store/index'
@@ -1,6 +1,6 @@
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
import { useVirtualizer, type Range } from '@tanstack/react-virtual'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { DashboardCard } from '../../../../../shared/dashboard-snapshot'
import { extractPinnedVirtualRange } from '@/lib/list-interaction/pinned-virtual-range'
// Estimates follow the card's fixed rows; measurement replaces them for message/question variants.
@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness'
import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types'
import type { FolderWorkspace } from '../../../../../shared/folder-workspaces/folder-workspace-types'
import type { ProjectGroup } from '../../../../../shared/project-group-types'
import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
import '../../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import type { DashboardSnapshotState } from '../snapshot/build-dashboard-snapshot'
import {
buildDashboardBucketCounts,
createDashboardBucketCountsCache
} from './build-dashboard-bucket-counts'
const BASE = 1_700_000_000_000
const STALE = AGENT_STATUS_STALE_AFTER_MS
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
const LEAF_3 = '33333333-3333-4333-8333-333333333333'
const PANE_1 = makePaneKey('tab1', LEAF_1)
const PANE_2 = makePaneKey('tab2', LEAF_2)
const FOLDER_WORKSPACE_ID = folderWorkspaceKey('folder-1')
const PANE_3 = makePaneKey('tab3', LEAF_3)
function worktree(id: string): Worktree {
return {
id,
repoId: 'r1',
path: `/r1/${id}`,
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: false,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: BASE
}
}
function tab(id: string, worktreeId: string): TerminalTab {
return {
id,
ptyId: `pty-${id}`,
worktreeId,
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: BASE
}
}
function entry(
paneKey: string,
tabId: string,
worktreeId: string,
overrides: Partial<AgentStatusEntry> = {}
): AgentStatusEntry {
return {
paneKey,
state: 'working',
prompt: 'do the thing',
updatedAt: BASE,
stateStartedAt: BASE - 5_000,
stateHistory: [],
agentType: 'claude',
tabId,
worktreeId,
...overrides
}
}
function leafLayout(tabId: string, leafId: string) {
return {
root: { type: 'leaf', leafId } as const,
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: `pty-${tabId}` }
}
}
function folderWorkspace(): FolderWorkspace {
return {
id: 'folder-1',
projectGroupId: 'group-1',
name: 'Docs workspace',
folderPath: '/workspace/docs',
connectionId: null,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: BASE,
createdAt: BASE,
updatedAt: BASE
}
}
function projectGroup(): ProjectGroup {
return {
id: 'group-1',
name: 'Documentation',
parentPath: '/workspace',
connectionId: null,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: BASE,
updatedAt: BASE
}
}
function baseState(): DashboardSnapshotState {
return {
repos: [
{
id: 'r1',
path: '/r1',
displayName: 'Repo One',
badgeColor: '#000',
addedAt: 1
}
],
worktreesByRepo: { r1: [worktree('w1'), worktree('w2')] },
folderWorkspaces: [folderWorkspace()],
projectGroups: [projectGroup()],
tabsByWorktree: {
w1: [tab('tab1', 'w1')],
w2: [tab('tab2', 'w2')],
[FOLDER_WORKSPACE_ID]: [tab('tab3', FOLDER_WORKSPACE_ID)]
},
agentStatusByPaneKey: {
[PANE_1]: entry(PANE_1, 'tab1', 'w1'),
[PANE_2]: entry(PANE_2, 'tab2', 'w2', { state: 'done' }),
[PANE_3]: entry(PANE_3, 'tab3', FOLDER_WORKSPACE_ID, {
state: 'waiting'
})
},
retainedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
terminalLayoutsByTabId: {
tab1: leafLayout('tab1', LEAF_1),
tab2: leafLayout('tab2', LEAF_2),
tab3: leafLayout('tab3', LEAF_3)
},
ptyIdsByTabId: {
tab1: ['pty-tab1'],
tab2: ['pty-tab2'],
tab3: ['pty-tab3']
},
runtimePaneTitlesByTabId: { tab1: { 0: 'shell' } },
acknowledgedAgentsByPaneKey: {},
settings: null
} as unknown as DashboardSnapshotState
}
describe('buildDashboardBucketCounts equivalence with the unmemoized walk', () => {
it('keeps the last-fresh instant working and decays one ms later', () => {
const cache = createDashboardBucketCountsCache()
const state = baseState()
expect(buildDashboardBucketCounts(state, BASE, cache, 1).working).toBe(1)
// At now-observedAt === STALE the cache still hits and the entry remains fresh.
const atBoundary = buildDashboardBucketCounts(state, BASE + STALE, cache, 1)
expect(atBoundary.working).toBe(1)
const pastBoundary = buildDashboardBucketCounts(state, BASE + STALE + 1, cache, 2)
expect(pastBoundary.working).toBe(0)
})
})
@@ -1,26 +1,26 @@
import type { DashboardBucket } from '../../../../shared/dashboard-snapshot'
import type { DashboardSnapshotState } from './build-dashboard-snapshot'
import type { DashboardBucket } from '../../../../../shared/dashboard-snapshot'
import type { DashboardSnapshotState } from '../snapshot/build-dashboard-snapshot'
import {
collectActiveDashboardWorkspaces,
type ActiveDashboardWorkspace,
type DashboardWorkspaceState
} from './dashboard-snapshot-workspaces'
import { selectDashboardOrchestration } from './dashboard-orchestration-selection'
} from '../snapshot/dashboard-snapshot-workspaces'
import { selectDashboardOrchestration } from '../snapshot/dashboard-orchestration-selection'
import {
dashboardRowBucketProjection,
readAgentRowTurnRecords,
type AgentRowUnreadState
} from './dashboard-row-bucket'
import type { DashboardAgentRowWithLineage } from './agent-row-lineage'
import '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index'
} from '../rows/dashboard-row-bucket'
import type { DashboardAgentRowWithLineage } from '../rows/agent-row-lineage'
import '../../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../../sidebar/workspace-card/activity/worktree-agent-orchestration-index'
import {
createWorktreeAgentRowsCache,
finishWorktreeAgentRowsCachePass,
selectWorktreeAgentRowsCached,
startWorktreeAgentRowsCachePass,
type WorktreeAgentRowsCache
} from './worktree-agent-rows-cache'
} from '../rows/worktree-agent-rows-cache'
import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
const EMPTY_COUNTS: Record<DashboardBucket, number> = {
@@ -1,7 +1,7 @@
import { useMemo, useRef } from 'react'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import type { AppState } from '@/store/types'
import type { DashboardBucket } from '../../../../shared/dashboard-snapshot'
import type { DashboardBucket } from '../../../../../shared/dashboard-snapshot'
import {
buildDashboardBucketCounts,
createDashboardBucketCountsCache
@@ -1,319 +0,0 @@
import { describe, expect, it } from 'vitest'
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness'
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import type { DashboardBucket } from '../../../../shared/dashboard-snapshot'
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index'
import type { DashboardSnapshotState } from './build-dashboard-snapshot'
import {
buildDashboardBucketCounts,
createDashboardBucketCountsCache
} from './build-dashboard-bucket-counts'
import { selectDashboardOrchestration } from './dashboard-orchestration-selection'
import { dashboardRowBucketProjection, readAgentRowTurnRecords } from './dashboard-row-bucket'
import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces'
import { selectWorktreeAgentRowsCached } from './worktree-agent-rows-cache'
const BASE = 1_700_000_000_000
const STALE = AGENT_STATUS_STALE_AFTER_MS
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
const LEAF_3 = '33333333-3333-4333-8333-333333333333'
const PANE_1 = makePaneKey('tab1', LEAF_1)
const PANE_2 = makePaneKey('tab2', LEAF_2)
const FOLDER_WORKSPACE_ID = folderWorkspaceKey('folder-1')
const PANE_3 = makePaneKey('tab3', LEAF_3)
/** Unmemoized reference using shared sidebar primitives, independent of the memoized builder. */
function oracleBucketCounts(
state: DashboardSnapshotState,
now: number
): Record<DashboardBucket, number> {
const counts: Record<DashboardBucket, number> = {
attention: 0,
working: 0,
done: 0,
idle: 0
}
const activeWorktrees = collectActiveDashboardWorkspaces(state, false)
const { singletonOrchestration, orchestrationByWorktree } = selectDashboardOrchestration(
state,
activeWorktrees
)
for (const { worktree } of activeWorktrees) {
const rows = selectWorktreeAgentRowsCached({
state,
worktreeId: worktree.id,
orchestration:
singletonOrchestration ??
orchestrationByWorktree?.get(worktree.id) ??
EMPTY_WORKTREE_AGENT_ORCHESTRATION,
now,
generation: undefined
})
for (const row of rows) {
if (row.rowSource === 'subagent') {
continue
}
counts[dashboardRowBucketProjection(row, readAgentRowTurnRecords(state)).bucket] += 1
}
}
return counts
}
function worktree(id: string): Worktree {
return {
id,
repoId: 'r1',
path: `/r1/${id}`,
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: false,
displayName: id,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: BASE
}
}
function tab(id: string, worktreeId: string): TerminalTab {
return {
id,
ptyId: `pty-${id}`,
worktreeId,
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: BASE
}
}
function entry(
paneKey: string,
tabId: string,
worktreeId: string,
overrides: Partial<AgentStatusEntry> = {}
): AgentStatusEntry {
return {
paneKey,
state: 'working',
prompt: 'do the thing',
updatedAt: BASE,
stateStartedAt: BASE - 5_000,
stateHistory: [],
agentType: 'claude',
tabId,
worktreeId,
...overrides
}
}
function leafLayout(tabId: string, leafId: string) {
return {
root: { type: 'leaf', leafId } as const,
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: `pty-${tabId}` }
}
}
function folderWorkspace(): FolderWorkspace {
return {
id: 'folder-1',
projectGroupId: 'group-1',
name: 'Docs workspace',
folderPath: '/workspace/docs',
connectionId: null,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: BASE,
createdAt: BASE,
updatedAt: BASE
}
}
function projectGroup(): ProjectGroup {
return {
id: 'group-1',
name: 'Documentation',
parentPath: '/workspace',
connectionId: null,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: BASE,
updatedAt: BASE
}
}
function baseState(): DashboardSnapshotState {
return {
repos: [
{
id: 'r1',
path: '/r1',
displayName: 'Repo One',
badgeColor: '#000',
addedAt: 1
}
],
worktreesByRepo: { r1: [worktree('w1'), worktree('w2')] },
folderWorkspaces: [folderWorkspace()],
projectGroups: [projectGroup()],
tabsByWorktree: {
w1: [tab('tab1', 'w1')],
w2: [tab('tab2', 'w2')],
[FOLDER_WORKSPACE_ID]: [tab('tab3', FOLDER_WORKSPACE_ID)]
},
agentStatusByPaneKey: {
[PANE_1]: entry(PANE_1, 'tab1', 'w1'),
[PANE_2]: entry(PANE_2, 'tab2', 'w2', { state: 'done' }),
[PANE_3]: entry(PANE_3, 'tab3', FOLDER_WORKSPACE_ID, {
state: 'waiting'
})
},
retainedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
terminalLayoutsByTabId: {
tab1: leafLayout('tab1', LEAF_1),
tab2: leafLayout('tab2', LEAF_2),
tab3: leafLayout('tab3', LEAF_3)
},
ptyIdsByTabId: {
tab1: ['pty-tab1'],
tab2: ['pty-tab2'],
tab3: ['pty-tab3']
},
runtimePaneTitlesByTabId: { tab1: { 0: 'shell' } },
acknowledgedAgentsByPaneKey: {},
settings: null
} as unknown as DashboardSnapshotState
}
/** Walk one cache across freshness boundaries; generation changes only when decay may shift buckets, and the exact last-fresh instant must still hit. */
const CLOCK_WALK: { label: string; now: number; generation: number }[] = [
{ label: 'cold', now: BASE, generation: 1 },
{ label: 'last fresh instant (cache hit)', now: BASE + STALE, generation: 1 },
{ label: 'first stale instant', now: BASE + STALE + 1, generation: 2 },
{ label: 'long stale (cache hit)', now: BASE + STALE * 4, generation: 2 },
{ label: 'long stale (recomputed)', now: BASE + STALE * 4, generation: 3 }
]
type Mutation = {
label: string
apply: (state: DashboardSnapshotState) => DashboardSnapshotState
}
const MUTATIONS: Mutation[] = [
{ label: 'unchanged', apply: (state) => state },
{
label: 'acknowledgement written',
apply: (state) => ({
...state,
acknowledgedAgentsByPaneKey: { [PANE_2]: BASE + STALE * 8 }
})
},
{
label: 'unrelated pane-title frame',
apply: (state) => ({
...state,
runtimePaneTitlesByTabId: {
...state.runtimePaneTitlesByTabId,
tab2: { 0: 'sh' }
}
})
},
{
label: 'status write on one worktree',
apply: (state) => ({
...state,
agentStatusByPaneKey: {
...state.agentStatusByPaneKey,
[PANE_1]: entry(PANE_1, 'tab1', 'w1', {
prompt: 'streamed',
state: 'blocked'
})
}
})
},
{
label: 'worktree leaves the active set',
apply: (state) => ({ ...state, worktreesByRepo: { r1: [worktree('w1')] } })
},
{
label: 'folder workspace archived',
apply: (state) => ({
...state,
folderWorkspaces: [{ ...folderWorkspace(), isArchived: true }]
})
},
{
label: 'project group renamed',
apply: (state) => ({
...state,
projectGroups: [{ ...projectGroup(), name: 'Renamed' }]
})
},
{
label: 'pty goes away',
apply: (state) => ({
...state,
ptyIdsByTabId: { tab2: ['pty-tab2'], tab3: ['pty-tab3'] }
})
}
]
describe('buildDashboardBucketCounts equivalence with the unmemoized walk', () => {
it('matches the oracle when mutations are applied cumulatively through one cache', () => {
const cache = createDashboardBucketCountsCache()
let state = baseState()
for (const mutation of MUTATIONS) {
state = mutation.apply(state)
for (const step of CLOCK_WALK) {
expect(
buildDashboardBucketCounts(state, step.now, cache, step.generation),
`${mutation.label} @ ${step.label}`
).toEqual(oracleBucketCounts(state, step.now))
}
}
})
it('serves the last-fresh instant from a cache hit and still decays one ms later', () => {
const cache = createDashboardBucketCountsCache()
const state = baseState()
expect(buildDashboardBucketCounts(state, BASE, cache, 1).working).toBe(1)
expect(cache.lastComputedWorktreeIds.length).toBe(3)
// At now-observedAt === STALE the cache still hits and the entry remains fresh.
const atBoundary = buildDashboardBucketCounts(state, BASE + STALE, cache, 1)
expect(cache.lastComputedWorktreeIds).toEqual([])
expect(atBoundary.working).toBe(1)
expect(atBoundary).toEqual(oracleBucketCounts(state, BASE + STALE))
const pastBoundary = buildDashboardBucketCounts(state, BASE + STALE + 1, cache, 2)
expect(pastBoundary.working).toBe(0)
expect(pastBoundary).toEqual(oracleBucketCounts(state, BASE + STALE + 1))
})
})
@@ -1,391 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildDashboardSnapshot,
type DashboardSnapshotInput as DashboardSnapshotState
} from './build-dashboard-snapshot'
import type {
AgentStatusEntry,
AgentStatusOrchestrationContext
} from '../../../../shared/agent-status/contracts/agent-status-types'
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { selectRuntimeAgentOrchestrationBatch } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import '../sidebar/workspace-card/activity/worktree-agent-row-selectors'
import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForWorktree } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index'
import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces'
import type * as AgentRowLineageModule from './agent-row-lineage'
const mapMetadataCalls = vi.hoisted(() => ({
hostKind: vi.fn(),
parentPaneKey: vi.fn()
}))
vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => {
const actual = await importOriginal<typeof DashboardSnapshotWorkspacesModule>()
return {
...actual,
dashboardCardMapWorkspaceMetadata: (
...args: Parameters<typeof actual.dashboardCardMapWorkspaceMetadata>
) => {
mapMetadataCalls.hostKind()
return actual.dashboardCardMapWorkspaceMetadata(...args)
}
}
})
vi.mock('./agent-row-lineage', async (importOriginal) => {
const actual = await importOriginal<typeof AgentRowLineageModule>()
return {
...actual,
dashboardCardParentPaneKey: (...args: Parameters<typeof actual.dashboardCardParentPaneKey>) => {
mapMetadataCalls.parentPaneKey()
return actual.dashboardCardParentPaneKey(...args)
}
}
})
const NOW = 1_000_000_000
const TAB_ID = 'tab1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SPLIT_SIBLING_LEAF_ID = '55555555-5555-4555-8555-555555555555'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
function entry(overrides: Partial<AgentStatusEntry>): AgentStatusEntry {
return {
paneKey: PANE_KEY,
state: 'working',
prompt: 'do the thing',
updatedAt: NOW,
stateStartedAt: NOW - 5000,
stateHistory: [],
agentType: 'claude',
tabId: TAB_ID,
worktreeId: 'w1',
...overrides
}
}
function tab(id = TAB_ID, worktreeId = 'w1'): TerminalTab {
return {
id,
ptyId: 'pty1',
worktreeId,
title: 'agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: NOW
}
}
function worktree(id = 'w1', displayName = 'wt-one'): Worktree {
return {
id,
repoId: 'r1',
path: `/r1/${id}`,
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: false,
displayName,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW
}
}
function baseState(overrides: Partial<DashboardSnapshotState>): DashboardSnapshotState {
return {
chatNames: new Map(),
repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000', addedAt: NOW }],
worktreesByRepo: { r1: [worktree()] },
tabsByWorktree: { w1: [tab()] },
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty1' }
}
},
ptyIdsByTabId: { [TAB_ID]: ['pty1'] },
runtimePaneTitlesByTabId: {},
acknowledgedAgentsByPaneKey: {},
settings: null,
...overrides
}
}
describe('buildDashboardSnapshot', () => {
it('names each pane of a split tab from its own title', () => {
const siblingPaneKey = makePaneKey(TAB_ID, SPLIT_SIBLING_LEAF_ID)
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
[PANE_KEY]: entry({}),
[siblingPaneKey]: entry({ paneKey: siblingPaneKey })
},
// The tab title is whichever pane has focus, so it must not name both.
tabsByWorktree: { w1: [{ ...tab(), title: '\u2733 Linear work log' }] },
terminalLayoutsByTabId: {
[TAB_ID]: {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SPLIT_SIBLING_LEAF_ID }
},
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty1', [SPLIT_SIBLING_LEAF_ID]: 'pty2' }
}
},
ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty2'] },
// Pane ids are replay-creation-ordered: 1 -> first leaf, 2 -> second.
runtimePaneTitlesByTabId: {
[TAB_ID]: { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' }
}
}),
NOW
)
const nameByPaneKey = new Map(
snapshot.cards.map((card) => [card.paneKey, card.conversationName])
)
expect(nameByPaneKey.get(PANE_KEY)).toBe('Linear work log')
expect(nameByPaneKey.get(siblingPaneKey)).toBe('Redis cache strategy')
})
it('nulls ptyId when the layout entry points at a dead pty', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
// Layouts retain old PTY IDs after restart; only the live set proves preview availability.
ptyIdsByTabId: { [TAB_ID]: [] }
}),
NOW
)
expect(snapshot.cards[0].ptyId).toBeNull()
})
it('labels a waiting agent with its question, not the prompt envelope JSON', () => {
const interactivePrompt = JSON.stringify({
questions: [{ question: 'Which database?', options: [{ label: 'Postgres' }] }]
})
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
[PANE_KEY]: entry({ state: 'waiting', toolName: 'AskUserQuestion', interactivePrompt })
}
}),
NOW
)
expect(snapshot.cards[0].askSummary).toBe('Which database?')
})
it('skips card-only context for count snapshots', () => {
mapMetadataCalls.hostKind.mockClear()
mapMetadataCalls.parentPaneKey.mockClear()
let linkedReviewReads = 0
let hostIdentityReads = 0
const countWorktree = worktree()
Object.defineProperty(countWorktree, 'linkedPR', {
enumerable: true,
get: () => {
linkedReviewReads += 1
return null
}
})
const countRepo = {
id: 'r1',
path: '/r1',
displayName: 'Repo One',
badgeColor: '#000',
addedAt: 0,
get connectionId() {
hostIdentityReads += 1
return null
}
}
const snapshot = buildDashboardSnapshot(
baseState({
repos: [countRepo],
worktreesByRepo: { r1: [countWorktree] },
agentStatusByPaneKey: { [PANE_KEY]: entry({}) }
}),
NOW,
{ includeCardDetails: false, includeFilterOptions: false }
)
expect(snapshot.cards[0].workspaceStatusId).toBeUndefined()
expect(snapshot.cards[0].subagents).toBeUndefined()
expect(snapshot.cards[0].parentPaneKey).toBeUndefined()
expect(snapshot.cards[0].parentWorktreeId).toBeUndefined()
expect(snapshot.cards[0].hostKind).toBeUndefined()
expect(snapshot.cards[0].workspaceKind).toBeUndefined()
// A live PTY alone must not trigger host-input resolution on sidebar count rebuilds.
expect(snapshot.cards[0].ptyId).toBe('pty1')
expect(snapshot.cards[0].terminalInput).toBeUndefined()
expect(snapshot.workspaces).toBeUndefined()
expect(linkedReviewReads).toBe(0)
expect(hostIdentityReads).toBe(0)
expect(mapMetadataCalls.hostKind).not.toHaveBeenCalled()
expect(mapMetadataCalls.parentPaneKey).not.toHaveBeenCalled()
})
it('indexes runtime host labels once per detailed snapshot', () => {
let environmentIdReads = 0
const environmentCount = 24
const runtimeEnvironments = Array.from({ length: environmentCount }, (_, index) => {
const environment = { id: `environment-${index}`, name: `Builder ${index}` }
Object.defineProperty(environment, 'id', {
enumerable: true,
get: () => {
environmentIdReads += 1
return `environment-${index}`
}
})
return environment
}) as unknown as DashboardSnapshotState['runtimeEnvironments']
const executionHostId = `runtime:environment-${environmentCount - 1}` as const
const snapshot = buildDashboardSnapshot(
baseState({
repos: [
{
id: 'r1',
path: '/r1',
displayName: 'Repo One',
badgeColor: '#000',
addedAt: 0,
executionHostId
}
],
worktreesByRepo: {
r1: [
{ ...worktree(), hostId: executionHostId },
{ ...worktree('w2', 'wt-two'), hostId: executionHostId }
]
},
runtimeEnvironments,
agentStatusByPaneKey: { [PANE_KEY]: entry({}) }
}),
NOW
)
expect(snapshot.cards[0].hostLabel).toBe(`Builder ${environmentCount - 1}`)
expect(environmentIdReads).toBe(environmentCount)
})
// Singleton/zero dashboard transitions must not release the shared orchestration index still read by mounted sidebar cards.
it('keeps batch records live and correct when production moves from multi to singleton to zero', () => {
const secondLeafId = '77777777-7777-4777-8777-777777777777'
const firstPaneKey = makePaneKey('tab-w1', LEAF_ID)
const secondPaneKey = makePaneKey('tab-w2', secondLeafId)
const runtimeAgentOrchestrationByPaneKey = {
[firstPaneKey]: {
taskId: 'task-1',
dispatchId: 'dispatch-1'
},
[secondPaneKey]: {
taskId: 'task-2',
dispatchId: 'dispatch-2'
}
}
const multiState = baseState({
worktreesByRepo: { r1: [worktree('w1'), worktree('w2')] },
tabsByWorktree: {
w1: [tab('tab-w1', 'w1')],
w2: [tab('tab-w2', 'w2')]
},
runtimeAgentOrchestrationByPaneKey
})
const requested = ['w1', 'w2']
const firstBatch = selectRuntimeAgentOrchestrationBatch(multiState, requested)
const firstW1 = firstBatch.get('w1')
buildDashboardSnapshot(
baseState({
worktreesByRepo: { r1: [worktree('w1')] },
tabsByWorktree: multiState.tabsByWorktree,
runtimeAgentOrchestrationByPaneKey
}),
NOW
)
const afterSingleton = selectRuntimeAgentOrchestrationBatch(multiState, requested)
expect(afterSingleton).toBe(firstBatch)
expect(afterSingleton.get('w1')).toBe(firstW1)
buildDashboardSnapshot(baseState({ repos: [], worktreesByRepo: {} }), NOW)
const afterZero = selectRuntimeAgentOrchestrationBatch(multiState, requested)
expect(afterZero).toBe(firstBatch)
for (const worktreeId of requested) {
expect(afterZero.get(worktreeId)).toBe(
selectRuntimeAgentOrchestrationForWorktree(multiState, worktreeId)
)
}
})
it('scans orchestration runtime once for a dashboard snapshot', () => {
const worktreeCount = 24
const contextCount = 128
let runtimeEnumerations = 0
let contextVisits = 0
const runtimeRecords: Record<string, AgentStatusOrchestrationContext> = {}
for (let index = 0; index < contextCount; index += 1) {
const paneKey = makePaneKey(
`tab-${index % worktreeCount}`,
`33333333-3333-4333-8333-${index.toString(16).padStart(12, '0')}`
)
runtimeRecords[paneKey] = {
taskId: `task-${index}`,
dispatchId: `dispatch-${index}`,
get parentPaneKey() {
contextVisits += 1
return undefined
}
}
}
const runtimeAgentOrchestrationByPaneKey = new Proxy(runtimeRecords, {
ownKeys(target) {
runtimeEnumerations += 1
return Reflect.ownKeys(target)
}
})
const worktrees = Array.from({ length: worktreeCount }, (_, index) =>
worktree(`w${index}`, `wt-${index}`)
)
const tabsByWorktree = Object.fromEntries(
worktrees.map((worktree, index) => [worktree.id, [tab(`tab-${index}`, worktree.id)]])
)
const snapshot = buildDashboardSnapshot(
baseState({
worktreesByRepo: { r1: worktrees },
tabsByWorktree,
runtimeAgentOrchestrationByPaneKey
}),
NOW
)
expect(snapshot.cards).toEqual([])
expect(runtimeEnumerations).toBe(1)
expect(contextVisits).toBe(contextCount)
})
})
@@ -0,0 +1,163 @@
import type * as RuntimeRpcCallModule from '@/runtime/rpc/calls/runtime-rpc-call'
// @vitest-environment happy-dom
// @vitest-environment happy-dom
import { cleanup } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store/index'
import {
makeCreatedAgentWorktree,
seedAlreadyActiveWorktree
} from '@/lib/workspace/activation/worktree-activation-created-agent.test-fixture'
import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import type * as WebRuntimeSessionEnvironment from '@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment'
import { activateRetainedAgentRow } from '../../sidebar/workspace-card/activity/activate-retained-agent-row'
import { activateWorktreeAgentPane } from '../../sidebar/workspace-card/activity/use-worktree-agent-activation'
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
const effects = vi.hoisted(() => ({
rpc: vi.fn(async () => ({ ok: true })),
composerFocus: vi.fn(),
environmentCall: vi.fn(async (_environmentId: string, _args: unknown) => ({
id: 'navigation-test',
ok: false as const,
error: { code: 'unavailable', message: 'Unit-test transport boundary' }
}))
}))
vi.mock('@/runtime/rpc/calls/runtime-rpc-call', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeRpcCallModule>()),
callRuntimeRpc: effects.rpc
}))
vi.mock('@/components/native-chat/composer/focus/native-chat-composer-focus-intent', () => ({
requestNativeChatComposerFocus: effects.composerFocus
}))
vi.mock('../terminal-preview/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null }))
vi.mock(
'@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment',
async (importOriginal) => ({
...(await importOriginal<typeof WebRuntimeSessionEnvironment>()),
captureRuntimeEnvironmentCall: (environmentId: string) => (args: unknown) =>
effects.environmentCall(environmentId, args)
})
)
const initialState = useAppStore.getState()
const NOW = 1_000_000_000
const LEAF = '11111111-1111-4111-8111-111111111111'
const WORKSPACE_HOST = 'runtime:host-A' as const
const TAB_HOST = 'runtime:host-B' as const
afterEach(() => {
cleanup()
useAppStore.setState(initialState, true)
vi.clearAllMocks()
vi.restoreAllMocks()
})
describe('Dashboard and retained sidebar destination contract', () => {
it('notifies the exact live retained terminal owner instead of the workspace default', async () => {
const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST }
seedAlreadyActiveWorktree(worktree, {
worktreesByRepo: { [worktree.repoId]: [worktree, { ...worktree, hostId: TAB_HOST }] },
activeWorkspaceExecutionHostId: WORKSPACE_HOST,
ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF },
activeLeafId: LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' }
}
}
})
activateRetainedAgentRow(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF))
expect(useAppStore.getState().activeWorkspaceExecutionHostId).toBe(TAB_HOST)
await vi.waitFor(() =>
expect(effects.environmentCall).toHaveBeenCalledWith(
'host-B',
expect.objectContaining({
method: 'session.tabs.activate',
params: expect.objectContaining({ leafId: LEAF })
})
)
)
expect(useAppStore.getState().getActiveTab(worktree.id)?.id).toBe('tab-1')
})
})
it('rejects a live terminal row on a different host before workspace activation', () => {
const worktree = { ...makeCreatedAgentWorktree(), hostId: WORKSPACE_HOST }
seedAlreadyActiveWorktree(worktree, {
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabId: null,
ptyIdsByTabId: { 'tab-1': ['remote:host-B@@pty-B'] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF },
activeLeafId: LEAF,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF]: 'remote:host-B@@pty-B' }
}
}
})
activateWorktreeAgentPane(worktree.id, 'tab-1', makePaneKey('tab-1', LEAF), WORKSPACE_HOST)
expect(useAppStore.getState()).toMatchObject({
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabId: null
})
})
it.each([
{ label: 'live', activate: activateWorktreeAgentPane },
{ label: 'retained', activate: activateRetainedAgentRow }
])('opens a $label paired chat from its physical SSH workspace', ({ activate }) => {
const worktree = {
...makeCreatedAgentWorktree(),
hostId: 'ssh:server' as const,
runtimeOwnerEnvironmentId: 'host-B'
}
seedAlreadyActiveWorktree(worktree, {
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeTabType: 'editor',
unifiedTabsByWorktree: {
[worktree.id]: [
{
id: 'native-B',
worktreeId: worktree.id,
executionHostId: TAB_HOST,
contentType: 'agent-session',
agentSessionAgent: 'claude',
entityId: 'session-B',
groupId: 'group-1',
label: 'Codex',
customLabel: null,
color: null,
sortOrder: 1,
createdAt: NOW
}
]
}
})
activate(
worktree.id,
'native-B',
structuredAgentSessionPaneKey('native-B', 'session-B'),
'ssh:server'
)
expect(useAppStore.getState()).toMatchObject({
activeWorktreeId: worktree.id,
activeWorkspaceExecutionHostId: 'ssh:server',
activeTabType: 'agent-session'
})
})
@@ -2,7 +2,7 @@
import { cleanup } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Tab } from '../../../../shared/tab-types'
import type { Tab } from '../../../../../shared/tab-types'
const mocks = vi.hoisted(() => ({
activateAndRevealWorkspace: vi.fn(),
@@ -21,7 +21,7 @@ const getState = () => ({
activateTab: mocks.activateTab,
setActiveTabType: mocks.setActiveTabType
})
vi.mock('@/store', () => ({ useAppStore: { getState: () => getState() } }))
vi.mock('@/store/index', () => ({ useAppStore: { getState: () => getState() } }))
vi.mock('@/store/index', () => ({ useAppStore: { getState: () => getState() } }))
vi.mock('@/lib/workspace/activation/worktree-activation', () => ({
activateAndRevealWorkspace: mocks.activateAndRevealWorkspace
@@ -45,7 +45,7 @@ vi.mock('@/runtime/rpc/targets/runtime-worktree-selector', () => ({
toRuntimeWorktreeSelector: (id: string) => `id:${id}`
}))
vi.mock('../dashboard-popout/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null }))
vi.mock('../terminal-preview/AgentTerminalPreview', () => ({ AgentTerminalPreview: () => null }))
import { revealDashboardAgent } from './reveal-dashboard-agent'
@@ -82,21 +82,8 @@ describe('revealDashboardAgent', () => {
mocks.activateAndRevealWorkspace.mockReturnValue(true)
})
it('does not fall back to a terminal when the current native owner has no runtime', () => {
it('refuses a native owner without a runtime', () => {
mocks.tabs = [{ ...nativeTab('codex'), executionHostId: 'ssh:host-C' }]
expect(revealDashboardAgent(args)).toBe(false)
expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled()
expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled()
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
it.each([false, true])('touches no tab when workspace reveal fails (native=%s)', (native) => {
mocks.tabs = native ? [nativeTab('codex')] : []
mocks.activateAndRevealWorkspace.mockReturnValue(false)
expect(revealDashboardAgent(args)).toBe(false)
expect(mocks.activateTerminalTabOnOwner).not.toHaveBeenCalled()
expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled()
expect(mocks.activateTab).not.toHaveBeenCalled()
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
})
@@ -5,8 +5,8 @@ import {
structuredAgentSessionOwnerForTab,
structuredAgentSessionTargetForTab
} from '@/runtime/native-chat/session-ownership/structured-agent-session-owner'
import { useAppStore } from '@/store'
import type { DashboardRevealAgentArgs } from '../../../../shared/dashboard-snapshot'
import { useAppStore } from '@/store/index'
import type { DashboardRevealAgentArgs } from '../../../../../shared/dashboard-snapshot'
export function revealDashboardAgent(args: DashboardRevealAgentArgs): boolean {
const state = useAppStore.getState()
@@ -1,14 +1,17 @@
import { useEffect } from 'react'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import type { AppState } from '@/store/types'
import {
subscribeStructuredChatTabNames,
getStructuredChatTabNames
} from '@/runtime/native-chat/naming/structured-chat-name-observation'
import { revealDashboardAgent } from './reveal-dashboard-agent'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot'
import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache'
import { revealDashboardAgent } from '../navigation/reveal-dashboard-agent'
import type { RepoIcon } from '../../../../../shared/repo-icon'
import {
buildDashboardSnapshot,
type DashboardSnapshotState
} from '../snapshot/build-dashboard-snapshot'
import { createWorktreeAgentRowsCache } from '../rows/worktree-agent-rows-cache'
import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
// Throttle cross-worktree snapshot rebuilds to 4Hz during agent-status bursts.
@@ -0,0 +1,230 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { useAppStore } from '@/store/index'
import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-status/agent-pane-authority'
import type {
AgentStatusEntry,
AgentStatusState
} from '../../../../../shared/agent-status/contracts/agent-status-types'
import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import type { Repo } from '../../../../../shared/repo-types'
import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import { useRetainedAgentsSync } from './useRetainedAgents'
const initialAppState = useAppStore.getInitialState()
beforeEach(() => {
useAppStore.setState(initialAppState, true)
resetAgentPaneAuthorityAliasesForTests()
})
afterEach(() => {
useAppStore.setState(initialAppState, true)
resetAgentPaneAuthorityAliasesForTests()
})
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1
}
}
function makeWorktree(overrides?: Partial<Worktree>): Worktree {
return {
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/wt-1',
head: 'abc123',
branch: 'feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
function makeTab(overrides: Partial<TerminalTab> & { id: string }): TerminalTab {
return {
worktreeId: 'wt-1',
title: 'Terminal',
ptyId: null,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
...overrides
}
}
function makeAgentRow(args: {
paneKey: string
state: AgentStatusState
interrupted?: boolean
mainAgent?: AgentStatusEntry['mainAgent']
}) {
const entry: AgentStatusEntry = {
state: args.state,
prompt: 'Fix it',
updatedAt: 100,
stateStartedAt: 100,
paneKey: args.paneKey,
terminalTitle: 'Claude',
stateHistory: [],
agentType: 'claude',
interrupted: args.interrupted,
mainAgent: args.mainAgent
}
return {
paneKey: args.paneKey,
entry,
tab: makeTab({ id: 'tab-1' }),
agentType: 'claude' as const,
state: args.state,
startedAt: 1
}
}
describe('useRetainedAgentsSync', () => {
it('uses the freshest tab title when a real agent transition retains a completed row', async () => {
const repo = makeRepo()
const worktree = makeWorktree()
const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const row = makeAgentRow({ paneKey, state: 'done' })
useAppStore.setState({
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
tabsByWorktree: {
[worktree.id]: [{ ...row.tab, title: '⠋ Codex is thinking' }]
},
agentStatusByPaneKey: { [paneKey]: row.entry },
agentStatusEpoch: initialAppState.agentStatusEpoch + 1
})
const hook = renderHook(() => useRetainedAgentsSync())
await act(async () => {
await Promise.resolve()
})
act(() => {
useAppStore.setState((state) => ({
tabsByWorktree: {
[worktree.id]: [
{
...state.tabsByWorktree[worktree.id][0],
title: '⠙ Codex is thinking'
}
]
}
}))
})
act(() => {
useAppStore.setState((state) => ({
agentStatusByPaneKey: {},
agentStatusEpoch: state.agentStatusEpoch + 1
}))
})
await act(async () => {
await Promise.resolve()
})
expect(useAppStore.getState().retainedAgentsByPaneKey[paneKey]?.tab.title).toBe(
'⠙ Codex is thinking'
)
hook.unmount()
})
it('does not re-retain when status removal and tab closure commit before the next retention effect', async () => {
const repo = makeRepo()
const worktree = makeWorktree()
const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const row = makeAgentRow({ paneKey, state: 'done' })
useAppStore.setState({
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
tabsByWorktree: { [worktree.id]: [row.tab] },
agentStatusByPaneKey: { [row.paneKey]: row.entry },
agentStatusEpoch: initialAppState.agentStatusEpoch + 1
})
const hook = renderHook(() => useRetainedAgentsSync())
await act(async () => {
await Promise.resolve()
})
// Why: model both teardown writes landing before the next retention effect runs.
act(() => {
useAppStore.setState((state) => ({
tabsByWorktree: { [worktree.id]: [] },
agentStatusByPaneKey: {},
recentlyClosedAgentStatusTabIds: { [row.tab.id]: true },
agentStatusEpoch: state.agentStatusEpoch + 1
}))
})
const state = useAppStore.getState()
expect(state.retainedAgentsByPaneKey[row.paneKey]).toBeUndefined()
hook.unmount()
})
it('does not leave a ghost row when a done pane detaches into another tab', async () => {
const repo = makeRepo()
const worktree = makeWorktree()
const leafId = '11111111-1111-4111-8111-111111111111'
const sourceTab = makeTab({ id: 'tab-source', ptyId: 'pty-a' })
const targetTab = makeTab({ id: 'tab-target' })
const sourcePaneKey = makePaneKey(sourceTab.id, leafId)
const targetPaneKey = makePaneKey(targetTab.id, leafId)
const row = makeAgentRow({ paneKey: sourcePaneKey, state: 'done' })
useAppStore.setState({
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
tabsByWorktree: { [worktree.id]: [sourceTab, targetTab] },
ptyIdsByTabId: { [sourceTab.id]: ['pty-a'], [targetTab.id]: [] },
agentStatusByPaneKey: { [sourcePaneKey]: { ...row.entry, tabId: sourceTab.id } },
agentStatusEpoch: initialAppState.agentStatusEpoch + 1
})
const hook = renderHook(() => useRetainedAgentsSync())
await act(async () => {
await Promise.resolve()
})
act(() => {
useAppStore.getState().syncPaneDetachPtyOwnership({
detachedLeafId: leafId,
detachedPtyId: 'pty-a',
sourceLayout: {
root: null,
activeLeafId: null,
expandedLeafId: null,
ptyIdsByLeafId: {}
},
sourceTabId: sourceTab.id,
targetTabId: targetTab.id
})
})
await act(async () => {
await Promise.resolve()
})
const state = useAppStore.getState()
expect(state.retainedAgentsByPaneKey[sourcePaneKey]).toBeUndefined()
expect(state.retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined()
hook.unmount()
})
})
@@ -1,23 +1,23 @@
import { agentEntrySessionStartedAt } from '../../../../shared/agent-session/lifecycle/agent-session-start-time'
import { agentEntrySessionStartedAt } from '../../../../../shared/agent-session/lifecycle/agent-session-start-time'
import { useEffect, useRef } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import {
isAgentStatusFresh,
AGENT_STATUS_STALE_AFTER_MS
} from '../../../../shared/agent-status/contracts/agent-status-freshness'
import type { DashboardAgentRow } from './useDashboardData'
} from '../../../../../shared/agent-status/contracts/agent-status-freshness'
import type { DashboardAgentRow } from '../rows/dashboard-agent-row'
import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract'
import type { FolderWorkspace } from '../../../../shared/folder-workspaces/folder-workspace-types'
import type { Repo } from '../../../../shared/repo-types'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import type { FolderWorkspace } from '../../../../../shared/folder-workspaces/folder-workspace-types'
import type { Repo } from '../../../../../shared/repo-types'
import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import { folderWorkspaceKey } from '../../../../../shared/workspace-scope'
import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-status/agent-pane-authority'
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { agentTurnEndedOnPurpose } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types'
import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import { agentTurnEndedOnPurpose } from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import {
createWorktreeTabBucketProjection,
@@ -1,9 +1,9 @@
import { agentEntryCompletionAt } from '../../../../shared/agent-status/lifecycle/agent-completion-time'
import { agentEntryCompletionAt } from '../../../../../shared/agent-status/lifecycle/agent-completion-time'
import {
agentTurnEndedOnPurpose,
agentVerdictDisplayMark
} from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import type { DashboardAgentRow } from './useDashboardData'
} from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import type { DashboardAgentRow } from './dashboard-agent-row'
/** Share completion age across sidebar and dashboard, including stopped/failed main turns and host-settled child rows. */
export function lastEnteredDoneAt(
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { DashboardAgentRow } from './useDashboardData'
import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types'
import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types'
import type { DashboardAgentRow } from './dashboard-agent-row'
import { buildAgentRowLineageTree } from './agent-row-lineage-model'
function makeTab(id: string): TerminalTab {
@@ -73,16 +73,6 @@ describe('buildAgentRowLineageTree', () => {
expect(tree.childPaneKeys.size).toBe(0)
})
it('does not use the call stack for a long parent chain', () => {
const rows = Array.from({ length: 10_000 }, (_, index) =>
makeRow(`pane-${index}`, index > 0 ? { parentPaneKey: `pane-${index - 1}` } : {})
)
const tree = buildAgentRowLineageTree(rows)
expect(tree.rootRows).toEqual([rows[0]])
expect(tree.childPaneKeys.size).toBe(rows.length - 1)
expect(tree.childrenByParentPaneKey.get('pane-9998')?.[0]).toBe(rows[9999])
})
it('terminates a reachable cycle introduced by duplicate pane rows', () => {
const root = makeRow('root')
const first = makeRow('first', { parentPaneKey: 'root' })
@@ -1,4 +1,4 @@
import type { AgentStatusEntry } from '../../../../shared/agent-status/contracts/agent-status-types'
import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types'
/** Minimal lineage input for real dashboard rows and synthetic thread-list rows. */
export type AgentLineageSourceRow = {
@@ -1,4 +1,4 @@
import type { DashboardAgentRow } from './useDashboardData'
import type { DashboardAgentRow } from './dashboard-agent-row'
import { buildAgentRowLineageTree } from './agent-row-lineage-model'
export type AgentRowLineagePresentation = {
@@ -1,5 +1,5 @@
import { resolveRuntimePaneTitleForLeaf } from '../../../../shared/runtime-pane-title-leaf-id'
import type { TerminalLayoutSnapshot } from '../../../../shared/workspace-layout/terminal-tab-types'
import { resolveRuntimePaneTitleForLeaf } from '../../../../../shared/runtime-pane-title-leaf-id'
import type { TerminalLayoutSnapshot } from '../../../../../shared/workspace-layout/terminal-tab-types'
/** Undefined preserves single-pane tab-title fallback; null suppresses unattributable split titles so siblings cannot inherit the focused pane’s name. */
export function resolveAgentRowPaneLiveTitle(
@@ -1,10 +1,10 @@
import type {
AgentStatusEntry,
AgentType
} from '../../../../shared/agent-status/contracts/agent-status-types'
import type { AgentRowState } from '../../../../shared/agent-status/presentation/agent-row-decay-state'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { AgentChildRowModel } from '../../../../shared/agent-status/presentation/agent-child-row-model'
} from '../../../../../shared/agent-status/contracts/agent-status-types'
import type { AgentRowState } from '../../../../../shared/agent-status/presentation/agent-row-decay-state'
import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types'
import type { AgentChildRowModel } from '../../../../../shared/agent-status/presentation/agent-child-row-model'
export type DashboardAgentRow = {
/** Synthetic subagent keys identify rows only; they have no PTY and must never be parsed as pane keys. */
@@ -1,11 +1,11 @@
import type { DashboardAgentRow } from './useDashboardData'
import type { DashboardAgentRow } from './dashboard-agent-row'
import {
dashboardCardBaseDisplayState,
type DashboardBucket,
type DashboardCardDotState
} from '../../../../shared/dashboard-snapshot'
import { dashboardBucketForDotState } from './dashboard-card-bucket'
import type { AgentRowState } from '../../../../shared/agent-status/presentation/agent-row-decay-state'
} from '../../../../../shared/dashboard-snapshot'
import { dashboardBucketForDotState } from '../snapshot/dashboard-card-bucket'
import type { AgentRowState } from '../../../../../shared/agent-status/presentation/agent-row-decay-state'
import {
isAgentSubjectUnread,
type AgentAttentionTurnRecords
@@ -1,12 +1,12 @@
import { useAppStateValue } from '@/store/retained-views/app-state-view'
import { getAgentRowConversationName } from '../../../../shared/agent-status/presentation/agent-row-conversation-name'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { structuredChatDisplayName } from '../../../../shared/structured-chat-row-name'
import { getAgentRowConversationName } from '../../../../../shared/agent-status/presentation/agent-row-conversation-name'
import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import { structuredChatDisplayName } from '../../../../../shared/structured-chat-row-name'
import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title'
import { structuredChatTabById } from '@/lib/agent-session/tab-identity/structured-chat-tab-index'
import { useStructuredChatTabConversationName } from '@/runtime/native-chat/naming/structured-conversation-name'
import type { AppState } from '@/store/types'
import type { DashboardAgentRow } from './useDashboardData'
import type { DashboardAgentRow } from './dashboard-agent-row'
type WorktreeTabs = NonNullable<AppState['tabsByWorktree'][string]>
@@ -1,7 +1,7 @@
import { useCallback, useSyncExternalStore } from 'react'
import type { DashboardAgentRow } from './useDashboardData'
import type { DashboardAgentRow } from './dashboard-agent-row'
import { useFocusedAgentPaneKey } from '@/components/sidebar/workspace-card/activity/focused-agent-row-highlight'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner'
import { runtimeTargetForOwnerEnvironment } from '@/runtime/rpc/targets/runtime-client-target'
import {
@@ -2,28 +2,28 @@ import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat
import type {
AgentStatusEntry,
AgentStatusOrchestrationContext
} from '../../../../shared/agent-status/contracts/agent-status-types'
import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status/contracts/agent-status-ipc-payload'
} from '../../../../../shared/agent-status/contracts/agent-status-types'
import type { MigrationUnsupportedPtyEntry } from '../../../../../shared/agent-status/contracts/agent-status-ipc-payload'
import type {
TerminalLayoutSnapshot,
TerminalTab
} from '../../../../shared/workspace-layout/terminal-tab-types'
import { migrationUnsupportedToAgentStatusEntry } from '../../../../shared/migration-unsupported-agent-entry'
} from '../../../../../shared/workspace-layout/terminal-tab-types'
import { migrationUnsupportedToAgentStatusEntry } from '../../../../../shared/migration-unsupported-agent-entry'
import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract'
import type { AppState } from '@/store/types'
import { applyAgentRowLineage, type DashboardAgentRowWithLineage } from './agent-row-lineage'
import { buildWorktreeAgentRows } from '../sidebar/workspace-card/activity/worktree-agent-rows'
import { buildWorktreeAgentRows } from '../../sidebar/workspace-card/activity/worktree-agent-rows'
import {
selectLiveAgentStatusEntriesForWorktree,
selectMigrationUnsupportedEntriesForWorktree,
selectRetainedAgentEntriesForWorktree,
selectTerminalLayoutsForWorktree
} from '../sidebar/workspace-card/activity/worktree-agent-row-selectors'
} from '../../sidebar/workspace-card/activity/worktree-agent-row-selectors'
import {
selectLivePtyIdsForWorktree,
selectPaneForegroundAgentsForWorktree,
selectRuntimePaneTitlesForWorktree
} from '../sidebar/workspace-card/activity/worktree-card-status-inputs'
} from '../../sidebar/workspace-card/activity/worktree-card-status-inputs'
import type { PaneForegroundAgentEntry } from '@/store/slices/agent-status/pane-foreground-agent'
type WorktreeAgentRowsCacheEntry = {
@@ -1,10 +1,10 @@
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
import { useTranslate } from '@/i18n/use-translate'
import { Label } from '../ui/label'
import { SearchableSetting } from '../settings/section-layout/SearchableSetting'
import { SettingsSegmentedControl } from '../settings/form-controls/SettingsFormControls'
import { Label } from '../../ui/label'
import { SearchableSetting } from '../../settings/section-layout/SearchableSetting'
import { SettingsSegmentedControl } from '../../settings/form-controls/SettingsFormControls'
import { Switch } from '@/components/ui/switch'
import { getExperimentalSearchEntry } from '../settings/advanced/experimental-search'
import { getExperimentalSearchEntry } from '../../settings/advanced/experimental-search'
import { canOpenAgentDashboardPopout } from './agent-dashboard-open-mode'
type AgentDashboardExperimentalSettingProps = {
@@ -1,5 +1,5 @@
import { Settings } from 'lucide-react'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -8,9 +8,9 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { SettingsSegmentedControl } from '../settings/form-controls/SettingsFormControls'
import { SettingsSegmentedControl } from '../../settings/form-controls/SettingsFormControls'
import { Switch } from '@/components/ui/switch'
import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types'
import type { AgentDashboardMode } from '../../../../../shared/ui-chrome-types'
import { useTranslate } from '@/i18n/use-translate'
import { canOpenAgentDashboardPopout } from './agent-dashboard-open-mode'
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { toggleAgentDashboardFromShortcut } from '../../hooks/ipc-events/agent-dashboard-command'
import type { toggleAgentDashboardFromShortcut } from '../../../hooks/ipc-events/agent-dashboard-command'
import { resolveAgentDashboardMode } from './agent-dashboard-open-mode'
function dashboardState(): Parameters<typeof toggleAgentDashboardFromShortcut>[0] {
@@ -18,13 +18,9 @@ function dashboardState(): Parameters<typeof toggleAgentDashboardFromShortcut>[0
describe('Agent Dashboard open mode', () => {
afterEach(() => vi.unstubAllGlobals())
it('opens the in-window board in a web client even when the setting says pop-out', () => {
it('resolves a web client to in-window despite a pop-out setting', () => {
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
const state = dashboardState()
const openPopout = vi.fn()
expect(resolveAgentDashboardMode(state.settings)).toBe('in-window')
toggleAgentDashboardFromShortcut(state, openPopout)
expect(openPopout).not.toHaveBeenCalled()
expect(state.setAgentDashboardDrawerOpen).toHaveBeenCalledWith(true)
})
})
@@ -1,5 +1,5 @@
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { AgentDashboardMode } from '../../../../shared/ui-chrome-types'
import type { GlobalSettings } from '../../../../../shared/global-settings-types'
import type { AgentDashboardMode } from '../../../../../shared/ui-chrome-types'
import { isPairedWebClientWindow } from '@/app-shell/chrome/desktop-window-chrome'
/** A browser tab cannot open a second Orca window, so a web client only has the in-window board. */
@@ -0,0 +1,196 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildDashboardSnapshot,
type DashboardSnapshotInput as DashboardSnapshotState
} from './build-dashboard-snapshot'
import type { AgentStatusEntry } from '../../../../../shared/agent-status/contracts/agent-status-types'
import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import type { TerminalTab } from '../../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../../shared/worktree/types'
import '../../sidebar/workspace-card/activity/worktree-agent-row-selectors'
import type * as DashboardSnapshotWorkspacesModule from './dashboard-snapshot-workspaces'
import type * as AgentRowLineageModule from '../rows/agent-row-lineage'
const mapMetadataCalls = vi.hoisted(() => ({
hostKind: vi.fn(),
parentPaneKey: vi.fn()
}))
vi.mock('./dashboard-snapshot-workspaces', async (importOriginal) => {
const actual = await importOriginal<typeof DashboardSnapshotWorkspacesModule>()
return {
...actual,
dashboardCardMapWorkspaceMetadata: (
...args: Parameters<typeof actual.dashboardCardMapWorkspaceMetadata>
) => {
mapMetadataCalls.hostKind()
return actual.dashboardCardMapWorkspaceMetadata(...args)
}
}
})
vi.mock('../rows/agent-row-lineage', async (importOriginal) => {
const actual = await importOriginal<typeof AgentRowLineageModule>()
return {
...actual,
dashboardCardParentPaneKey: (...args: Parameters<typeof actual.dashboardCardParentPaneKey>) => {
mapMetadataCalls.parentPaneKey()
return actual.dashboardCardParentPaneKey(...args)
}
}
})
const NOW = 1_000_000_000
const TAB_ID = 'tab1'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const SPLIT_SIBLING_LEAF_ID = '55555555-5555-4555-8555-555555555555'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
function entry(overrides: Partial<AgentStatusEntry>): AgentStatusEntry {
return {
paneKey: PANE_KEY,
state: 'working',
prompt: 'do the thing',
updatedAt: NOW,
stateStartedAt: NOW - 5000,
stateHistory: [],
agentType: 'claude',
tabId: TAB_ID,
worktreeId: 'w1',
...overrides
}
}
function tab(id = TAB_ID, worktreeId = 'w1'): TerminalTab {
return {
id,
ptyId: 'pty1',
worktreeId,
title: 'agent',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: NOW
}
}
function worktree(id = 'w1', displayName = 'wt-one'): Worktree {
return {
id,
repoId: 'r1',
path: `/r1/${id}`,
head: 'abc123',
branch: 'main',
isBare: false,
isMainWorktree: false,
displayName,
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: NOW
}
}
function baseState(overrides: Partial<DashboardSnapshotState>): DashboardSnapshotState {
return {
chatNames: new Map(),
repos: [{ id: 'r1', path: '/r1', displayName: 'Repo One', badgeColor: '#000', addedAt: NOW }],
worktreesByRepo: { r1: [worktree()] },
tabsByWorktree: { w1: [tab()] },
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
migrationUnsupportedByPtyId: {},
runtimeAgentOrchestrationByPaneKey: {},
terminalLayoutsByTabId: {
[TAB_ID]: {
root: { type: 'leaf', leafId: LEAF_ID },
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty1' }
}
},
ptyIdsByTabId: { [TAB_ID]: ['pty1'] },
runtimePaneTitlesByTabId: {},
acknowledgedAgentsByPaneKey: {},
settings: null,
...overrides
}
}
describe('buildDashboardSnapshot', () => {
it('names each pane of a split tab from its own title', () => {
const siblingPaneKey = makePaneKey(TAB_ID, SPLIT_SIBLING_LEAF_ID)
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
[PANE_KEY]: entry({}),
[siblingPaneKey]: entry({ paneKey: siblingPaneKey })
},
// The tab title is whichever pane has focus, so it must not name both.
tabsByWorktree: { w1: [{ ...tab(), title: '\u2733 Linear work log' }] },
terminalLayoutsByTabId: {
[TAB_ID]: {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: LEAF_ID },
second: { type: 'leaf', leafId: SPLIT_SIBLING_LEAF_ID }
},
activeLeafId: LEAF_ID,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_ID]: 'pty1', [SPLIT_SIBLING_LEAF_ID]: 'pty2' }
}
},
ptyIdsByTabId: { [TAB_ID]: ['pty1', 'pty2'] },
// Pane ids are replay-creation-ordered: 1 -> first leaf, 2 -> second.
runtimePaneTitlesByTabId: {
[TAB_ID]: { 1: '\u2733 Linear work log', 2: '\u2733 Redis cache strategy' }
}
}),
NOW
)
const nameByPaneKey = new Map(
snapshot.cards.map((card) => [card.paneKey, card.conversationName])
)
expect(nameByPaneKey.get(PANE_KEY)).toBe('Linear work log')
expect(nameByPaneKey.get(siblingPaneKey)).toBe('Redis cache strategy')
})
it('nulls ptyId when the layout entry points at a dead pty', () => {
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: { [PANE_KEY]: entry({}) },
// Layouts retain old PTY IDs after restart; only the live set proves preview availability.
ptyIdsByTabId: { [TAB_ID]: [] }
}),
NOW
)
expect(snapshot.cards[0].ptyId).toBeNull()
})
it('labels a waiting agent with its question, not the prompt envelope JSON', () => {
const interactivePrompt = JSON.stringify({
questions: [{ question: 'Which database?', options: [{ label: 'Postgres' }] }]
})
const snapshot = buildDashboardSnapshot(
baseState({
agentStatusByPaneKey: {
[PANE_KEY]: entry({ state: 'waiting', toolName: 'AskUserQuestion', interactivePrompt })
}
}),
NOW
)
expect(snapshot.cards[0].askSummary).toBe('Which database?')
})
})
@@ -1,6 +1,6 @@
import { agentVerdictDisplayMark } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import { interactivePromptLabel } from '../../../../shared/native-chat/input/native-chat-interactive-prompt'
import { sayNativeChatApprovalTranslated } from '../native-chat/prompts/native-chat-interactive-prompt'
import { agentVerdictDisplayMark } from '../../../../../shared/agent-status/lifecycle/agent-main-agent-verdict'
import { interactivePromptLabel } from '../../../../../shared/native-chat/input/native-chat-interactive-prompt'
import { sayNativeChatApprovalTranslated } from '../../native-chat/prompts/native-chat-interactive-prompt'
import type { StructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-tab-name'
import type { AppState } from '@/store/types'
import {
@@ -8,26 +8,26 @@ import {
type DashboardCard,
type DashboardSnapshot,
type DashboardWorkspace
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
} from '../../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../../shared/repo-icon'
import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import {
resolveDashboardCardTerminalInput,
type DashboardCardTerminalInputState
} from './dashboard-card-terminal-input'
import { readDashboardClientHost } from './dashboard-client-host'
import { dashboardCardParentPaneKey } from './agent-row-lineage'
import { lastEnteredDoneAt } from './agent-finished-timestamp'
import { selectTerminalLayoutsForWorktree } from '../sidebar/workspace-card/activity/worktree-agent-row-selectors'
import '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index'
import { dashboardCardParentPaneKey } from '../rows/agent-row-lineage'
import { lastEnteredDoneAt } from '../rows/agent-finished-timestamp'
import { selectTerminalLayoutsForWorktree } from '../../sidebar/workspace-card/activity/worktree-agent-row-selectors'
import '../../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from '../../sidebar/workspace-card/activity/worktree-agent-orchestration-index'
import {
finishWorktreeAgentRowsCachePass,
selectWorktreeAgentRowsCached,
startWorktreeAgentRowsCachePass,
type WorktreeAgentRowsCache
} from './worktree-agent-rows-cache'
import { selectRuntimePaneTitlesForWorktree } from '../sidebar/workspace-card/activity/worktree-card-status-inputs'
} from '../rows/worktree-agent-rows-cache'
import { selectRuntimePaneTitlesForWorktree } from '../../sidebar/workspace-card/activity/worktree-card-status-inputs'
import {
resolveDashboardCardContext,
type DashboardCardContextState
@@ -46,7 +46,7 @@ import {
import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options'
import { groupSubagentsByParentPaneKey } from './dashboard-subagent-cards'
import { selectDashboardOrchestration } from './dashboard-orchestration-selection'
import { dashboardRowBucketProjection, readAgentRowTurnRecords } from './dashboard-row-bucket'
import { dashboardRowBucketProjection, readAgentRowTurnRecords } from '../rows/dashboard-row-bucket'
import type { Translate } from '@/i18n/use-translate'
/** Keep builder inputs as a Pick so tests need only construct the store slices read here. */
@@ -1,7 +1,7 @@
import type {
DashboardBucket,
DashboardCardBaseDisplayState
} from '../../../../shared/dashboard-snapshot'
} from '../../../../../shared/dashboard-snapshot'
export function dashboardBucketForDotState(state: DashboardCardBaseDisplayState): DashboardBucket {
switch (state) {
@@ -1,20 +1,18 @@
import { branchName } from '@/lib/source-control/branches/branch-name'
import { getHostedReviewCacheKey } from '@/store/slices/hosted-review/hosted-review-cache-identity'
import type { AppState } from '@/store/types'
import type { DashboardCardReview } from '../../../../shared/dashboard-snapshot'
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../shared/source-control/hosted-review/hosted-review-github'
import { isPositiveHostedReviewNumber } from '../../../../shared/source-control/hosted-review/hosted-review-contracts'
import type { Repo } from '../../../../shared/repo-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../shared/worktree/types'
import {
DEFAULT_WORKSPACE_STATUSES,
getWorkspaceStatus
} from '../../../../shared/workspace-statuses'
import type { DashboardCardReview } from '../../../../../shared/dashboard-snapshot'
import { hostedReviewInfoFromGitHubPRInfo } from '../../../../../shared/source-control/hosted-review/hosted-review-github'
import { isPositiveHostedReviewNumber } from '../../../../../shared/source-control/hosted-review/hosted-review-contracts'
import type { Repo } from '../../../../../shared/repo-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../shared/worktree/types'
import { getWorkspaceStatus } from '../../../../../shared/workspace-statuses'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../../shared/workspace-status-defaults'
import {
canUseParentPrChecksGitHubPRCacheEntry,
getParentPrChecksGitHubPRCacheEntry
} from '../right-sidebar/hosted-review/folder-checks/parent-pr-checks-github-pr-cache'
import { canUseParentPrChecksHostedReviewCacheEntry } from '../right-sidebar/hosted-review/folder-checks/parent-pr-checks-hosted-review-cache'
} from '../../right-sidebar/hosted-review/folder-checks/parent-pr-checks-github-pr-cache'
import { canUseParentPrChecksHostedReviewCacheEntry } from '../../right-sidebar/hosted-review/folder-checks/parent-pr-checks-hosted-review-cache'
import type { Translate } from '@/i18n/use-translate'
export type DashboardCardContextState = Partial<
@@ -1,12 +1,12 @@
import { getAgentRowConversationName } from '../../../../shared/agent-status/presentation/agent-row-conversation-name'
import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../shared/dashboard-snapshot'
import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { resolveAgentRowPaneLiveTitle } from './agent-row-pane-live-title'
import { getAgentRowConversationName } from '../../../../../shared/agent-status/presentation/agent-row-conversation-name'
import { DASHBOARD_MAX_LABEL_LENGTH } from '../../../../../shared/dashboard-snapshot'
import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id'
import { resolveAgentRowPaneLiveTitle } from '../rows/agent-row-pane-live-title'
import { structuredChatTabById } from '@/lib/agent-session/tab-identity/structured-chat-tab-index'
import { projectedStructuredChatTabName } from '@/runtime/native-chat/naming/structured-chat-tab-name'
import type { StructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-tab-name'
import type { TerminalLayoutSnapshot } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { DashboardAgentRow } from './useDashboardData'
import type { TerminalLayoutSnapshot } from '../../../../../shared/workspace-layout/terminal-tab-types'
import type { DashboardAgentRow } from '../rows/dashboard-agent-row'
import type { AppState } from '@/store/types'
export function rowTask(row: DashboardAgentRow): string {
@@ -1,8 +1,11 @@
import type { AppState } from '@/store/types'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent'
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import type { DashboardCardTerminalInput } from '../../../../../shared/dashboard-snapshot'
import type { TuiAgent } from '../../../../../shared/agent-launch/catalog/tui-agent'
import {
toRuntimeExecutionHostId,
toSshExecutionHostId
} from '../../../../../shared/execution-host'
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
import '@/lib/workspace/ownership/connection-context'
import { getConnectionIdFromState } from '@/lib/workspace/ownership/connection-owner-resolution'
import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner'
@@ -3,9 +3,9 @@ import type { DashboardSnapshotState } from './build-dashboard-snapshot'
import {
releaseRuntimeAgentOrchestrationBatchCache,
selectRuntimeAgentOrchestrationBatch
} from '../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import '../sidebar/workspace-card/activity/worktree-agent-row-selectors'
import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForWorktree } from '../sidebar/workspace-card/activity/worktree-agent-orchestration-index'
} from '../../sidebar/workspace-card/activity/worktree-agent-orchestration-batch'
import '../../sidebar/workspace-card/activity/worktree-agent-row-selectors'
import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForWorktree } from '../../sidebar/workspace-card/activity/worktree-agent-orchestration-index'
export function selectDashboardOrchestration(
state: DashboardSnapshotState,
@@ -1,6 +1,6 @@
import type { AppState } from '@/store/types'
import type { DashboardFilterOptions } from '../../../../shared/dashboard-snapshot'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-statuses'
import type { DashboardFilterOptions } from '../../../../../shared/dashboard-snapshot'
import { DEFAULT_WORKSPACE_STATUSES } from '../../../../../shared/workspace-status-defaults'
import { boundedLabel } from './dashboard-card-labels'
import type { ActiveDashboardWorkspace } from './dashboard-snapshot-workspaces'
import { translateWorkspaceBoardStatusLabel } from '@/components/sidebar/workspace-board-status-label'
@@ -5,19 +5,19 @@ import {
type DashboardCard,
type DashboardCardHostKind,
type DashboardCardWorkspaceKind
} from '../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../shared/repo-icon'
} from '../../../../../shared/dashboard-snapshot'
import type { RepoIcon } from '../../../../../shared/repo-icon'
import {
getWorktreeExecutionHostId,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspaces/folder-workspace-worktree'
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
import { isFolderRepo } from '../../../../shared/repo-kind'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
} from '../../../../../shared/execution-host'
import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspaces/folder-workspace-worktree'
import { getHostDisplayLabelOverrides } from '../../../../../shared/host-setting-overrides'
import { isFolderRepo } from '../../../../../shared/repo-kind'
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields'
export type ActiveDashboardWorkspace = {
@@ -1,7 +1,7 @@
import type { DashboardCardSubagent } from '../../../../shared/dashboard-snapshot'
import type { DashboardCardSubagent } from '../../../../../shared/dashboard-snapshot'
import { nonEmpty } from './dashboard-card-labels'
import type { DashboardAgentRow } from './useDashboardData'
import { dashboardCardDotState } from './dashboard-row-bucket'
import type { DashboardAgentRow } from '../rows/dashboard-agent-row'
import { dashboardCardDotState } from '../rows/dashboard-row-bucket'
/** Nest terminal-less subagents on the card of their spawning parent pane. */
export function groupSubagentsByParentPaneKey(
@@ -1,9 +1,9 @@
import { useMemo, useRef } from 'react'
import { useAppStore } from '@/store'
import { useAppStore } from '@/store/index'
import { useStructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-name-observation'
import type { DashboardSnapshot } from '../../../../shared/dashboard-snapshot'
import type { DashboardSnapshot } from '../../../../../shared/dashboard-snapshot'
import { buildDashboardSnapshot } from './build-dashboard-snapshot'
import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache'
import { createWorktreeAgentRowsCache } from '../rows/worktree-agent-rows-cache'
import {
selectDetectedWorktreesByRepo,
selectFolderWorkspaces,
@@ -1,24 +1,21 @@
import { useEffect, useId, useRef } from 'react'
import { SquareArrowOutUpRight, XIcon } from 'lucide-react'
import { AgentTypeIcon } from '@/components/agent/icons/agent-type-icon'
import { formatAgentTypeLabel } from '../../../../shared/agent-status/presentation/agent-type-label'
import { formatAgentTypeLabel } from '../../../../../shared/agent-status/presentation/agent-type-label'
import { agentStateLabel } from '@/components/AgentStateDot'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
dashboardCardDisplayState,
type DashboardCard,
type DashboardRevealAgentArgs
} from '../../../../shared/dashboard-snapshot'
type DashboardRevealAgentArgs as AgentRevealArgs
} from '../../../../../shared/dashboard-snapshot'
import { AgentTerminalPreview } from './AgentTerminalPreview'
import { AgentCardLastMessages, hasAgentCardLastMessages } from './AgentCardLastMessages'
import { AgentCardLastMessages, hasAgentCardLastMessages } from '../board/AgentCardLastMessages'
import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message'
import { useTranslate } from '@/i18n/use-translate'
import { cn } from '@/lib/class-names'
/** Routing payload for focusing an agent's pane in the main window. */
export type AgentRevealArgs = DashboardRevealAgentArgs
type AgentTerminalDialogProps = {
/** The agent shown in the dialog; null renders the dialog closed. */
card: DashboardCard | null
@@ -2,35 +2,35 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/appearance/terminal-appearance'
import { useSystemPrefersDark } from '@/hooks/theme/use-system-prefers-dark'
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker'
import { replayPreviewConnectionSnapshot } from './preview-terminal-snapshot-replay'
import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker'
import { replayPreviewConnectionSnapshot } from './transport/preview-terminal-snapshot-replay'
import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt'
import {
buildPreviewAppearanceOptions,
buildPreviewTerminalOptions,
previewAdvertisesKittyKeyboard
} from './preview-terminal-options'
import { syncPreviewTerminalLigatures } from './preview-terminal-ligatures'
} from '../../standalone-terminal/rendering/preview-terminal-options'
import { syncPreviewTerminalLigatures } from '../../standalone-terminal/rendering/preview-terminal-ligatures'
import { terminalPreviewUnavailableMessage } from './terminal-preview-unavailable-message'
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal/appearance/terminal-theme'
import { cn } from '@/lib/class-names'
import { useAppStore } from '@/store/index'
import { usePluginFontReady } from '@/store/plugins/appearance/plugin-fonts'
import { createPreviewGridClaim } from './preview-grid-claim'
import { createPreviewBoxFit } from './preview-terminal-box-fit'
import { openStandaloneTerminal } from '../standalone-terminal/open-standalone-terminal'
import type { StandaloneTerminal } from '../standalone-terminal/open-standalone-terminal'
import { terminalPreviewApiFor } from './terminal-preview-api'
import { createPreviewGridClaim } from './geometry/preview-grid-claim'
import { createPreviewBoxFit } from './geometry/preview-terminal-box-fit'
import { openStandaloneTerminal } from '../../standalone-terminal/open-standalone-terminal'
import type { StandaloneTerminal } from '../../standalone-terminal/open-standalone-terminal'
import { terminalPreviewApiFor } from './transport/terminal-preview-api'
import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover'
import { closeLinkActionRequest } from '@/components/link-actions/link-action-request'
import type { LinkActionRequest } from '@/components/link-actions/link-action-request'
import { installDashboardPreviewLinkInteraction } from './dashboard-preview-link-interaction'
import { installDashboardPreviewLinkInteraction } from './links/dashboard-preview-link-interaction'
import { useTranslate } from '@/i18n/use-translate'
import type { PreviewTerminalLinkHandler } from './preview-terminal-links'
import type { PreviewTerminalLinkHandler } from '../../standalone-terminal/rendering/preview-terminal-links'
import type { ITheme, Terminal } from '@xterm/xterm'
import type { DashboardCardTerminalInput } from '../../../../shared/dashboard-snapshot'
import type { TerminalPreviewDataPayload } from '../../../../shared/terminal/replay/terminal-preview'
import type { DashboardPreviewLinkSource } from './dashboard-preview-http-actions'
import type { DashboardCardTerminalInput } from '../../../../../shared/dashboard-snapshot'
import type { TerminalPreviewDataPayload } from '../../../../../shared/terminal/replay/terminal-preview'
import type { DashboardPreviewLinkSource } from './links/dashboard-preview-http-actions'
const PREVIEW_SCROLLBACK_ROWS = 24
// Preview snapshots bound history; pane scrollback would only cost memory.
@@ -1,6 +1,6 @@
import type { Terminal } from '@xterm/xterm'
import { clampTerminalViewport } from '../../../../shared/terminal/viewport/terminal-viewport'
import { terminalPreviewApiFor } from './terminal-preview-api'
import { clampTerminalViewport } from '../../../../../../shared/terminal/viewport/terminal-viewport'
import { terminalPreviewApiFor } from '../transport/terminal-preview-api'
const FIT_REQUEST_DEBOUNCE_MS = 200
/** Claim the dialog-sized PTY grid once per target size; unchanged requests must not fight a host or phone reclaiming geometry. */
@@ -1,11 +1,11 @@
import { toast } from 'sonner'
import { intentPresentation } from '@/lib/browser/workspace/workspace-browser-tab-presentation'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { parseRoutableExecutionHostId } from '../../../../shared/execution-host'
import { parseAppSshPtyId } from '../../../../../../shared/ssh-pty-id'
import { parseRoutableExecutionHostId } from '../../../../../../shared/execution-host'
import {
parseRemoteRuntimePtyId,
isRemoteRuntimePtyId
} from '../../../../shared/remote-runtime/remote-runtime-pty-id'
} from '../../../../../../shared/remote-runtime/remote-runtime-pty-id'
import { useAppStore } from '@/store/index'
import { resolveOwner } from '@/lib/workspace/ownership/resolve-owner'
import { resolveWorktreeOperationRoute } from '@/lib/workspace/ownership/worktree-operation-route'
@@ -24,7 +24,7 @@ import type {
HttpLinkDestination,
HttpLinkActionDestinations
} from '@/lib/browser/navigation/http-link-destinations'
import type { DashboardCard } from '../../../../shared/dashboard-snapshot'
import type { DashboardCard } from '../../../../../../shared/dashboard-snapshot'
import type { HttpLinkSourceOwner } from '@/lib/browser/navigation/http-link-routing'
export type DashboardPreviewLinkSource = Pick<
@@ -1,4 +1,4 @@
import type { PreviewTerminalLinkHandler } from './preview-terminal-links'
import type { PreviewTerminalLinkHandler } from '../../../standalone-terminal/rendering/preview-terminal-links'
import type { Terminal } from '@xterm/xterm'
import type { LinkActionRequest } from '@/components/link-actions/link-action-request'
import { installTerminalLinkifierClickPriming } from '@/components/terminal-pane/links/gestures/terminal-linkifier-click-priming'
@@ -12,10 +12,10 @@ import {
} from '@/components/terminal-pane/links/gestures/terminal-link-activation'
import { getTerminalBufferPositionForMouseEvent } from '@/components/terminal-pane/input/pointer/terminal-mouse-buffer-position'
import { findHttpLinkAtTerminalMouseEvent } from '@/components/terminal-pane/links/http/terminal-url-link-hit-testing'
import { findTerminalHttpLinkAtBufferPosition } from '../../../../shared/terminal/links/terminal-link-buffer-hit-testing'
import { findTerminalHttpLinkAtBufferPosition } from '../../../../../../shared/terminal/links/terminal-link-buffer-hit-testing'
import { httpLinkDirectClickDestination } from '@/lib/browser/navigation/http-link-destinations'
import { isWindowsUserAgent } from '@/app-shell/platform/user-agent-platform'
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { GlobalSettings } from '../../../../../../shared/global-settings-types'
import {
dashboardPreviewHttpActions,
type DashboardPreviewLinkSource
@@ -1,8 +1,8 @@
import { translate } from '@/i18n/i18n'
import type { Translate } from '@/i18n/use-translate'
import type { DashboardCardHostKind } from '../../../../shared/dashboard-snapshot'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
import type { DashboardCardHostKind } from '../../../../../shared/dashboard-snapshot'
import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id'
import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime/remote-runtime-pty-id'
/** Remote snapshot absence cannot prove pane exit because the client does not own the PTY; see docs/reference/ssh-execution-boundary.md. */
export function terminalPreviewUnavailableMessage(
@@ -1,10 +1,10 @@
import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker'
import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-flags'
import { buildKittyKeyboardRestore } from '../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles'
import type { TerminalKittyKeyboardModeTracker } from '../../../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-mode-tracker'
import { parseTerminalKittyKeyboardFlags } from '../../../../../../shared/terminal/protocol/input-modes/terminal-kitty-keyboard-flags'
import { buildKittyKeyboardRestore } from '../../../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles'
import type {
TerminalPreviewReplayChunk,
TerminalPreviewSnapshot
} from '../../../../shared/terminal/replay/terminal-preview'
} from '../../../../../../shared/terminal/replay/terminal-preview'
/** Restore proven kitty flags after kitty-free snapshot ANSI; remain synchronous so events cannot observe the temporary reset. */
export function replayPreviewConnectionSnapshot(args: {
@@ -1,16 +1,16 @@
import { createNonSecureContextUuid as createBrowserUuid } from '../../../../shared/non-secure-context-uuid'
import { createNonSecureContextUuid as createBrowserUuid } from '../../../../../../shared/non-secure-context-uuid'
import { takeCurrentTerminalDeliveryCredit } from '@/lib/pane-manager/terminal-output/terminal-delivery-credit'
import { getRemoteRuntimeTerminalMultiplexer } from '@/runtime/hosted-terminals/streams/registry/remote-runtime-terminal-multiplexer-registry'
import type {
RemoteRuntimeMultiplexedTerminal,
RemoteRuntimeMultiplexedTerminalCallbacks
} from '@/runtime/hosted-terminals/streams/remote-terminal-stream-contracts'
import { parseRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
import { parseRemoteRuntimePtyId } from '../../../../../../shared/remote-runtime/remote-runtime-pty-id'
import type {
TerminalPreviewConnectResult,
TerminalPreviewDataPayload,
TerminalPreviewReplayChunk
} from '../../../../shared/terminal/replay/terminal-preview'
} from '../../../../../../shared/terminal/replay/terminal-preview'
type TerminalPreviewApi = typeof window.api.terminalPreview
@@ -1,4 +1,4 @@
import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime/remote-runtime-pty-id'
import { isRemoteRuntimePtyId } from '../../../../../../shared/remote-runtime/remote-runtime-pty-id'
import { remoteRuntimeTerminalPreviewApi } from './remote-runtime-terminal-preview-api'
/** A paired host's pane previews from that host; everything else from this app's main process. */
@@ -1,389 +0,0 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { buildWorktreeAgentRows } from '../sidebar/workspace-card/activity/worktree-agent-rows'
import { useAppStore } from '@/store'
import { resetAgentPaneAuthorityAliasesForTests } from '@/store/slices/agent-status/agent-pane-authority'
import type {
AgentStatusEntry,
AgentStatusState
} from '../../../../shared/agent-status/contracts/agent-status-types'
import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id'
import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api'
import {
ENV,
LEAF_ID,
WT,
makeSnapshot,
resetWebSessionTabsSyncTestState
} from '@/runtime/web-session-tabs-sync/testing/web-session-tabs-sync.test-fixture'
import type { Repo } from '../../../../shared/repo-types'
import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types'
import type { Worktree } from '../../../../shared/worktree/types'
import {
buildRetainedAgentsSyncSnapshot,
collectRetainedAgentsOnDisappear,
useRetainedAgentsSync
} from './useRetainedAgents'
const initialAppState = useAppStore.getInitialState()
beforeEach(() => {
useAppStore.setState(initialAppState, true)
resetAgentPaneAuthorityAliasesForTests()
})
afterEach(() => {
useAppStore.setState(initialAppState, true)
resetAgentPaneAuthorityAliasesForTests()
})
function makeRepo(): Repo {
return {
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#000',
addedAt: 1
}
}
function makeWorktree(overrides?: Partial<Worktree>): Worktree {
return {
id: 'wt-1',
repoId: 'repo-1',
path: '/repo/wt-1',
head: 'abc123',
branch: 'feature',
isBare: false,
isMainWorktree: false,
displayName: 'feature',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
...overrides
}
}
function makeTab(overrides: Partial<TerminalTab> & { id: string }): TerminalTab {
return {
worktreeId: 'wt-1',
title: 'Terminal',
ptyId: null,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
...overrides
}
}
function makeAgentRow(args: {
paneKey: string
state: AgentStatusState
interrupted?: boolean
mainAgent?: AgentStatusEntry['mainAgent']
}) {
const entry: AgentStatusEntry = {
state: args.state,
prompt: 'Fix it',
updatedAt: 100,
stateStartedAt: 100,
paneKey: args.paneKey,
terminalTitle: 'Claude',
stateHistory: [],
agentType: 'claude',
interrupted: args.interrupted,
mainAgent: args.mainAgent
}
return {
paneKey: args.paneKey,
entry,
tab: makeTab({ id: 'tab-1' }),
agentType: 'claude' as const,
state: args.state,
startedAt: 1
}
}
describe('collectRetainedAgentsOnDisappear', () => {
it('carries paired retained order through same-session return and resets known replacement', () => {
resetWebSessionTabsSyncTestState()
useAppStore.setState({
repos: [makeRepo()],
worktreesByRepo: { 'repo-1': [makeWorktree({ id: WT })] }
})
const entry: AgentStatusEntry = {
...makeAgentRow({ paneKey: makePaneKey('host-tab-1', LEAF_ID), state: 'done' }).entry,
worktreeId: WT,
providerSession: { key: 'session_id', id: 'A' }
}
const quiet: AgentStatusEntry = {
...entry,
paneKey: makePaneKey('host-tab-2', LEAF_ID),
state: 'working',
updatedAt: 2000,
stateStartedAt: 2000,
providerSession: { key: 'session_id', id: 'quiet' }
}
const update = (agentStatus: AgentStatusEntry | undefined, version: number) => {
const tabs = [agentStatus, quiet].map((status, index) => ({
type: 'terminal' as const,
id: `host-tab-${index + 1}::${LEAF_ID}`,
title: 'Claude',
parentTabId: `host-tab-${index + 1}`,
leafId: LEAF_ID,
isActive: index === 0,
status: 'ready' as const,
terminal: `terminal-${index + 1}`,
agentStatus: status
}))
useAppStore.setState(
applyWebSessionTabsSnapshot(
useAppStore.getState(),
makeSnapshot(tabs, { snapshotVersion: version }),
ENV,
1_000_000 + version
)
)
}
const snapshot = () =>
buildRetainedAgentsSyncSnapshot({
repos: [makeRepo()],
worktreesByRepo: { 'repo-1': [makeWorktree({ id: WT })] },
folderWorkspaces: [],
tabsByWorktree: useAppStore.getState().tabsByWorktree,
agentStatusByPaneKey: useAppStore.getState().agentStatusByPaneKey,
now: 1_000_000
})
const rows = () =>
buildWorktreeAgentRows({
tabs: useAppStore.getState().tabsByWorktree[WT] ?? [],
entries: Object.values(useAppStore.getState().agentStatusByPaneKey),
retained: Object.values(useAppStore.getState().retainedAgentsByPaneKey),
now: 1_000_000
})
update({ ...entry, updatedAt: 1000, stateStartedAt: 1000, state: 'working' }, 1)
update({ ...entry, updatedAt: 5000, stateStartedAt: 5000 }, 2)
const previous = snapshot()
update(undefined, 3)
const current = snapshot()
const result = collectRetainedAgentsOnDisappear({
previousAgents: previous.currentAgents,
currentAgents: current.currentAgents,
retainedAgentsByPaneKey: useAppStore.getState().retainedAgentsByPaneKey,
retentionSuppressedPaneKeys: {},
recentlyClosedAgentStatusTabIds: {},
recentlyRetiredAgentStatusPaneKeys: {},
tabIndex: current.tabIndex
})
expect(result.toRetain).toHaveLength(1)
useAppStore.getState().retainAgents(result.toRetain)
expect(rows().map((row) => row.startedAt)).toEqual([1000, 2000])
update({ ...entry, updatedAt: 6000, stateStartedAt: 6000, state: 'working' }, 4)
expect(rows().map((row) => row.entry.providerSession?.id)).toEqual(['A', 'quiet'])
expect(rows().map((row) => row.startedAt)).toEqual([1000, 2000])
update(undefined, 5)
update(
{
...entry,
updatedAt: 7000,
stateStartedAt: 6000,
state: 'working',
providerSession: { key: 'session_id', id: 'B' }
},
6
)
expect(rows().map((row) => row.entry.providerSession?.id)).toEqual(['quiet', 'B'])
expect(rows().map((row) => row.startedAt)).toEqual([2000, 7000])
})
})
describe('useRetainedAgentsSync', () => {
it('uses the freshest tab title when a real agent transition retains a completed row', async () => {
const repo = makeRepo()
const worktree = makeWorktree()
const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const row = makeAgentRow({ paneKey, state: 'done' })
useAppStore.setState({
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
tabsByWorktree: {
[worktree.id]: [{ ...row.tab, title: '⠋ Codex is thinking' }]
},
agentStatusByPaneKey: { [paneKey]: row.entry },
agentStatusEpoch: initialAppState.agentStatusEpoch + 1
})
const hook = renderHook(() => useRetainedAgentsSync())
await act(async () => {
await Promise.resolve()
})
act(() => {
useAppStore.setState((state) => ({
tabsByWorktree: {
[worktree.id]: [
{
...state.tabsByWorktree[worktree.id][0],
title: '⠙ Codex is thinking'
}
]
}
}))
})
act(() => {
useAppStore.setState((state) => ({
agentStatusByPaneKey: {},
agentStatusEpoch: state.agentStatusEpoch + 1
}))
})
await act(async () => {
await Promise.resolve()
})
expect(useAppStore.getState().retainedAgentsByPaneKey[paneKey]?.tab.title).toBe(
'⠙ Codex is thinking'
)
hook.unmount()
})
it('does not re-retain when status removal and tab closure commit before the next retention effect', async () => {
const repo = makeRepo()
const worktree = makeWorktree()
const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const row = makeAgentRow({ paneKey, state: 'done' })
useAppStore.setState({
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
tabsByWorktree: { [worktree.id]: [row.tab] },
agentStatusByPaneKey: { [row.paneKey]: row.entry },
agentStatusEpoch: initialAppState.agentStatusEpoch + 1
})
const hook = renderHook(() => useRetainedAgentsSync())
await act(async () => {
await Promise.resolve()
})
expect(useAppStore.getState().retentionSuppressedPaneKeys[row.paneKey]).toBeUndefined()
// Why: model both teardown writes landing before the next retention effect runs.
act(() => {
useAppStore.setState((state) => ({
tabsByWorktree: { [worktree.id]: [] },
agentStatusByPaneKey: {},
recentlyClosedAgentStatusTabIds: { [row.tab.id]: true },
agentStatusEpoch: state.agentStatusEpoch + 1
}))
})
const state = useAppStore.getState()
expect(state.recentlyClosedAgentStatusTabIds[row.tab.id]).toBe(true)
expect(state.retainedAgentsByPaneKey[row.paneKey]).toBeUndefined()
hook.unmount()
})
it('does not leave a ghost row when a done pane detaches into another tab', async () => {
const repo = makeRepo()
const worktree = makeWorktree()
const leafId = '11111111-1111-4111-8111-111111111111'
const sourceTab = makeTab({ id: 'tab-source', ptyId: 'pty-a' })
const targetTab = makeTab({ id: 'tab-target' })
const sourcePaneKey = makePaneKey(sourceTab.id, leafId)
const targetPaneKey = makePaneKey(targetTab.id, leafId)
const row = makeAgentRow({ paneKey: sourcePaneKey, state: 'done' })
useAppStore.setState({
repos: [repo],
worktreesByRepo: { [repo.id]: [worktree] },
tabsByWorktree: { [worktree.id]: [sourceTab, targetTab] },
ptyIdsByTabId: { [sourceTab.id]: ['pty-a'], [targetTab.id]: [] },
agentStatusByPaneKey: { [sourcePaneKey]: { ...row.entry, tabId: sourceTab.id } },
agentStatusEpoch: initialAppState.agentStatusEpoch + 1
})
const hook = renderHook(() => useRetainedAgentsSync())
await act(async () => {
await Promise.resolve()
})
act(() => {
useAppStore.getState().syncPaneDetachPtyOwnership({
detachedLeafId: leafId,
detachedPtyId: 'pty-a',
sourceLayout: {
root: null,
activeLeafId: null,
expandedLeafId: null,
ptyIdsByLeafId: {}
},
sourceTabId: sourceTab.id,
targetTabId: targetTab.id
})
})
await act(async () => {
await Promise.resolve()
})
const state = useAppStore.getState()
expect(state.agentStatusByPaneKey[targetPaneKey]?.state).toBe('done')
expect(state.retainedAgentsByPaneKey[sourcePaneKey]).toBeUndefined()
expect(state.retainedAgentsByPaneKey[targetPaneKey]).toBeUndefined()
expect(state.retentionSuppressedPaneKeys[sourcePaneKey]).toBeUndefined()
hook.unmount()
})
it('keeps a wrapped-history completion in position through disappearance retention', () => {
const paneKey = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
const store = useAppStore.getState()
store.setAgentStatus(
paneKey,
{ state: 'working', prompt: 'task', agentType: 'pi' },
undefined,
{
updatedAt: 1000
}
)
for (let i = 0; i < 23; i++) {
store.setAgentStatus(
paneKey,
{ state: i % 2 ? 'working' : 'done', prompt: 'task', agentType: 'pi' },
undefined,
{ updatedAt: 3000 + i * 1000 }
)
}
const entry = useAppStore.getState().agentStatusByPaneKey[paneKey]
expect(entry.stateHistory[0].startedAt).toBeGreaterThan(2000)
const previous = buildRetainedAgentsSyncSnapshot({
repos: [makeRepo()],
worktreesByRepo: { 'repo-1': [makeWorktree()] },
folderWorkspaces: [],
tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1' })] },
agentStatusByPaneKey: { [paneKey]: entry },
now: 30000
})
const result = collectRetainedAgentsOnDisappear({
previousAgents: previous.currentAgents,
currentAgents: new Map(),
retainedAgentsByPaneKey: {},
retentionSuppressedPaneKeys: {},
recentlyClosedAgentStatusTabIds: {},
recentlyRetiredAgentStatusPaneKeys: {}
})
expect(result.toRetain[0]?.startedAt).toBe(1000)
const quiet = makeAgentRow({
paneKey: makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222'),
state: 'working'
}).entry
const rows = buildWorktreeAgentRows({
tabs: [makeTab({ id: 'tab-2' })],
entries: [{ ...quiet, stateStartedAt: 2000 }],
retained: result.toRetain,
now: 30000
})
expect(rows.map((row) => row.paneKey)).toEqual([paneKey, quiet.paneKey])
})
})
@@ -43,10 +43,10 @@ import { agentKindForAgentType } from '@/lib/agent-status/aggregation/worktree-a
import { formatAgentTypeLabel } from '../../../../../shared/agent-status/presentation/agent-type-label'
import { track } from '@/diagnostics/telemetry/telemetry'
import { useNow } from '@/hooks/use-now'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row'
import { lastEnteredDoneAt } from '@/components/dashboard/rows/agent-finished-timestamp'
import { useWorktreeAgentRows } from '@/components/sidebar/workspace-card/activity/useWorktreeAgentRows'
import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name'
import { useAgentRowConversationName } from '@/components/dashboard/rows/use-agent-row-conversation-name'
import { getAgentRowPrimaryText } from '../../../../../shared/agent-status/presentation/agent-row-primary-text'
import type { LaunchSource } from '../../../../../shared/telemetry-property-schemas'
import { agentRowDisplayDotState } from '@/lib/agent-status/presentation/agent-row-dot-state'
@@ -1,4 +1,4 @@
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData'
import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/rows/dashboard-agent-row'
import type { NotesSendAgentTarget } from '@/lib/agent-session/messaging/notes-send-agent-targets'
export type OrderedSendTarget = {
@@ -5,7 +5,7 @@ import {
createWorktreeAgentRowsCache,
startWorktreeAgentRowsCachePass,
selectWorktreeAgentRowsCached
} from '@/components/dashboard/worktree-agent-rows-cache'
} from '@/components/dashboard/rows/worktree-agent-rows-cache'
import { createWorktreeAgentFreshnessSelector } from '@/components/sidebar/workspace-card/activity/worktree-agent-freshness-selector'
import '@/components/sidebar/workspace-card/activity/worktree-agent-row-selectors'
import { selectWorktreeAgentOrchestration as selectRuntimeAgentOrchestrationForWorktree } from '@/components/sidebar/workspace-card/activity/worktree-agent-orchestration-index'
@@ -8,11 +8,11 @@ import { composeActiveTerminalTheme } from '@/components/terminal-pane/appearanc
import { resolveEffectiveTerminalAppearance } from '@/lib/terminal/appearance/terminal-theme'
import { useSystemPrefersDark } from '@/hooks/theme/use-system-prefers-dark'
import { resolveTerminalMinimumContrastRatio } from '../../../../shared/terminal-themes/terminal-contrast-correction'
import { syncPreviewTerminalLigatures } from '@/components/dashboard-popout/preview-terminal-ligatures'
import { syncPreviewTerminalLigatures } from '@/components/standalone-terminal/rendering/preview-terminal-ligatures'
import {
buildPreviewAppearanceOptions,
buildPreviewTerminalOptions
} from '@/components/dashboard-popout/preview-terminal-options'
} from '@/components/standalone-terminal/rendering/preview-terminal-options'
import { openStandaloneTerminal } from '@/components/standalone-terminal/open-standalone-terminal'
import { removePlainSshPanel } from '@/ssh/plain-ssh-terminal-panels'
import { usePluginFontReady } from '@/store/plugins/appearance/plugin-fonts'

Some files were not shown because too many files have changed in this diff Show More