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
48 changes: 48 additions & 0 deletions .github/skills/coc-knowledge/references/dashboard-spa.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,54 @@ server/Forge layer); cells without a displayable USD value show explicit
`USD pricing unavailable` copy instead of silently leaving cost blank. The UI
does not render Copilot premium request units.

### Agents view (sub-agent canvas)

`ChatHeader` exposes a `Thread | Agents` segmented toggle (`ChatViewToggle`,
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
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
`history.replaceState` on toggle (`chatViewHash.ts`). `parseActivityDeepLink`
strips the `?query` so the param never corrupts the taskId. `view` resets to
`thread` on chat switch (honoring a deep-linked view on first mount).

`buildAgentRunTreeFromTurns(turns, root)` derives the tree with no extra fetch:
the orchestrator (this process) is the root and each `Task` tool call becomes a
sub-agent child. From the call's args it captures the agent name (`args.name`,
falling back to `description`/`prompt`), type (`agent_type`/`subagent_type`),
`model`, `mode`, `description`, and `prompt`; status/timing come from the call.
Children are deduped across `toolCalls`+timeline — keeping the snapshot with
non-empty args, since a terminal `tool-complete` often carries empty args while
an earlier snapshot holds the full invocation — and ordered by start time.
Tool name/args are read
via `toolName ?? name` and `args ?? parameters` so sub-agents are detected in
both the live (SSE) shape and the persisted forge read model — they stay on the
canvas after the chat completes and turns refresh. The `AgentRunNode` tree
supports arbitrary depth, so deeper recursion can be layered on later.
`AgentCanvas` reuses the shared `useZoomPan` hook — it opens at 100% zoom,
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.
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
with the run's name/type/status/elapsed, a details list (model, mode, summary),
the task prompt, its result, and its children (clickable to drill in); clicking
the orchestrator root closes it.
`AgentCanvas` owns the selection; the inspector's "Open in thread" button calls
`onOpenInThread`, which `ChatDetail` maps back to the issuing turn via
`findTurnIndexForRun`, switching to the thread and scrolling there.
Styles live in scoped `agent-canvas.css` (`.agent-canvas`,
light/dark via `.dark`); there is no clock scrubber (the prototype's replay
control is dropped — the real view is
live). Distinct from the co-edited `CanvasPanel` side panel.

## Tool Call Rendering

Inside `WhisperCollapsedGroup`, tool calls render as compact "whisper-row" variant:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import { buildEffortOptionsForModel } from './EffortPillSelector';
import type { EffortLevel } from './EffortPillSelector';
import type { RichTextInputHandle } from '../../shared/RichTextInput';
import { ConversationMiniMap } from './conversation/ConversationMiniMap';
import { AgentCanvas, ChatViewToggle, buildAgentRunTreeFromTurns, findTurnIndexForRun, readChatViewFromHash, applyChatViewToHash } from './agent-canvas';
import type { AgentRunNode, ChatView } from './agent-canvas';
import { useConversationSelection } from './hooks/useConversationSelection';
import { snapshotConversation } from '../../utils/snapshot-copy-utils';
import { copyHtmlToClipboard } from '../../utils/format';
Expand Down Expand Up @@ -179,6 +181,12 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari
const [activeCanvasId, setActiveCanvasId] = useState<string | null>(null);
const [canvasLiveEvent, setCanvasLiveEvent] = useState<CanvasUpdatedEvent | null>(null);
const [canvasPanelClosed, setCanvasPanelClosed] = useState(false);
// Thread vs. Agents (spatial sub-agent run tree) view. In the main inline
// context the view is deep-linked via a `?view=agents` hash param, so a
// shared/bookmarked URL reopens straight into the canvas.
const hashViewSync = variant === 'inline' && !standalone;
const [view, setView] = useState<ChatView>(() => (hashViewSync ? (readChatViewFromHash(window.location.hash) ?? 'thread') : 'thread'));
const [pendingScrollTurn, setPendingScrollTurn] = useState<number | null>(null);
const [noteEdits, setNoteEdits] = useState<Array<{
editId: string; notePath: string; preEditContent: string;
postEditContent?: string; timestamp: string; turnIndex: number; tooLarge?: boolean;
Expand Down Expand Up @@ -303,6 +311,36 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari

const createdFiles = useMemo(() => scanTurnsForCreatedFiles(turns), [turns]);

// ── Agents view: spatial tree of this chat's recursive sub-agent runs ──
const agentRoot = useMemo<AgentRunNode>(() => buildAgentRunTreeFromTurns(turns, {
id: 'root',
title: (task?.customTitle as string | undefined) || title || task?.title || task?.displayName,
status: effectiveStatus,
}), [turns, task?.customTitle, task?.title, task?.displayName, title, effectiveStatus]);

// The inspector's "Open in thread" action: switch to the thread and scroll
// to the run's turn.
const openAgentInThread = useCallback((node: AgentRunNode) => {
const idx = node.isRoot
? (turnsRef.current[0]?.turnIndex ?? 0)
: findTurnIndexForRun(turnsRef.current, node.id);
if (idx != null) {
setView('thread');
setPendingScrollTurn(idx);
}
}, []);

// After switching to the thread, scroll to the turn a canvas node pointed at.
useEffect(() => {
if (pendingScrollTurn == null || view !== 'thread') return;
const container = conversationContainerRef.current;
const target = container?.querySelector<HTMLElement>(`[data-turn-index="${pendingScrollTurn}"]`);
if (target) {
target.scrollIntoView({ block: 'start', behavior: 'smooth' });
setPendingScrollTurn(null);
}
}, [pendingScrollTurn, view, turns]);

// Compute the follow-up mode pill set, optionally appending Ralph when
// the chat is eligible for in-place promotion. Eligibility:
// - completed (no in-flight turn or queued follow-ups)
Expand Down Expand Up @@ -429,6 +467,29 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari
setInvalidScratchpadPaths(new Set());
}, [taskId]);

// Reset the Agents view when switching chats, but honor a deep-linked view
// on first mount (the useState initializer already read it from the hash).
const viewResetMountRef = useRef(true);
useEffect(() => {
if (viewResetMountRef.current) {
viewResetMountRef.current = false;
return;
}
setView(hashViewSync ? (readChatViewFromHash(window.location.hash) ?? 'thread') : 'thread');
setPendingScrollTurn(null);
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps

// Mirror the active view into the chat hash (`?view=agents`) so it can be
// shared/bookmarked. replaceState avoids history spam and doesn't refire
// the router's hashchange handler.
useEffect(() => {
if (!hashViewSync || !window.location.hash) return;
const next = applyChatViewToHash(window.location.hash, view);
if (next !== window.location.hash) {
window.history.replaceState(null, '', next);
}
}, [view, hashViewSync]);

// ── Resolve existing implementation runs from task metadata ─────────
const rawImplementations: ImplementationRecord[] = useMemo(() => {
const impls = task?.metadata?.implementations;
Expand Down Expand Up @@ -1609,6 +1670,9 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari
onRenameTitle={processId ? () => setRenameOpen(true) : undefined}
onStartFreshSameContext={onStartFreshSameContext}
startingFreshSameContext={startingFreshSameContext}
viewToggle={!loading && !isPending && variant !== 'floating'
? <ChatViewToggle view={view} onChange={setView} />
: undefined}
/>
{loopPanelOpen && isLoopsEnabled() && (
<div className="relative">
Expand All @@ -1635,8 +1699,12 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari
: { flex: '1 1 auto', minHeight: 0 }
}
>
{/* Inner row: ConversationArea + MiniMap side by side */}
{/* Inner row: ConversationArea + MiniMap, or the Agents canvas */}
<div className="relative flex flex-1 min-h-0 overflow-hidden min-w-0">
{view === 'agents' ? (
<AgentCanvas root={agentRoot} onOpenInThread={openAgentInThread} />
) : (
<>
<ConversationArea
loading={loading}
error={error}
Expand Down Expand Up @@ -1689,9 +1757,11 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari
isStreaming={task?.status === 'running'}
/>
)}
</>
)}
</div>
{/* Ralph grilling complete — show Start Ralph panel */}
{(() => {
{/* Ralph grilling complete — show Start Ralph panel (thread view only) */}
{view === 'thread' && (() => {
const ralphCtx = getRalphContext(task);
const goalPath = detectedGoalFile || (task?.metadata?.goalFilePath as string | undefined) || '';
// Path 1: traditional grilling-phase → start
Expand Down Expand Up @@ -1733,8 +1803,8 @@ export function ChatDetail({ taskId, onBack, workspaceId, isPopOut = false, vari
}
return null;
})()}
{/* Plan file complete — offer one-click handoff to autopilot */}
{isTerminal && !planChatBusy && resolveLoadedTaskMode(task) === 'ask' && effectivePlanPath && (
{/* Plan file complete — offer one-click handoff to autopilot (thread view only) */}
{view === 'thread' && isTerminal && !planChatBusy && resolveLoadedTaskMode(task) === 'ask' && effectivePlanPath && (
<ImplementPlanCard
planFilePath={effectivePlanPath}
workspaceId={workspaceId}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ReferencesDropdown, ReferenceList, deduplicateReferenceFiles } from '../../ui/ReferencesDropdown';
import { BottomSheet } from '../../ui/BottomSheet';
import { ConversationMetadataPopover } from './conversation/ConversationMetadataPopover';
Expand Down Expand Up @@ -103,6 +103,8 @@ export interface ChatHeaderProps {
onStartFreshSameContext?: () => Promise<boolean> | boolean | void;
/** True while the lens chat fresh-context operation is in progress. */
startingFreshSameContext?: boolean;
/** Optional control rendered at the start of the right-side action area (e.g. the Thread/Agents view toggle). */
viewToggle?: ReactNode;
}

/** Build overflow menu items based on what's hidden at the current container tier */
Expand Down Expand Up @@ -346,6 +348,7 @@ export function ChatHeader({
onRenameTitle,
onStartFreshSameContext,
startingFreshSameContext = false,
viewToggle,
}: ChatHeaderProps) {
const { isMobile } = useBreakpoint();
const { isFloating } = useFloatingChats();
Expand Down Expand Up @@ -494,6 +497,8 @@ export function ChatHeader({

{/* Right side */}
<div className="flex items-center flex-shrink-0">
{/* View toggle (Thread / Agents), when provided by the host. */}
{viewToggle}
{/*
Vertical divider visually separates the identity/status area
from the action group, matching the redesign mockup's
Expand Down
Loading
Loading