Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/skills/coc-knowledge/references/dashboard-spa.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ws>/<tab>/<taskId>?view=agents`) is read on mount (so a
shared/bookmarked URL reopens straight into the canvas) and written via
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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'
? <ChatViewToggle view={view} onChange={setView} />
: undefined}
/>
Expand Down Expand Up @@ -1701,7 +1709,7 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari
>
{/* Inner row: ConversationArea + MiniMap, or the Agents canvas */}
<div className="relative flex flex-1 min-h-0 overflow-hidden min-w-0">
{view === 'agents' ? (
{effectiveView === 'agents' ? (
<AgentCanvas root={agentRoot} onOpenInThread={openAgentInThread} />
) : (
<>
Expand Down Expand Up @@ -1761,7 +1769,7 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari
)}
</div>
{/* 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
Expand Down Expand Up @@ -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 && (
<ImplementPlanCard
planFilePath={effectivePlanPath}
workspaceId={workspaceId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ export function useZoomPan(options: UseZoomPanOptions): UseZoomPanReturn {

const onWheel = (e: WheelEvent) => {
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();

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 45 additions & 1 deletion packages/coc/test/spa/react/hooks/useZoomPan.test.ts
Original file line number Diff line number Diff line change
@@ -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));
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down
Loading