diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index fd05e1d5..f41572ac 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -23,6 +23,12 @@ type ClaudeV2Session = { readonly sessionId: string; }; import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message"; +import { + appendBufferedAssistantText, + canAppendBufferedAssistantText, + shouldFlushBufferedAssistantTextForEvent, + type BufferedAssistantText, +} from "./chatTextBatching"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createSessionService } from "../sessions/sessionService"; @@ -262,6 +268,7 @@ type ManagedChatSession = { turnId?: string; itemId?: string; } | null; + bufferedText: (BufferedAssistantText & { timer: NodeJS.Timeout | null }) | null; recentConversationEntries: Array<{ role: "user" | "assistant"; text: string; @@ -336,6 +343,7 @@ const DEFAULT_UNIFIED_MODEL_ID = DEFAULT_UNIFIED_DESCRIPTOR?.id ?? "anthropic/cl const DEFAULT_REASONING_EFFORT = "medium"; const DEFAULT_AUTO_TITLE_MODEL_ID = "anthropic/claude-haiku-4-5-api"; const MAX_CHAT_TRANSCRIPT_BYTES = 8 * 1024 * 1024; +const BUFFERED_TEXT_FLUSH_MS = 100; const CHAT_TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] chat transcript limit reached (8MB). Further events omitted.\n"; const DEFAULT_TRANSCRIPT_READ_LIMIT = 20; const MAX_TRANSCRIPT_READ_LIMIT = 100; @@ -1236,6 +1244,8 @@ export function createAgentChatService(args: { const managed = ensureManagedSession(sessionId); const normalizedLimit = Math.max(1, Math.min(MAX_TRANSCRIPT_READ_LIMIT, Math.floor(limit))); const normalizedMaxChars = Math.max(200, Math.min(MAX_TRANSCRIPT_READ_CHARS, Math.floor(maxChars))); + // Flush any pending buffered text so the transcript includes all content + flushBufferedText(managed); const transcriptEntries = readTranscriptEntries(managed); const fallbackEntries = transcriptEntries.length ? transcriptEntries @@ -1917,6 +1927,54 @@ export function createAgentChatService(args: { }); }; + const flushBufferedText = (managed: ManagedChatSession): void => { + const buffered = managed.bufferedText; + if (!buffered) return; + if (buffered.timer) { + clearTimeout(buffered.timer); + } + managed.bufferedText = null; + if (!buffered.text.length) return; + commitChatEvent(managed, { + type: "text", + text: buffered.text, + ...(buffered.turnId ? { turnId: buffered.turnId } : {}), + ...(buffered.itemId ? { itemId: buffered.itemId } : {}), + }); + }; + + const scheduleBufferedTextFlush = (managed: ManagedChatSession): void => { + const buffered = managed.bufferedText; + if (!buffered || buffered.timer) return; + buffered.timer = setTimeout(() => { + if (managed.bufferedText) { + managed.bufferedText.timer = null; + } + flushBufferedText(managed); + }, BUFFERED_TEXT_FLUSH_MS); + }; + + const queueBufferedTextEvent = ( + managed: ManagedChatSession, + event: Extract, + ): void => { + if (canAppendBufferedAssistantText(managed.bufferedText, event)) { + managed.bufferedText = { + ...appendBufferedAssistantText(managed.bufferedText, event), + timer: managed.bufferedText?.timer ?? null, + }; + scheduleBufferedTextFlush(managed); + return; + } + + flushBufferedText(managed); + managed.bufferedText = { + ...appendBufferedAssistantText(null, event), + timer: null, + }; + scheduleBufferedTextFlush(managed); + }; + const flushBufferedReasoning = (managed: ManagedChatSession): void => { const buffered = managed.bufferedReasoning; if (!buffered) return; @@ -1941,6 +1999,11 @@ export function createAgentChatService(args: { }; const emitChatEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => { + if (event.type === "text") { + queueBufferedTextEvent(managed, event); + return; + } + if (event.type === "reasoning") { queueReasoningEvent(managed, event); return; @@ -1952,12 +2015,18 @@ export function createAgentChatService(args: { return; } flushBufferedReasoning(managed); + if (shouldFlushBufferedAssistantTextForEvent(event)) { + flushBufferedText(managed); + } managed.lastActivitySignature = signature; commitChatEvent(managed, event); return; } flushBufferedReasoning(managed); + if (shouldFlushBufferedAssistantTextForEvent(event)) { + flushBufferedText(managed); + } if ( event.type === "user_message" @@ -1975,6 +2044,7 @@ export function createAgentChatService(args: { /** Tear down the active runtime, releasing all resources and cancelling pending approvals. */ const teardownRuntime = (managed: ManagedChatSession): void => { flushBufferedReasoning(managed); + flushBufferedText(managed); if (managed.runtime?.kind === "codex") { managed.runtime.suppressExitError = true; try { managed.runtime.reader.close(); } catch { /* ignore */ } @@ -2087,6 +2157,8 @@ export function createAgentChatService(args: { if (managed.endedNotified) return; managed.endedNotified = true; clearSubagentSnapshots(managed.session.id); + flushBufferedText(managed); + flushBufferedReasoning(managed); if (options?.summary !== undefined) { sessionService.setSummary(managed.session.id, options.summary); @@ -2239,6 +2311,7 @@ export function createAgentChatService(args: { lastActivitySignature: null, bufferedReasoning: null, previewTextBuffer: null, + bufferedText: null, recentConversationEntries: [], }; managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; @@ -4800,6 +4873,7 @@ export function createAgentChatService(args: { autoTitleStage: "none", autoTitleInFlight: false, previewTextBuffer: null, + bufferedText: null, recentConversationEntries: [], }; @@ -5050,6 +5124,7 @@ export function createAgentChatService(args: { lastActivitySignature: null, bufferedReasoning: null, previewTextBuffer: null, + bufferedText: null, recentConversationEntries: [], }; managed.transcriptLimitReached = managed.transcriptBytesWritten >= MAX_CHAT_TRANSCRIPT_BYTES; diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.test.ts b/apps/desktop/src/main/services/chat/chatTextBatching.test.ts new file mode 100644 index 00000000..444cdd5f --- /dev/null +++ b/apps/desktop/src/main/services/chat/chatTextBatching.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + appendBufferedAssistantText, + canAppendBufferedAssistantText, + shouldFlushBufferedAssistantTextForEvent, +} from "./chatTextBatching"; + +describe("chatTextBatching", () => { + it("appends adjacent text deltas for the same turn and item", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " world", + turnId: "turn-1", + itemId: "item-1", + })).toBe(true); + + expect(appendBufferedAssistantText(buffered, { + type: "text", + text: " world", + turnId: "turn-1", + itemId: "item-1", + })).toMatchObject({ + text: "Hello world", + turnId: "turn-1", + itemId: "item-1", + }); + }); + + it("stops batching when the text identity changes", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + turnId: "turn-1", + itemId: "item-1", + }); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: "Other", + turnId: "turn-2", + itemId: "item-1", + })).toBe(false); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: "Other", + turnId: "turn-1", + itemId: "item-2", + })).toBe(false); + }); + + it("flushes buffered text on structural chat events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "tool_call", + tool: "functions.exec_command", + args: { cmd: "pwd" }, + itemId: "tool-1", + turnId: "turn-1", + })).toBe(true); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "command", + command: "pwd", + cwd: "/tmp", + output: "", + itemId: "cmd-1", + turnId: "turn-1", + status: "running", + })).toBe(true); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "approval_request", + itemId: "approval-1", + kind: "command", + description: "Run shell command", + turnId: "turn-1", + })).toBe(true); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "done", + turnId: "turn-1", + status: "completed", + })).toBe(true); + }); + + it("does not collapse anonymous text chunks that lack identity", () => { + const buffered = appendBufferedAssistantText(null, { + type: "text", + text: "Hello", + }); + + expect(canAppendBufferedAssistantText(buffered, { + type: "text", + text: " world", + })).toBe(false); + }); + + it("flushes buffered text on discrete UI card events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "todo_update", + todos: [], + turnId: "turn-1", + } as any)).toBe(true); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "subagent_started", + taskId: "task-1", + turnId: "turn-1", + } as any)).toBe(true); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "web_search", + query: "test", + turnId: "turn-1", + } as any)).toBe(true); + }); + + it("keeps buffered text live across lightweight progress events", () => { + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "activity", + activity: "thinking", + detail: "Reasoning", + turnId: "turn-1", + })).toBe(false); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "reasoning", + text: "Thinking through it", + turnId: "turn-1", + })).toBe(false); + + expect(shouldFlushBufferedAssistantTextForEvent({ + type: "plan_text", + text: "- step one", + turnId: "turn-1", + })).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.ts b/apps/desktop/src/main/services/chat/chatTextBatching.ts new file mode 100644 index 00000000..047880e9 --- /dev/null +++ b/apps/desktop/src/main/services/chat/chatTextBatching.ts @@ -0,0 +1,48 @@ +import type { AgentChatEvent } from "../../../shared/types"; + +export type BufferedAssistantText = { + text: string; + turnId?: string; + itemId?: string; +}; + +export function canAppendBufferedAssistantText( + buffered: BufferedAssistantText | null, + event: Extract, +): boolean { + if (!buffered) return false; + // Don't collapse anonymous chunks that lack any identity + if (!buffered.turnId && !buffered.itemId && !event.turnId && !event.itemId) return false; + return (buffered.turnId ?? null) === (event.turnId ?? null) + && (buffered.itemId ?? null) === (event.itemId ?? null); +} + +export function appendBufferedAssistantText( + buffered: BufferedAssistantText | null, + event: Extract, +): BufferedAssistantText { + if (canAppendBufferedAssistantText(buffered, event)) { + return { + ...buffered!, + text: `${buffered!.text}${event.text}`, + }; + } + + return { + text: event.text, + ...(event.turnId ? { turnId: event.turnId } : {}), + ...(event.itemId ? { itemId: event.itemId } : {}), + }; +} + +export function shouldFlushBufferedAssistantTextForEvent(event: AgentChatEvent): boolean { + switch (event.type) { + case "text": + case "reasoning": + case "activity": + case "plan_text": + return false; + default: + return true; + } +} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index c83865fa..d258c85c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1,11 +1,14 @@ /* @vitest-environment jsdom */ -import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ComponentProps } from "react"; import { createDefaultComputerUsePolicy } from "../../../shared/types"; +import { getPermissionOptions } from "../shared/permissionOptions"; import { AgentChatComposer } from "./AgentChatComposer"; +afterEach(cleanup); + function renderComposer(overrides: Partial> = {}) { const props: ComponentProps = { modelId: "openai/gpt-5-chat-latest", @@ -42,6 +45,23 @@ function renderComposer(overrides: Partial["executionModeOptions"]>; + describe("AgentChatComposer", () => { it("clear draft only triggers the draft-clear action during an active turn", () => { const props = renderComposer(); @@ -61,4 +81,40 @@ describe("AgentChatComposer", () => { expect(props.onInterrupt).toHaveBeenCalledTimes(1); expect(props.onClearDraft).not.toHaveBeenCalled(); }); + + it("labels the Codex plan permission mode as Plan", () => { + expect(getPermissionOptions({ family: "openai", isCliWrapped: true })[0]?.label).toBe("Plan"); + }); + + it("opens the advanced popover and wires the advanced controls", () => { + const onExecutionModeChange = vi.fn(); + const onComputerUsePolicyChange = vi.fn(); + const onToggleProof = vi.fn(); + const onIncludeProjectDocsChange = vi.fn(); + renderComposer({ + executionMode: "focused", + executionModeOptions, + onExecutionModeChange, + onComputerUsePolicyChange, + onToggleProof, + includeProjectDocs: false, + onIncludeProjectDocsChange, + }); + + fireEvent.click(screen.getByRole("button", { name: "Advanced" })); + + fireEvent.click(screen.getByRole("button", { name: /^Parallel/ })); + fireEvent.click(screen.getByRole("button", { name: /Computer use.*\b(On|Off)\b/i })); + fireEvent.click(screen.getByRole("button", { name: /^Proof\b/i })); + fireEvent.click(screen.getByRole("button", { name: /^Project Context\b/i })); + + expect(onExecutionModeChange).toHaveBeenCalledWith("parallel"); + expect(onComputerUsePolicyChange).toHaveBeenCalledTimes(1); + expect(onComputerUsePolicyChange.mock.calls[0]?.[0]?.mode).toBe("off"); + expect(onToggleProof).toHaveBeenCalledTimes(1); + expect(onIncludeProjectDocsChange).toHaveBeenCalledWith(true); + + fireEvent.click(screen.getByRole("button", { name: "Advanced" })); + expect(screen.queryByText("Advanced settings")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index a86382be..a6e1dc2c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { At, Image, Paperclip, Square, X, PaperPlaneTilt, Lightning } from "@phosphor-icons/react"; +import { At, CaretDown, Image, Paperclip, Square, X, PaperPlaneTilt, Lightning } from "@phosphor-icons/react"; import { inferAttachmentType, type AgentChatApprovalDecision, @@ -117,7 +117,8 @@ function PermissionHoverPane({ opt }: { opt: PermissionOption }) { {badgeLabel}
-

{opt.detail}

+

{opt.shortDesc}

+

{opt.detail}

{opt.allows.length > 0 && (
{opt.allows.map((item) => ( @@ -158,6 +159,219 @@ function PermissionHoverPane({ opt }: { opt: PermissionOption }) { ); } +type AdvancedSettingsPopoverProps = { + executionModeOptions: ExecutionModeOption[]; + executionMode: AgentChatExecutionMode | null; + onExecutionModeChange?: (mode: AgentChatExecutionMode) => void; + computerUsePolicy: ComputerUsePolicy; + computerUseSnapshot: ComputerUseOwnerSnapshot | null; + onToggleComputerUse: () => void; + onOpenComputerUseDetails: () => void; + proofOpen: boolean; + proofArtifactCount: number; + onToggleProof?: () => void; + includeProjectDocs?: boolean; + onIncludeProjectDocsChange?: (checked: boolean) => void; +}; + +function AdvancedSettingsPopover({ + executionModeOptions, + executionMode, + onExecutionModeChange, + computerUsePolicy, + computerUseSnapshot, + onToggleComputerUse, + onOpenComputerUseDetails, + proofOpen, + proofArtifactCount, + onToggleProof, + includeProjectDocs, + onIncludeProjectDocsChange, +}: AdvancedSettingsPopoverProps) { + const [hoveredExecutionMode, setHoveredExecutionMode] = useState(null); + const computerUseAllowed = computerUsePolicy.mode !== "off"; + const activeBackend = computerUseSnapshot?.activeBackend?.name ?? (computerUsePolicy.allowLocalFallback ? "Fallback allowed" : "No fallback"); + const activeExecutionMode = executionModeOptions.find((option) => option.value === executionMode) ?? executionModeOptions[0] ?? null; + const helpMode = hoveredExecutionMode + ? executionModeOptions.find((option) => option.value === hoveredExecutionMode) ?? activeExecutionMode + : activeExecutionMode; + + return ( +
+
+
+
+
Advanced settings
+
+ Tune execution behavior, computer use, proof retention, and project context without widening the main composer. +
+
+ +
+
+ +
+ {executionModeOptions.length > 0 && onExecutionModeChange ? ( +
+
+
+
Execution mode
+
Choose whether the model stays in one thread or spreads work across delegates.
+
+ {helpMode ? ( +
+ {helpMode.summary} +
+ ) : null} +
+
+ {executionModeOptions.map((option) => { + const isActive = executionMode === option.value; + return ( + + ); + })} +
+
+ ) : null} + +
+ + + + + +
+ + {helpMode ? ( +
+
+ Mode help + {helpMode.label} +
+
{hoveredExecutionMode ? helpMode.helper : helpMode.summary}
+
+ ) : null} +
+
+ ); +} + function ComputerUseSettingsModal({ open, policy, @@ -426,17 +640,19 @@ export function AgentChatComposer({ const [hoveredMode, setHoveredMode] = useState(null); const [dragActive, setDragActive] = useState(false); + const [advancedMenuOpen, setAdvancedMenuOpen] = useState(false); const [computerUseModalOpen, setComputerUseModalOpen] = useState(false); const attachmentInputRef = useRef(null); const uploadInputRef = useRef(null); const textareaRef = useRef(null); + const advancedMenuRef = useRef(null); + const advancedButtonRef = useRef(null); const canAttach = !turnActive; const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); const selectedModel = useMemo(() => getModelById(modelId), [modelId]); const computerUseAllowed = computerUsePolicy.mode !== "off"; - const proofButtonLabel = proofArtifactCount > 0 ? `Proof ${proofArtifactCount}` : "Proof"; const effectiveSlashCommands = useMemo( () => buildSlashCommands(sdkSlashCommands, selectedModel?.family), @@ -479,6 +695,29 @@ export function AgentChatComposer({ return () => window.removeEventListener("keydown", onKeyDown, true); }, [computerUseModalOpen]); + useEffect(() => { + if (!advancedMenuOpen) return; + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (advancedMenuRef.current?.contains(target)) return; + if (advancedButtonRef.current?.contains(target)) return; + setAdvancedMenuOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setAdvancedMenuOpen(false); + advancedButtonRef.current?.focus(); + } + }; + window.addEventListener("pointerdown", onPointerDown, true); + window.addEventListener("keydown", onKeyDown, true); + return () => { + window.removeEventListener("pointerdown", onPointerDown, true); + window.removeEventListener("keydown", onKeyDown, true); + }; + }, [advancedMenuOpen]); + useEffect(() => { if (!attachmentPickerOpen) return; const query = attachmentQuery.trim(); @@ -767,131 +1006,44 @@ export function AgentChatComposer({ } footer={ -
- {/* Row 1: Control groups — permissions, CU, execution mode */} -
- {!isPersistentIdentitySurface && executionModeOptions.length > 0 && onExecutionModeChange ? ( -
- {executionModeOptions.map((option) => { - const isActive = executionMode === option.value; - return ( - - ); - })} -
- ) : null} - - {permissionMode && onPermissionModeChange && permissionOptions.length > 0 && !permissionModeLocked ? ( -
- {permissionOptions.map((opt) => { - const isActive = permissionMode === opt.value; - const isHovered = hoveredMode === opt.value; - const colors = safetyColors(opt.safety); - return ( -
- - {isHovered && !isPersistentIdentitySurface ? : null} -
- ); - })} -
- ) : null} - -
-
- + {isHovered && !isPersistentIdentitySurface ? : null} +
+ ); })} - title="Allow the agent to use connected browser or desktop tools in this chat" - > - {computerUseAllowed ? "Computer use on" : "Computer use off"} - - -
- - +
+ ) : null}
- {!isPersistentIdentitySurface && onIncludeProjectDocsChange && !turnActive ? ( - - ) : null} -
- - {/* Row 2: Model selector + actions */} -
+
-
+
+
+ > + @ + + > + + + > + / + +
+ +
+
+ + {advancedMenuOpen ? ( +
+ { + onComputerUsePolicyChange({ + ...computerUsePolicy, + mode: computerUseAllowed ? "off" : "enabled", + }); + }} + onOpenComputerUseDetails={() => { + setAdvancedMenuOpen(false); + setComputerUseModalOpen(true); + }} + proofOpen={proofOpen} + proofArtifactCount={proofArtifactCount} + onToggleProof={onToggleProof} + includeProjectDocs={includeProjectDocs} + onIncludeProjectDocsChange={onIncludeProjectDocsChange} + /> +
+ ) : null} +
{turnActive ? ( <> - {draft.trim().length > 0 && ( - <> - {onClearDraft ? ( - - ) : null} - - - )} + {draft.trim().length > 0 && onClearDraft ? ( + + ) : null} + {draft.trim().length > 0 ? ( + + ) : null} + {expandable && open ? ( +
+ {children} +
+ ) : null} +
+ ); +} + +function normalizeFileSystemPath(value: string): string { + return value.replace(/\\/g, "/"); +} + +function trimTrailingSlashes(value: string): string { + if (value === "/") return value; + return value.replace(/\/+$/, ""); +} + +function resolveFilesNavigationTarget(args: { + path: string; + workspaces: FilesWorkspace[]; + fallbackLaneId: string | null; +}): { openFilePath: string; laneId: string | null } | null { + const candidate = normalizeWorkspacePathCandidate(args.path); + if (!candidate) return null; + + const normalizedCandidate = normalizeFileSystemPath(candidate); + if (normalizedCandidate.startsWith("/")) { + const matches = args.workspaces + .map((workspace) => ({ + workspace, + rootPath: trimTrailingSlashes(normalizeFileSystemPath(workspace.rootPath)), + })) + .filter(({ rootPath }) => + normalizedCandidate === rootPath || normalizedCandidate.startsWith(`${rootPath}/`), + ) + .sort((left, right) => { + const rightMatchesLane = right.workspace.laneId != null && right.workspace.laneId === args.fallbackLaneId ? 1 : 0; + const leftMatchesLane = left.workspace.laneId != null && left.workspace.laneId === args.fallbackLaneId ? 1 : 0; + if (rightMatchesLane !== leftMatchesLane) return rightMatchesLane - leftMatchesLane; + return right.rootPath.length - left.rootPath.length; + }); + + const match = matches[0]; + if (!match) return null; + const openFilePath = normalizedCandidate.slice(match.rootPath.length).replace(/^\/+/, ""); + if (!openFilePath.length) return null; + return { + openFilePath, + laneId: match.workspace.laneId ?? args.fallbackLaneId ?? null, + }; + } + + const openFilePath = normalizedCandidate.replace(/^\.\//, ""); + if (!openFilePath.length) return null; + return { + openFilePath, + laneId: args.fallbackLaneId ?? null, + }; +} + /* ── Markdown renderer ── */ -const MarkdownBlock = React.memo(function MarkdownBlock({ markdown }: { markdown: string }) { +const MarkdownBlock = React.memo(function MarkdownBlock({ + markdown, + onOpenWorkspacePath, + workspaceLaneId, +}: { + markdown: string; + onOpenWorkspacePath?: (path: string, laneId?: string | null) => void; + workspaceLaneId?: string | null; +}) { + const openWorkspacePath = useCallback((path: string) => { + onOpenWorkspacePath?.(path, workspaceLaneId ?? null); + }, [onOpenWorkspacePath, workspaceLaneId]); + return ( -
+
), pre: ({ children }) => ( -
+            
               {children}
             
), code: ({ className, children }) => { const text = String(children ?? ""); const isBlock = /\n/.test(text) || (typeof className === "string" && className.length > 0); + const workspacePath = !isBlock ? normalizeWorkspacePathCandidate(text) : null; + const pathIsClickable = Boolean(workspacePath && looksLikeWorkspacePath(workspacePath)); return isBlock ? ( {children} + ) : pathIsClickable ? ( + ) : ( {children} ); }, - a: ({ children, href }) => ( - - {children} - - ) + a: ({ children, href }) => { + const workspacePath = resolveWorkspacePathFromHref(href); + if (workspacePath) { + return ( + + ); + } + return ( + + {children} + + ); + } }} > {markdown} @@ -926,6 +1222,254 @@ function ModelGlyph({ return ; } +type AssistantPresentation = { + label: string; + glyph: React.ReactNode; +}; + +function resolveAssistantPresentation({ + assistantLabel, + turnModel, +}: { + assistantLabel?: string; + turnModel?: { label: string; modelId?: string; model?: string } | null; +}): AssistantPresentation { + const customLabel = assistantLabel?.trim() ?? ""; + const modelMeta = turnModel ? resolveModelMeta(turnModel.modelId, turnModel.model) : { family: null, cliCommand: null }; + const providerLabel = + modelMeta.family === "anthropic" || modelMeta.cliCommand === "claude" + ? "Claude" + : modelMeta.cliCommand === "codex" + ? "Codex" + : null; + const fallbackProviderLabel = customLabel === "Claude" || customLabel === "Codex" ? customLabel : null; + const hardOverrideLabel = + customLabel.length > 0 + && customLabel !== "Agent" + && customLabel !== "Assistant" + && customLabel !== "Claude" + && customLabel !== "Codex" + ? customLabel + : null; + const resolvedProviderLabel = providerLabel ?? fallbackProviderLabel; + const label = hardOverrideLabel ?? resolvedProviderLabel ?? "Assistant"; + const glyph = resolvedProviderLabel === "Claude" + ? + : resolvedProviderLabel === "Codex" + ? + : ; + return { label, glyph }; +} + +function commandStatusBadgeCls(status: "running" | "completed" | "failed"): string { + switch (status) { + case "completed": + return "border-emerald-500/25 bg-emerald-500/10 text-emerald-400"; + case "failed": + return "border-red-500/25 bg-red-500/10 text-red-400"; + default: + return "border-amber-500/25 bg-amber-500/10 text-amber-400"; + } +} + +function aggregateCommandStatus(commands: Array }>): "running" | "completed" | "failed" { + if (commands.some((entry) => entry.event.status === "failed")) return "failed"; + if (commands.some((entry) => entry.event.status === "running")) return "running"; + return "completed"; +} + +function aggregateFileChangeStatus(fileChanges: Array }>): "running" | "completed" | "failed" { + if (fileChanges.some((entry) => entry.event.status === "failed")) return "failed"; + if (fileChanges.some((entry) => entry.event.status === "running")) return "running"; + return "completed"; +} + +function commandTimelineVerb(status: Extract["status"]): string { + if (status === "failed") return "Command failed"; + if (status === "running") return "Running"; + return "Ran"; +} + +function CommandEventCard({ + event, +}: { + event: Extract; +}) { + const outputTrimmed = event.output.trim(); + const hasOutput = outputTrimmed.length > 0; + const timelineVerb = commandTimelineVerb(event.status); + const timelineSummary = ( +
+ + + {timelineVerb} + {event.command} + {event.durationMs != null ? {Math.max(0, event.durationMs)}ms : null} + {event.exitCode != null ? ( + + {event.exitCode === 0 ? "pass" : `exit ${event.exitCode}`} + + ) : null} +
+ ); + + const commandBody = ( + <> +
+ $ + {event.command} +
+ {hasOutput ? ( +
+          {event.output}
+        
+ ) : null} + + ); + + return ( + + {commandBody} + + ); +} + +function FileChangeEventCard({ + event, +}: { + event: Extract; +}) { + const { additions, deletions } = summarizeDiffStats(event.diff); + const hasDiff = event.diff.trim().length > 0; + const basename = basenamePathLabel(event.path); + const dirname = dirnamePathLabel(event.path); + const summary = ( +
+ + + {formatFileAction(event.kind)} + {basename} + {additions > 0 ? +{additions} : null} + {deletions > 0 || event.kind === "delete" ? -{deletions} : null} + {dirname ? {dirname} : null} +
+ ); + + return ( + + {hasDiff ? ( + + ) : ( +
No diff payload available.
+ )} +
+ ); +} + +function CommandGroupCard({ + group, +}: { + group: CommandGroup; +}) { + const status = aggregateCommandStatus(group.commands); + const preview = summarizeInlineText(group.commands[group.commands.length - 1]!.event.command, 96); + return ( + + + + Ran + {group.commands.length} command{group.commands.length === 1 ? "" : "s"} + {preview} +
+ } + > +
+ {group.commands.map((entry) => ( + + ))} +
+ + ); +} + +function FileChangeGroupCard({ + group, +}: { + group: FileChangeGroup; +}) { + const status = aggregateFileChangeStatus(group.fileChanges); + const preview = summarizeInlineText( + basenamePathLabel(group.fileChanges[group.fileChanges.length - 1]!.event.path), + 96, + ); + const totals = group.fileChanges.reduce((acc, entry) => { + const stats = summarizeDiffStats(entry.event.diff); + return { + additions: acc.additions + stats.additions, + deletions: acc.deletions + stats.deletions, + }; + }, { additions: 0, deletions: 0 }); + return ( + + + + Changed + {group.fileChanges.length} file{group.fileChanges.length === 1 ? "" : "s"} + {totals.additions > 0 ? +{totals.additions} : null} + {totals.deletions > 0 ? -{totals.deletions} : null} + {preview} +
+ } + > +
+ {group.fileChanges.map((entry) => ( + + ))} +
+ + ); +} + function renderEvent( envelope: RenderEnvelope, options?: { @@ -935,6 +1479,7 @@ function renderEvent( surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; turnActive?: boolean; + onOpenWorkspacePath?: (path: string) => void; } ) { const event = envelope.event; @@ -970,24 +1515,25 @@ function renderEvent( /* ── Agent text ── */ if (event.type === "text") { + const assistant = resolveAssistantPresentation({ + assistantLabel: options?.assistantLabel, + turnModel: options?.turnModel, + }); return (
-
-
- - +
+
+ + {assistant.glyph} - {options?.assistantLabel ?? "Agent"} - {formatTime(envelope.timestamp)} + {assistant.label} + {formatTime(envelope.timestamp)} +
+
+
- {options?.turnModel?.label ? ( -
+
{options.turnModel.label}
) : null} @@ -998,96 +1544,12 @@ function renderEvent( /* ── Command ── */ if (event.type === "command") { - const outputTrimmed = event.output.trim(); - const isLong = outputTrimmed.split("\n").length > 12; - - let statusBadgeCls: string; - switch (event.status) { - case "completed": - statusBadgeCls = "border-emerald-500/25 bg-emerald-500/10 text-emerald-400"; - break; - case "failed": - statusBadgeCls = "border-red-500/25 bg-red-500/10 text-red-400"; - break; - default: - statusBadgeCls = "border-amber-500/25 bg-amber-500/10 text-amber-400"; - } - - const commandHeader = ( -
- - - BASH - - {event.command} - {event.durationMs != null ? {Math.max(0, event.durationMs)}ms : null} - - {event.status === "completed" ? "PASS" : event.status === "failed" ? "FAIL" : "RUN"} - {event.exitCode != null ? ` ${event.exitCode}` : ""} - -
- ); - - const commandBody = ( - <> -
- $ {event.command} -
- {outputTrimmed.length ? ( -
-            {event.output}
-          
- ) : null} - - ); - - if (isLong) { - return ( - - {commandBody} - - ); - } - - return ( -
-
{commandHeader}
- {commandBody} -
- ); + return ; } /* ── File change ── */ if (event.type === "file_change") { - const hasDiff = event.diff.trim().length > 0; - const summary = ( -
- - - EDIT - - {event.path} - {event.kind} -
- ); - - return ( - - {hasDiff ? ( - - ) : ( -
No diff payload available.
- )} -
- ); + return ; } /* ── Plan ── */ @@ -1095,18 +1557,23 @@ function renderEvent( if (hideInternalExecution) { return null; } + const completedCount = event.steps.filter((step) => step.status === "completed").length; return ( -
-
- - - - Plan -
-
+ + + + Plan updated + {completedCount}/{event.steps.length || 0} complete + {event.steps[0]?.text ? {summarizeInlineText(event.steps[0].text, 96)} : null} +
+ } + > +
{event.steps.length ? ( event.steps.map((step, index) => ( -
+
@@ -1123,9 +1590,9 @@ function renderEvent( )}
{event.explanation ? ( -
{event.explanation}
+
{event.explanation}
) : null} -
+ ); } @@ -1133,25 +1600,33 @@ function renderEvent( if (event.type === "todo_update") { const completedCount = event.items.filter((item) => item.status === "completed").length; const totalCount = event.items.length; - const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0; + const activeItem = event.items.find((item) => item.status === "in_progress") ?? null; return ( -
-
- - - - TODO - {completedCount}/{totalCount} -
-
+ + + + Task list + {completedCount}/{totalCount} complete + {activeItem?.description ? ( + + {summarizeInlineText(activeItem.description, 96)} + + ) : null} +
+ } + > +
{event.items.length ? ( event.items.map((item) => ( -
+
{item.status === "completed" ? ( ) : item.status === "in_progress" ? ( - + ) : ( )} @@ -1174,17 +1649,7 @@ function renderEvent(
No items yet.
)}
- {totalCount > 0 ? ( -
-
-
-
-
- ) : null} -
+ ); } @@ -1284,7 +1749,7 @@ function renderEvent(
- +
@@ -1294,10 +1759,11 @@ function renderEvent( /* ── Subagent Started ── */ if (event.type === "subagent_started") { return ( -
- - - Subagent: {event.description} +
+ + Spawning agent + + {event.description}
); @@ -1307,18 +1773,15 @@ function renderEvent( if (event.type === "subagent_progress") { const summaryText = summarizeInlineText(event.summary, 140); return ( - - - - Subagent running - +
+ + Agent running {summaryText ? {summaryText} : null}
} - className="border-violet-500/12" >
@@ -1331,7 +1794,7 @@ function renderEvent(
{renderSubagentUsage(event.usage)}
-
+ ); } @@ -1341,28 +1804,21 @@ function renderEvent( const defaultOpen = !isSuccess; const summaryTruncated = summarizeInlineText(event.summary, 120); return ( - - {isSuccess ? ( - - ) : ( - - )} - - Subagent {event.status} - +
+ + {isSuccess ? "Agent finished" : "Agent failed"} {summaryTruncated ? {summaryTruncated} : null}
} - className="border-violet-500/12" >
{event.summary}
{renderSubagentUsage(event.usage)}
-
+ ); } @@ -1406,24 +1862,21 @@ function renderEvent( } const summaryText = event.summary; const toolCount = event.toolUseIds.length; - const isLong = summaryText.length > 120; return ( - - - - Tool Summary - +
+ + + Tool summary + {toolCount} tool{toolCount === 1 ? "" : "s"} {summarizeInlineText(summaryText, 100)} - {toolCount} tool{toolCount === 1 ? "" : "s"}
} - className="border-transparent" >
{summaryText}
-
+ ); } @@ -1692,7 +2145,14 @@ function renderEvent( return (
- + {isAskUser ? ( +