mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
refactor: organize dashboard and popout modules by owner
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,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
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
-234
@@ -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()
|
||||
})
|
||||
})
|
||||
-229
@@ -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])
|
||||
})
|
||||
-167
@@ -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)
|
||||
})
|
||||
-178
@@ -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 }
|
||||
}
|
||||
-322
@@ -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())
|
||||
})
|
||||
-104
@@ -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]])
|
||||
})
|
||||
-168
@@ -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('')
|
||||
})
|
||||
})
|
||||
+7
-7
@@ -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 = {
|
||||
+2
-2
@@ -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
|
||||
|
||||
-396
@@ -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' })
|
||||
)
|
||||
}
|
||||
)
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+5
-2
@@ -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,
|
||||
+5
-4
@@ -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,
|
||||
+2
-2
@@ -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'
|
||||
+2
-2
@@ -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,
|
||||
+2
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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.
|
||||
+187
@@ -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)
|
||||
})
|
||||
})
|
||||
+9
-9
@@ -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> = {
|
||||
+2
-2
@@ -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
|
||||
-319
@@ -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)
|
||||
})
|
||||
})
|
||||
+163
@@ -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'
|
||||
})
|
||||
})
|
||||
+4
-17
@@ -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()
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -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()
|
||||
+8
-5
@@ -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()
|
||||
})
|
||||
})
|
||||
+12
-12
@@ -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,
|
||||
+3
-3
@@ -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(
|
||||
+3
-13
@@ -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
-1
@@ -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
-1
@@ -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 = {
|
||||
+2
-2
@@ -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(
|
||||
+4
-4
@@ -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. */
|
||||
+4
-4
@@ -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
|
||||
+4
-4
@@ -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]>
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
+7
-7
@@ -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 = {
|
||||
+5
-5
@@ -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 = {
|
||||
+3
-3
@@ -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'
|
||||
|
||||
+2
-6
@@ -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)
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -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?')
|
||||
})
|
||||
})
|
||||
+14
-14
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
DashboardBucket,
|
||||
DashboardCardBaseDisplayState
|
||||
} from '../../../../shared/dashboard-snapshot'
|
||||
} from '../../../../../shared/dashboard-snapshot'
|
||||
|
||||
export function dashboardBucketForDotState(state: DashboardCardBaseDisplayState): DashboardBucket {
|
||||
switch (state) {
|
||||
+9
-11
@@ -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<
|
||||
+6
-6
@@ -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 {
|
||||
+7
-4
@@ -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
-3
@@ -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,
|
||||
+2
-2
@@ -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'
|
||||
+7
-7
@@ -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 = {
|
||||
+3
-3
@@ -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(
|
||||
+3
-3
@@ -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,
|
||||
+4
-7
@@ -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
|
||||
+14
-14
@@ -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.
|
||||
+2
-2
@@ -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. */
|
||||
+4
-4
@@ -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<
|
||||
+3
-3
@@ -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
|
||||
+3
-3
@@ -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(
|
||||
+4
-4
@@ -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: {
|
||||
+3
-3
@@ -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
-1
@@ -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])
|
||||
})
|
||||
})
|
||||
+3
-3
@@ -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
-1
@@ -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 = {
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user