diff --git a/.github/skills/coc-knowledge/references/dashboard-spa.md b/.github/skills/coc-knowledge/references/dashboard-spa.md index f7bdbdb93..578ed21fe 100644 --- a/.github/skills/coc-knowledge/references/dashboard-spa.md +++ b/.github/skills/coc-knowledge/references/dashboard-spa.md @@ -303,8 +303,13 @@ under `features/chat/agent-canvas/`) via its `viewToggle` slot. `ChatDetail` owns the `view` state and, in `agents` mode, swaps the `ConversationArea` inner row for `AgentCanvas` — a pannable/zoomable spatial tree of the chat's recursive sub-agent runs — while keeping the composer/scratchpad and hiding the -thread-only flow cards (Ralph start, Implement-plan). The toggle is hidden in -the `floating` variant and while loading/pending. In the main inline context +thread-only flow cards (Ralph start, Implement-plan). The toggle is hidden when +the chat has no sub-agents (`hasSubAgents = agentRoot.children.length > 0`), in +the `floating` variant, and while loading/pending. Rendering keys off +`effectiveView` (= `view` only when sub-agents exist, otherwise `thread`), so a +stale `?view=agents` deep-link can't strand the user on an empty canvas — it +"waits", revealing the canvas the moment the first sub-agent appears. In the +main inline context the view is deep-linked: a `?view=agents` query param on the chat hash (`#repos///?view=agents`) is read on mount (so a shared/bookmarked URL reopens straight into the canvas) and written via @@ -330,6 +335,9 @@ centered (`centerContent`), re-centering on mount/growth/resize until the user takes over. The toolbar's % is a dropdown of preset levels (25/50/75/100/150/200% + Fit) backed by `useZoomPan`'s `zoomTo(scale)` (zooms about the viewport center); the Fit button zooms to fit the whole tree. +`useZoomPan`'s wheel-zoom and pan-drag both skip events originating inside a +`[data-no-drag]` overlay — the toolbar, legend, and the open inspector — so +those scroll/click natively instead of zooming/panning the canvas behind them. It renders curved SVG edges + node cards (role glyph, name, live elapsed, spawn-count pill, status dot, progress bar) and a live 1s clock for running nodes. Clicking a sub-agent node opens `AgentInspector` — a right-side panel diff --git a/packages/coc/src/server/spa/client/react/features/chat/ChatDetail.tsx b/packages/coc/src/server/spa/client/react/features/chat/ChatDetail.tsx index 41c5f480c..6d2122b11 100644 --- a/packages/coc/src/server/spa/client/react/features/chat/ChatDetail.tsx +++ b/packages/coc/src/server/spa/client/react/features/chat/ChatDetail.tsx @@ -318,6 +318,14 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari status: effectiveStatus, }), [turns, task?.customTitle, task?.title, task?.displayName, title, effectiveStatus]); + // The Agents view only makes sense once this chat has actually spawned + // sub-agents. With none, hide the Thread/Agents toggle and pin the thread, + // so a stale `?view=agents` deep-link can't strand the user on an empty + // canvas with no toggle to escape. Keeping `view` state untouched means a + // deep-link "waits": the canvas appears the moment the first sub-agent does. + const hasSubAgents = agentRoot.children.length > 0; + const effectiveView: ChatView = hasSubAgents ? view : 'thread'; + // The inspector's "Open in thread" action: switch to the thread and scroll // to the run's turn. const openAgentInThread = useCallback((node: AgentRunNode) => { @@ -1670,7 +1678,7 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari onRenameTitle={processId ? () => setRenameOpen(true) : undefined} onStartFreshSameContext={onStartFreshSameContext} startingFreshSameContext={startingFreshSameContext} - viewToggle={!loading && !isPending && variant !== 'floating' + viewToggle={hasSubAgents && !loading && !isPending && variant !== 'floating' ? : undefined} /> @@ -1701,7 +1709,7 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari > {/* Inner row: ConversationArea + MiniMap, or the Agents canvas */}
- {view === 'agents' ? ( + {effectiveView === 'agents' ? ( ) : ( <> @@ -1761,7 +1769,7 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari )}
{/* Ralph grilling complete — show Start Ralph panel (thread view only) */} - {view === 'thread' && (() => { + {effectiveView === 'thread' && (() => { const ralphCtx = getRalphContext(task); const goalPath = detectedGoalFile || (task?.metadata?.goalFilePath as string | undefined) || ''; // Path 1: traditional grilling-phase → start @@ -1804,7 +1812,7 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari return null; })()} {/* Plan file complete — offer one-click handoff to autopilot (thread view only) */} - {view === 'thread' && isTerminal && !planChatBusy && resolveLoadedTaskMode(task) === 'ask' && effectivePlanPath && ( + {effectiveView === 'thread' && isTerminal && !planChatBusy && resolveLoadedTaskMode(task) === 'ask' && effectivePlanPath && ( { if (requireModifierKey && !e.ctrlKey && !e.metaKey) return; + // Don't hijack the wheel over interactive overlays (toolbars, an + // open inspector panel, etc.). They opt out with [data-no-drag] — + // the same marker the pan-drag handler honors — so their own + // content can scroll natively instead of zooming the canvas behind. + const target = e.target as HTMLElement | null; + if (target?.closest('[data-no-drag]')) return; e.preventDefault(); e.stopPropagation(); diff --git a/packages/coc/test/server/spa/client/repos/ChatDetail-agents-toggle.test.ts b/packages/coc/test/server/spa/client/repos/ChatDetail-agents-toggle.test.ts new file mode 100644 index 000000000..8077da14f --- /dev/null +++ b/packages/coc/test/server/spa/client/repos/ChatDetail-agents-toggle.test.ts @@ -0,0 +1,48 @@ +/** + * @vitest-environment node + * + * Static-analysis test: the Thread/Agents toggle must only appear once a chat + * has actually spawned sub-agents, and the canvas must never render without + * them — even via a stale `?view=agents` deep-link. + * + * ChatDetail is too heavyweight (SSE, queue/app contexts, the coc client, + * model hooks) to render in a unit test, so — mirroring the other ChatDetail + * tests in this folder — we assert the wiring against the source. The data + * contract this wiring depends on (no `Task` calls ⇒ `root.children` is empty) + * is covered directly in agent-canvas-data.test.ts. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +const SPA_ROOT = resolve(__dirname, '../../../../../src/server/spa/client/react'); +const source = readFileSync(resolve(SPA_ROOT, 'features/chat/ChatDetail.tsx'), 'utf-8'); + +describe('ChatDetail Agents toggle visibility', () => { + it('derives hasSubAgents from the agent tree children', () => { + expect(source).toMatch(/const\s+hasSubAgents\s*=\s*agentRoot\.children\.length\s*>\s*0/); + }); + + it('pins the rendered view to thread when there are no sub-agents', () => { + expect(source).toMatch(/const\s+effectiveView\s*:\s*ChatView\s*=\s*hasSubAgents\s*\?\s*view\s*:\s*'thread'/); + }); + + it('gates the Thread/Agents toggle on hasSubAgents', () => { + // The toggle must be hidden when no sub-agents exist; `hasSubAgents` + // is the first guard in the viewToggle expression. + expect(source).toMatch(/viewToggle=\{hasSubAgents\s*&&/); + }); + + it('renders the canvas from effectiveView, not the raw view state', () => { + expect(source).toMatch(/effectiveView\s*===\s*'agents'\s*\?/); + expect(source).not.toMatch(/\{view\s*===\s*'agents'\s*\?/); + }); + + it('gates the thread-only side panels on effectiveView', () => { + // Ralph start + ImplementPlan cards should show when we fall back to + // the thread, so they must key off effectiveView rather than `view`. + expect(source).not.toMatch(/\{view\s*===\s*'thread'\s*&&/); + const threadGuards = source.match(/effectiveView\s*===\s*'thread'\s*&&/g) ?? []; + expect(threadGuards.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/coc/test/spa/react/hooks/useZoomPan.test.ts b/packages/coc/test/spa/react/hooks/useZoomPan.test.ts index 0a7b00617..dcf9a3f31 100644 --- a/packages/coc/test/spa/react/hooks/useZoomPan.test.ts +++ b/packages/coc/test/spa/react/hooks/useZoomPan.test.ts @@ -1,9 +1,32 @@ import { describe, it, expect } from 'vitest'; -import { renderHook, act } from '@testing-library/react'; +import { renderHook, act, render, fireEvent } from '@testing-library/react'; +import { createElement, type ReactElement } from 'react'; import { useZoomPan } from '../../../../src/server/spa/client/react/hooks/ui/useZoomPan'; const defaultOptions = { contentWidth: 400, contentHeight: 200 }; +/** + * A real-DOM harness: the wheel listener is attached in an effect to + * `containerRef.current`, so it only exists when the ref points at a live node + * (the `renderHook` tests above poke a mock ref and never exercise the wheel). + * The container holds a bare canvas surface and a `[data-no-drag]` overlay + * (mirroring the inspector/toolbar) so we can dispatch wheel events at each. + */ +function ZoomPanHarness(): ReactElement { + const { containerRef, zoomLabel } = useZoomPan(defaultOptions); + return createElement( + 'div', + { ref: containerRef, 'data-testid': 'container' }, + createElement('div', { 'data-testid': 'surface' }), + createElement( + 'div', + { 'data-no-drag': true }, + createElement('div', { 'data-testid': 'overlay-content' }), + ), + createElement('span', { 'data-testid': 'label' }, zoomLabel), + ); +} + describe('useZoomPan', () => { it('initial state is scale=1, translate=(0,0), isDragging=false', () => { const { result } = renderHook(() => useZoomPan(defaultOptions)); @@ -181,3 +204,24 @@ describe('useZoomPan', () => { expect(result.current.state.translateX).toBe(0); }); }); + +describe('useZoomPan wheel handling', () => { + it('zooms when the wheel fires over the canvas surface', () => { + const { getByTestId } = render(createElement(ZoomPanHarness)); + // deltaY < 0 → zoom in by one ZOOM_STEP (0.15) → 115%. + const prevented = fireEvent.wheel(getByTestId('surface'), { deltaY: -100, clientX: 10, clientY: 10 }); + expect(getByTestId('label').textContent).toBe('115%'); + // The handler called preventDefault, so the page/canvas doesn't scroll. + expect(prevented).toBe(false); + }); + + it('does NOT zoom when the wheel fires inside a [data-no-drag] overlay', () => { + // Regression: scrolling inside the inspector panel must scroll the panel, + // not zoom the canvas underneath it. + const { getByTestId } = render(createElement(ZoomPanHarness)); + const notPrevented = fireEvent.wheel(getByTestId('overlay-content'), { deltaY: -100, clientX: 10, clientY: 10 }); + expect(getByTestId('label').textContent).toBe('100%'); + // Not prevented → the browser performs its native scroll of the overlay. + expect(notPrevented).toBe(true); + }); +}); diff --git a/packages/coc/test/spa/react/repos/WorkItemDetail.chat-lens.test.tsx b/packages/coc/test/spa/react/repos/WorkItemDetail.chat-lens.test.tsx index 03648df0b..ffc21d2a9 100644 --- a/packages/coc/test/spa/react/repos/WorkItemDetail.chat-lens.test.tsx +++ b/packages/coc/test/spa/react/repos/WorkItemDetail.chat-lens.test.tsx @@ -226,7 +226,12 @@ describe('WorkItemDetail Work Item chat lens', () => { expect(localStorage.getItem(getReviewChatOpenStorageKey(target))).toBe('true'); expect(screen.getByTestId('work-item-chat-lens').getAttribute('data-work-item-id')).toBe('wi-1'); expect(screen.getByTestId('work-item-chat-lens').getAttribute('data-title')).toBe('Saved title one'); - expect(screen.getByTestId('work-item-chat-lens').getAttribute('data-unsaved')).toBe('true'); + // `hasUnsavedChanges` reaches the lens a render-tick after the title edit, + // so poll for it rather than reading synchronously (mirrors the post-save + // assertion below). A bare read flakes under CI load (stale 'false'). + await waitFor(() => + expect(screen.getByTestId('work-item-chat-lens').getAttribute('data-unsaved')).toBe('true'), + ); fireEvent.click(screen.getByTestId('wi-save-btn'));