diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md new file mode 100644 index 00000000..0fecef62 --- /dev/null +++ b/apps/desktop/AGENTS.md @@ -0,0 +1,6 @@ +# AGENTS.md + +## Renderer Iconography + +- Keep desktop renderer icons visually consistent with the left sidebar. For inline controls, menu rows, composer chips, and popover rows, prefer Lucide icons at `size-3.5` with `stroke-[1.5]` unless the surrounding sidebar/navigation pattern clearly uses a different established size. +- When an icon changes state on hover, keep the icon slot dimensions stable and swap the glyph in place instead of adding a new icon that shifts adjacent text. diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-load.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-load.test.ts index 95b78181..e68f28ba 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-load.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-session-load.test.ts @@ -281,6 +281,54 @@ describe("ACP desktop SDK session cwd discovery", () => { ]) }) + test("preserves replayed research artifact metadata on text parts", async () => { + const transport = new FakeTransport((method, _params, _directory, tx) => { + if (method === "initialize") return initializeResult + if (method === "session/list") return { sessions: [storedSession] } + if (method === "session/load") { + tx?.emitSessionUpdate({ + sessionId: "stored-session", + update: { + sessionUpdate: "user_message_chunk", + messageId: "history-0", + content: { type: "text", text: "research topic" }, + _meta: { "devo/historyIndex": 0 }, + }, + } satisfies AcpSessionNotification) + tx?.emitSessionUpdate({ + sessionId: "stored-session", + update: { + sessionUpdate: "agent_message_chunk", + messageId: "history-1", + content: { type: "text", text: "brief body" }, + _meta: { + "devo/historyIndex": 1, + "devo/parentMessageId": "history-0", + "devo/itemKind": "research_artifact", + "devo/researchArtifactType": "brief", + "devo/researchArtifactTitle": "Research Brief", + }, + }, + } satisfies AcpSessionNotification) + return {} + } + throw new Error(`unexpected request ${method}`) + }) + const client = createDevoClient({ transport }) + + const result = await client.session.messages({ sessionID: "stored-session" }) + + expect(result.data[1]?.parts[0]).toMatchObject({ + type: "text", + text: "brief body", + metadata: { + "devo/itemKind": "research_artifact", + "devo/researchArtifactType": "brief", + "devo/researchArtifactTitle": "Research Brief", + }, + }) + }) + test("keeps locally limited cached history windows on turn boundaries", async () => { const transport = new FakeTransport((method, _params, _directory, tx) => { if (method === "initialize") return initializeResult diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index db31f49f..3dfd6325 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -38,6 +38,16 @@ import type { ProviderVendorListResult, ProviderVendorUpsertParams, ProviderVendorUpsertResult, + GoalClearResult, + GoalSetResult, + GoalSetStatusResult, + GoalStatusResult, + InputItem, + ThreadGoalStatus, + TurnQueueRemoveResult, + TurnQueueSteerResult, + TurnStartResult, + TurnSteerResult, RequestUserInputRespondParams, WorkspaceChangeCoverage, WorkspaceChangeScope, @@ -304,6 +314,65 @@ function sessionStatusChangedFromOriginalEvent( return typeof sessionId === "string" && typeof status === "string" ? { sessionId, status } : null } +function sessionCompactionFromOriginalEvent( + original: unknown, + originalMethod?: string, +): { sessionId: string; status: "started" | "completed" | "failed"; message?: string } | null { + const event = objectRecord(original) + if (!event) return null + + let status: "started" | "completed" | "failed" | null = null + let payload: Record | undefined + if (originalMethod === "session/compaction/started") { + status = "started" + payload = objectRecord(event.SessionCompactionStarted) ?? event + } else if (originalMethod === "session/compaction/completed") { + status = "completed" + payload = objectRecord(event.SessionCompactionCompleted) ?? event + } else if (originalMethod === "session/compaction/failed") { + status = "failed" + payload = objectRecord(event.SessionCompactionFailed) ?? event + } else { + const candidates: Array< + ["started" | "completed" | "failed", Record | undefined] + > = [ + ["started", objectRecord(event.SessionCompactionStarted)], + ["started", objectRecord(event.session_compaction_started)], + ["started", objectRecord(event.sessionCompactionStarted)], + ["completed", objectRecord(event.SessionCompactionCompleted)], + ["completed", objectRecord(event.session_compaction_completed)], + ["completed", objectRecord(event.sessionCompactionCompleted)], + ["failed", objectRecord(event.SessionCompactionFailed)], + ["failed", objectRecord(event.session_compaction_failed)], + ["failed", objectRecord(event.sessionCompactionFailed)], + ] + const found = candidates.find(([, value]) => value) + if (found) { + status = found[0] + payload = found[1] + } else if (event.kind === "session_compaction_started") { + status = "started" + payload = event + } else if (event.kind === "session_compaction_completed") { + status = "completed" + payload = event + } else if (event.kind === "session_compaction_failed") { + status = "failed" + payload = event + } + } + + if (!status || !payload) return null + const sessionId = payload.session_id ?? payload.sessionId + if (typeof sessionId !== "string" || !sessionId) return null + const message = payload.message + return { + sessionId, + status, + ...(typeof message === "string" && message ? { message } : {}), + } +} + function workspaceChangesUpdatedEventProperties( payload: WorkspaceChangesUpdatedPayload, ): WorkspaceChangesUpdatedEventProperties { @@ -337,6 +406,58 @@ const DEVO_ACTIVITY_AT_META = "devo/activityAt" const DEVO_HISTORY_INDEX_META = "devo/historyIndex" const DEVO_PARENT_MESSAGE_ID_META = "devo/parentMessageId" const DEVO_TURN_DURATION_MS_META = "devo/turnDurationMs" +const DEVO_ITEM_KIND_META = "devo/itemKind" +const DEVO_RESEARCH_ARTIFACT_TYPE_META = "devo/researchArtifactType" +const DEVO_RESEARCH_ARTIFACT_TITLE_META = "devo/researchArtifactTitle" + +type PromptPartInput = { + type: string + text?: string + url?: string + filename?: string + mime?: string + mediaType?: string +} + +function pathFromFileUri(uri: string): string | null { + if (!uri.startsWith("file://")) return null + try { + const url = new URL(uri) + let path = decodeURIComponent(url.pathname) + if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1) + return path.replace(/\//g, "\\") + } catch { + return uri.slice("file://".length) + } +} + +function inputItemsFromPromptParts(parts: PromptPartInput[]): InputItem[] { + const input: InputItem[] = [] + const text = parts + .map((part) => (part.type === "text" ? (part.text ?? "") : "")) + .join("\n") + .trim() + if (text || parts.every((part) => part.type !== "file")) { + input.push({ type: "text", text }) + } + for (const part of parts) { + if (part.type !== "file" || !part.url) continue + const path = pathFromFileUri(part.url) + if (path) { + input.push({ + type: "mention", + path, + name: part.filename ?? path.split(/[\\/]/).pop() ?? path, + }) + continue + } + input.push({ + type: "text", + text: `Resource ${part.filename ?? part.url}: ${part.url}`, + }) + } + return input +} function normalizedHistoryLimit(limit: unknown): number | undefined { if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) return undefined @@ -365,6 +486,27 @@ function updateMetaString(update: Record, key: string): string return typeof value === "string" && value ? value : undefined } +function textPartMetadataFromUpdate( + update: Record, + existingPart?: Record, +): Record | undefined { + const existing = objectRecord(existingPart?.metadata) + const metadata = existing ? { ...existing } : {} + const meta = updateMeta(update) + if (meta?.[DEVO_ITEM_KIND_META] === "research_artifact") { + metadata[DEVO_ITEM_KIND_META] = "research_artifact" + const artifactType = meta[DEVO_RESEARCH_ARTIFACT_TYPE_META] + if (typeof artifactType === "string" && artifactType) { + metadata[DEVO_RESEARCH_ARTIFACT_TYPE_META] = artifactType + } + const title = meta[DEVO_RESEARCH_ARTIFACT_TITLE_META] + if (typeof title === "string" && title) { + metadata[DEVO_RESEARCH_ARTIFACT_TITLE_META] = title + } + } + return Object.keys(metadata).length > 0 ? metadata : undefined +} + function updateHistoryCreatedAt(update: Record): number | undefined { const value = updateMeta(update)?.[DEVO_HISTORY_INDEX_META] const index = @@ -437,7 +579,7 @@ class AcpClient { create: async (_params?: { title?: string }) => ({ data: await this.createSession() }), promptAsync: async (params: { sessionID: string - parts: Array<{ type: string; text?: string; url?: string; filename?: string; mime?: string; mediaType?: string }> + parts: PromptPartInput[] model?: unknown agent?: string variant?: string @@ -556,6 +698,63 @@ class AcpClient { }), } + turn = { + // User requirement: busy composer follow-ups can be queued first, then converted + // to steer from the composer status stack without creating transcript-only state. + start: async (params: { + sessionID: string + parts: PromptPartInput[] + model?: unknown + variant?: string + cwd?: string | null + }) => { + const model = params.model as { modelID?: string } | undefined + if (model?.modelID) await this.setSessionConfigOption(params.sessionID, "model", model.modelID) + if (params.variant) await this.setSessionConfigOption(params.sessionID, "thought_level", params.variant) + const result = (await this.request("turn/start", { + session_id: params.sessionID, + input: inputItemsFromPromptParts(params.parts), + model: model?.modelID ?? null, + sandbox: null, + approval_policy: null, + cwd: params.cwd ?? null, + collaboration_mode: "build", + })) as TurnStartResult + return { data: result } + }, + steer: async (params: { + sessionID: string + expectedTurnID: string + parts: PromptPartInput[] + }) => { + const result = (await this.request("turn/steer", { + session_id: params.sessionID, + expected_turn_id: params.expectedTurnID, + input: inputItemsFromPromptParts(params.parts), + })) as TurnSteerResult + return { data: result } + }, + removeQueued: async (params: { sessionID: string; queuedInputID: string }) => { + const result = (await this.request("turn/queue/remove", { + session_id: params.sessionID, + queued_input_id: params.queuedInputID, + })) as TurnQueueRemoveResult + return { data: result } + }, + steerQueued: async (params: { + sessionID: string + expectedTurnID: string + queuedInputID: string + }) => { + const result = (await this.request("turn/queue/steer", { + session_id: params.sessionID, + expected_turn_id: params.expectedTurnID, + queued_input_id: params.queuedInputID, + })) as TurnQueueSteerResult + return { data: result } + }, + } + permission = { respond: async (params: { sessionID: string @@ -627,6 +826,51 @@ class AcpClient { list: async () => ({ data: [{ name: "compact", description: "Compact the session" }] }), } + // User requirement: Desktop's composer status area needs direct goal state + // controls, while the existing /goal trigger remains available for entry. + goal = { + status: async (params: { sessionID: string }) => { + const result = (await this.request("goal/status", { + sessionId: params.sessionID, + })) as GoalStatusResult + return { data: result.goal } + }, + set: async (params: { + sessionID: string + objective?: string + status?: ThreadGoalStatus + tokenBudget?: number | null + }) => { + const result = (await this.request("goal/set", { + sessionId: params.sessionID, + ...(params.objective !== undefined ? { objective: params.objective } : {}), + ...(params.status !== undefined ? { status: params.status } : {}), + ...(params.tokenBudget !== undefined ? { tokenBudget: params.tokenBudget } : {}), + })) as GoalSetResult + return { data: result.goal } + }, + pause: async (params: { sessionID: string }) => { + const result = (await this.request("goal/pause", { + sessionId: params.sessionID, + status: "paused", + })) as GoalSetStatusResult + return { data: result.goal } + }, + resume: async (params: { sessionID: string }) => { + const result = (await this.request("goal/resume", { + sessionId: params.sessionID, + status: "active", + })) as GoalSetStatusResult + return { data: result.goal } + }, + clear: async (params: { sessionID: string }) => { + const result = (await this.request("goal/clear", { + sessionId: params.sessionID, + })) as GoalClearResult + return { data: result } + }, + } + find = { files: async (_params: { query: string }) => ({ data: [] }), } @@ -1218,6 +1462,17 @@ class AcpClient { this.rememberSessionStatus(changedStatus.sessionId, directory, changedStatus.status) return } + const compaction = sessionCompactionFromOriginalEvent(original, originalMethod) + if (compaction) { + this.emit(directory, { + type: `session.compaction.${compaction.status}`, + properties: { + sessionID: compaction.sessionId, + ...(compaction.message ? { message: compaction.message } : {}), + }, + }) + return + } if ("RequestUserInput" in original) { const payload = (original as { RequestUserInput: Record }).RequestUserInput this.handleRequestUserInput(sessionId, directory, payload) @@ -1496,12 +1751,14 @@ class AcpClient { ? "" : existingPart[field] const partEventTime = updateHistoryCreatedAt(update) ?? now + const metadata = textPartMetadataFromUpdate(update, existingPart) const part = { id: partId, sessionID: sessionId, messageID: messageId, type: partType, [field]: `${existingText}${text}`, + ...(metadata ? { metadata } : {}), time: partTime(existingPart, partEventTime), } as TextPart | ReasoningPart this.appendPart(sessionId, messageId, part) diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index 0fae748a..96a29326 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -274,6 +274,43 @@ export type MessageResponseProps = ComponentProps const streamdownPlugins = { cjk, code, math, mermaid } +// Product requirement: regular transcript Markdown tables should keep copy and +// download controls, but not show a fullscreen control. +const transcriptMarkdownControls: NonNullable = { + table: { + fullscreen: false, + }, +} + +type TranscriptMarkdownHeadingProps = ComponentProps<"h1"> & { node?: unknown } + +function TranscriptMarkdownHeading({ + className, + node: _node, + ...props +}: TranscriptMarkdownHeadingProps) { + return ( +

+ ) +} + +// Product requirement: transcript Markdown headings should look like bold body text, +// not oversized section titles or headings with divider rules. +const transcriptMarkdownComponents: NonNullable = { + h1: TranscriptMarkdownHeading, + h2: TranscriptMarkdownHeading, + h3: TranscriptMarkdownHeading, + h4: TranscriptMarkdownHeading, + h5: TranscriptMarkdownHeading, + h6: TranscriptMarkdownHeading, +} + export const MessageResponse = memo( ({ className, ...props }: MessageResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0", className, )} + components={transcriptMarkdownComponents} + controls={transcriptMarkdownControls} plugins={streamdownPlugins} {...props} /> diff --git a/apps/desktop/packages/ui/src/styles/globals.css b/apps/desktop/packages/ui/src/styles/globals.css index eaf610a1..65417b9f 100644 --- a/apps/desktop/packages/ui/src/styles/globals.css +++ b/apps/desktop/packages/ui/src/styles/globals.css @@ -3,6 +3,11 @@ @import "shadcn/tailwind.css"; @source "../components"; +@source "../../../../node_modules/streamdown/dist/*.js"; +@source "../../../../node_modules/@streamdown/code/dist/*.js"; +@source "../../../../node_modules/@streamdown/cjk/dist/*.js"; +@source "../../../../node_modules/@streamdown/math/dist/*.js"; +@source "../../../../node_modules/@streamdown/mermaid/dist/*.js"; @custom-variant dark (&:is(.dark *)); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index b358364a..6dac6e6f 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -6,7 +6,7 @@ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, nativeTheme, se import { initAutomations, shutdownAutomations } from "./automation" import { initCredentialStore } from "./credential-store" import { getOpaqueWindowsPref, registerIpcHandlers } from "./ipc-handlers" -import { installLiquidGlass, resolveWindowChrome } from "./liquid-glass" +import { installLiquidGlass, resolveStartupWindowBackground, resolveWindowChrome } from "./liquid-glass" import { createLogger } from "./logger" import { stopServer } from "./devo-manager" import { getSessionStates } from "./notification-watcher" @@ -227,10 +227,12 @@ async function createWindow(): Promise { // Resolve window chrome tier: liquid glass > vibrancy > Windows transparency > opaque const isOpaque = getOpaqueWindowsPref() const colorScheme = getSettings().appearance.colorScheme + const isDarkMode = colorScheme === "dark" || (colorScheme === "system" && nativeTheme.shouldUseDarkColors) const chrome = await resolveWindowChrome({ isOpaque, - isDarkMode: colorScheme === "dark" || (colorScheme === "system" && nativeTheme.shouldUseDarkColors), + isDarkMode, }) + const startupWindowBackground = resolveStartupWindowBackground(isDarkMode) // Resolve the window icon for Linux/Windows. macOS uses the .app bundle icon. // Linux: use 256x256 icon — GTK's GdkPixbuf can choke on the full 1024x1024 @@ -253,7 +255,9 @@ async function createWindow(): Promise { autoHideMenuBar: process.platform === "win32", // Transparent background for macOS glass/vibrancy tiers. Windows acrylic // keeps a non-transparent BrowserWindow so native resize/maximize work. - backgroundColor: chrome.usesTransparentBackground ? "#00000000" : "#000000", + // Product requirement: the Windows startup titlebar should match the + // splash/opening page background instead of flashing as a separate black strip. + backgroundColor: chrome.usesTransparentBackground ? "#00000000" : startupWindowBackground, // Don't show the window until the renderer has painted its first frame. // Prevents a flash of transparent/empty content, especially on Wayland. show: false, diff --git a/apps/desktop/src/main/liquid-glass.test.ts b/apps/desktop/src/main/liquid-glass.test.ts index 7504ea5e..ee9b9ac1 100644 --- a/apps/desktop/src/main/liquid-glass.test.ts +++ b/apps/desktop/src/main/liquid-glass.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { getResolvedChromeTier, + resolveStartupWindowBackground, resolveTitleBarOverlay, resolveWindowChrome, } from "./liquid-glass" @@ -104,4 +105,14 @@ describe("resolveWindowChrome", () => { height: 40, }) }) + + test("matches the native startup background to the splash theme", () => { + expect({ + dark: resolveStartupWindowBackground(true), + light: resolveStartupWindowBackground(false), + }).toEqual({ + dark: "#181818", + light: "#ffffff", + }) + }) }) diff --git a/apps/desktop/src/main/liquid-glass.ts b/apps/desktop/src/main/liquid-glass.ts index ff05d07d..302f295e 100644 --- a/apps/desktop/src/main/liquid-glass.ts +++ b/apps/desktop/src/main/liquid-glass.ts @@ -37,6 +37,8 @@ const TITLE_BAR_OVERLAY_HEIGHT = 40 const TITLE_BAR_OVERLAY_COLOR = "#00000000" const TITLE_BAR_OVERLAY_DARK_SYMBOL_COLOR = "#111111" const TITLE_BAR_OVERLAY_LIGHT_SYMBOL_COLOR = "#f4f4f5" +const STARTUP_WINDOW_DARK_BACKGROUND = "#181818" +const STARTUP_WINDOW_LIGHT_BACKGROUND = "#ffffff" export function resolveTitleBarOverlay(isDarkMode: boolean): TitleBarOverlay { return { @@ -48,6 +50,10 @@ export function resolveTitleBarOverlay(isDarkMode: boolean): TitleBarOverlay { } } +export function resolveStartupWindowBackground(isDarkMode: boolean): string { + return isDarkMode ? STARTUP_WINDOW_DARK_BACKGROUND : STARTUP_WINDOW_LIGHT_BACKGROUND +} + // ============================================================ // Liquid glass support detection (cached singleton) // ============================================================ diff --git a/apps/desktop/src/renderer/atoms/actions/event-processor.ts b/apps/desktop/src/renderer/atoms/actions/event-processor.ts index fa60261e..eb5ea86a 100644 --- a/apps/desktop/src/renderer/atoms/actions/event-processor.ts +++ b/apps/desktop/src/renderer/atoms/actions/event-processor.ts @@ -1,6 +1,7 @@ import { createLogger } from "../../lib/logger" import { queryClient } from "../../lib/query-client" import type { Event } from "../../lib/types" +import { compactionStatusFamily } from "../compaction" import { serverConnectedAtom } from "../connection" import { discoveryAtom } from "../discovery" import { removeMessageAtom, upsertMessageAtom } from "../messages" @@ -126,6 +127,33 @@ export function processEvent(event: Event): void { break } + case "session.compaction.started": + case "session/compaction/started": { + const sessionID = event.properties.sessionID ?? event.properties.session_id + if (sessionID) { + set(compactionStatusFamily(sessionID), "started") + } + break + } + + case "session.compaction.completed": + case "session/compaction/completed": { + const sessionID = event.properties.sessionID ?? event.properties.session_id + if (sessionID) { + set(compactionStatusFamily(sessionID), "completed") + } + break + } + + case "session.compaction.failed": + case "session/compaction/failed": { + const sessionID = event.properties.sessionID ?? event.properties.session_id + if (sessionID) { + set(compactionStatusFamily(sessionID), null) + } + break + } + case "permission.asked": set(addPermissionAtom, { sessionId: event.properties.sessionID, diff --git a/apps/desktop/src/renderer/atoms/compaction.ts b/apps/desktop/src/renderer/atoms/compaction.ts new file mode 100644 index 00000000..1c1c6c4a --- /dev/null +++ b/apps/desktop/src/renderer/atoms/compaction.ts @@ -0,0 +1,8 @@ +import { atom } from "jotai" +import { atomFamily } from "jotai-family" + +export type SessionCompactionStatus = "started" | "completed" + +export const compactionStatusFamily = atomFamily((_sessionId: string) => + atom(null), +) diff --git a/apps/desktop/src/renderer/atoms/derived/session-chat.ts b/apps/desktop/src/renderer/atoms/derived/session-chat.ts index d5e1c386..6192433b 100644 --- a/apps/desktop/src/renderer/atoms/derived/session-chat.ts +++ b/apps/desktop/src/renderer/atoms/derived/session-chat.ts @@ -1,6 +1,9 @@ import type { Message, Part } from "../../lib/types" import { getStreamingPartsForSession } from "../streaming" +const DEVO_ITEM_KIND_META = "devo/itemKind" +const DEVO_RESEARCH_ARTIFACT_TITLE_META = "devo/researchArtifactTitle" + // ============================================================ // Types — wrappers around SDK Message + Part // ============================================================ @@ -29,9 +32,18 @@ function messageFingerprint(entry: ChatMessageEntry): string { const completed = entry.info.role === "assistant" ? (entry.info.time.completed ?? 0) : 0 let textLen = 0 const toolSegments: string[] = [] + const textMetadataSegments: string[] = [] for (const part of entry.parts) { if (part.type === "text" || part.type === "reasoning") { textLen += part.text.length + if (part.type === "text") { + const metadata = (part as { metadata?: Record }).metadata + if (metadata?.[DEVO_ITEM_KIND_META] === "research_artifact") { + textMetadataSegments.push( + `${part.id}:${metadata[DEVO_ITEM_KIND_META]}:${metadata[DEVO_RESEARCH_ARTIFACT_TITLE_META] ?? ""}`, + ) + } + } } else if (part.type === "tool") { const outLen = part.state.status === "completed" @@ -42,7 +54,7 @@ function messageFingerprint(entry: ChatMessageEntry): string { toolSegments.push(`${part.id}:${part.state.status}:${outLen}`) } } - return `${entry.info.id}:${completed}:${entry.parts.length}:${lastPart?.id ?? ""}:${textLen}:${toolSegments.join(",")}` + return `${entry.info.id}:${completed}:${entry.parts.length}:${lastPart?.id ?? ""}:${textLen}:${textMetadataSegments.join(",")}:${toolSegments.join(",")}` } function turnFingerprint(turn: ChatTurn): string { diff --git a/apps/desktop/src/renderer/components/chat/chat-input.tsx b/apps/desktop/src/renderer/components/chat/chat-input.tsx index edd28b20..054d3284 100644 --- a/apps/desktop/src/renderer/components/chat/chat-input.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-input.tsx @@ -48,7 +48,6 @@ interface ChatInputProps { providers?: ProvidersData | null config?: ConfigData | null devoAgents?: SdkAgent[] - onSkillsOpen: () => void onScrollToBottom: (behavior?: "instant" | "smooth") => void handleSlashCommand: (text: string) => Promise } @@ -162,7 +161,6 @@ export function ChatInput({ providers, config, devoAgents, - onSkillsOpen, onScrollToBottom, handleSlashCommand, }: ChatInputProps) { @@ -317,7 +315,6 @@ export function ChatInput({ query={slashQuery} open={slashOpen} enabled={isConnected} - directory={agent.directory} onSelect={(cmd) => { setSlashOpen(false) // Use the command string directly instead of setText + setTimeout @@ -337,7 +334,6 @@ export function ChatInput({ slashCommandRef.current?.setText(cmd) } }} - onSkillsOpen={onSkillsOpen} onClose={() => setSlashOpen(false)} /> ]*)>/)?.[1] ?? "" + source.match(/\{responseText && \([\s\S]*?]*)>/)?.[1] ?? + ""; const footerMetadataSource = - source.match(/\{\/\* Per-turn metadata[\s\S]*?\{\/\* Turn-level message actions/)?.[0] ?? "" -const stepToggleDurationCount = source.match(/\{duration && `· \$\{duration\} `\}/g)?.length ?? 0 -const stepsToggleIndex = source.indexOf("completedProcessExpanded && stepsToggle") -const defaultModeIndex = source.indexOf("Default mode: interleaved text + grouped tool summaries") -const verboseModeIndex = source.indexOf("Verbose mode: full tool cards") + source.match( + /\{\/\* Per-turn metadata[\s\S]*?\{\/\* Turn-level message actions/, + )?.[0] ?? ""; +const stepToggleDurationCount = + source.match(/\{duration && `· \$\{duration\} `\}/g)?.length ?? 0; +const stepsToggleIndex = source.indexOf( + "completedProcessExpanded && stepsToggle", +); +const defaultModeIndex = source.indexOf( + "Default mode: interleaved text + grouped tool summaries", +); +const verboseModeIndex = source.indexOf("Verbose mode: full tool cards"); describe("ChatTurnComponent transcript controls", () => { - test("keeps completed process collapsed, suppresses zero-second footer, and shows actions", () => { - expect({ - collapsesCompletedProcess: source.includes("const processSectionVisible =") && - source.includes("completedProcessExpanded"), - suppressesSubSecondDuration: source.includes( - 'workTimeMs >= 1000 ? formatWorkDuration(workTimeMs) : ""', - ), - usesActiveTurnDuration: source.includes("computeTurnWorkTime(turn, { active: working })"), - stepsToggleBeforeDefaultStream: - stepsToggleIndex !== -1 && defaultModeIndex !== -1 && stepsToggleIndex < defaultModeIndex, - expandedStepsRenderBeforeFinalText: - source.includes('const stepParts = processOrderedParts.filter((part) => part.kind !== "text")') && - source.includes('const textParts = processOrderedParts.filter((part) => part.kind === "text")') && - source.includes("{verboseOrderedParts.map((item) => {") && - verboseModeIndex !== -1 && - source.indexOf("{verboseOrderedParts.map((item) => {") > verboseModeIndex, - stepsShareOneToggle: - source.match(/const stepsToggle =/g)?.length === 1 && - !source.includes("Toggle to verbose view") && - !source.includes("Collapse back to default view"), - footerConditionOmitsDuration: footerMetadataSource.includes( - "turn.assistantMessages.length > 0 && (turnModel || turnCostStr)", - ), - footerDoesNotRenderDuration: !footerMetadataSource.includes( - "{duration && {duration}}", - ), - stepsKeepDuration: stepToggleDurationCount === 1, - usesAlwaysVisibleActions: responseActionsProps.trim() === "", - usesHoverHiddenActions: - responseActionsProps.includes("opacity-0") || - responseActionsProps.includes("group-hover/turn:opacity-100"), - }).toEqual({ - collapsesCompletedProcess: true, - suppressesSubSecondDuration: true, - usesActiveTurnDuration: true, - stepsToggleBeforeDefaultStream: true, - expandedStepsRenderBeforeFinalText: true, - stepsShareOneToggle: true, - footerConditionOmitsDuration: true, - footerDoesNotRenderDuration: true, - stepsKeepDuration: true, - usesAlwaysVisibleActions: true, - usesHoverHiddenActions: false, - }) - }) + test("keeps completed process collapsed, suppresses zero-second footer, and shows actions", () => { + expect({ + collapsesCompletedProcess: + source.includes("const processSectionVisible =") && + source.includes("completedProcessExpanded"), + suppressesSubSecondDuration: source.includes( + 'workTimeMs >= 1000 ? formatWorkDuration(workTimeMs) : ""', + ), + usesActiveTurnDuration: source.includes( + "computeTurnWorkTime(turn, { active: working })", + ), + stepsToggleBeforeDefaultStream: + stepsToggleIndex !== -1 && + defaultModeIndex !== -1 && + stepsToggleIndex < defaultModeIndex, + expandedStepsRenderBeforeFinalText: + source.includes( + 'const stepParts = processOrderedParts.filter((part) => part.kind !== "text")', + ) && + source.includes( + 'const textParts = processOrderedParts.filter((part) => part.kind === "text")', + ) && + source.includes("{verboseOrderedParts.map((item) => {") && + verboseModeIndex !== -1 && + source.indexOf("{verboseOrderedParts.map((item) => {") > + verboseModeIndex, + stepsShareOneToggle: + source.match(/const stepsToggle =/g)?.length === 1 && + !source.includes("Toggle to verbose view") && + !source.includes("Collapse back to default view"), + footerConditionOmitsDuration: footerMetadataSource.includes( + "turn.assistantMessages.length > 0 && (turnModel || turnCostStr)", + ), + footerDoesNotRenderDuration: !footerMetadataSource.includes( + "{duration && {duration}}", + ), + stepsKeepDuration: stepToggleDurationCount === 1, + usesAlwaysVisibleActions: responseActionsProps.trim() === "", + usesHoverHiddenActions: + responseActionsProps.includes("opacity-0") || + responseActionsProps.includes("group-hover/turn:opacity-100"), + }).toEqual({ + collapsesCompletedProcess: true, + suppressesSubSecondDuration: true, + usesActiveTurnDuration: true, + stepsToggleBeforeDefaultStream: true, + expandedStepsRenderBeforeFinalText: true, + stepsShareOneToggle: true, + footerConditionOmitsDuration: true, + footerDoesNotRenderDuration: true, + stepsKeepDuration: true, + usesAlwaysVisibleActions: true, + usesHoverHiddenActions: false, + }); + }); - test("wires pending permission requests into the active chat turn", () => { - expect({ - chatTurnAcceptsPendingPermission: source.includes("pendingPermission?: PendingPermission"), - chatTurnRendersPermissionItem: - source.includes(" { + expect({ + chatTurnAcceptsPendingPermission: source.includes( + "pendingPermission?: PendingPermission", + ), + chatTurnRendersPermissionItem: + source.includes(" { - const userMessageIndex = source.indexOf("{/* User message */}") - const workingStripIndex = source.indexOf(" { + const userMessageIndex = source.indexOf("{/* User message */}"); + const workingStripIndex = source.indexOf(" { - const disclosureIndex = source.indexOf(" { + const disclosureIndex = source.indexOf(" { - expect({ - disclosureAllowsMissingDuration: source.includes( - "if (!duration && !hasProcessDetails) return null", - ), - disclosureUsesWorkedFallback: source.includes('{duration ? "Worked for " : "Worked"}'), - renderConditionIncludesProcessDetails: source.includes( - "{!working && (duration || hasCompletedProcessDetails) && (", - ), - }).toEqual({ - disclosureAllowsMissingDuration: true, - disclosureUsesWorkedFallback: true, - renderConditionIncludesProcessDetails: true, - }) - }) + test("keeps completed process disclosure reachable when duration is unavailable", () => { + expect({ + disclosureAllowsMissingDuration: source.includes( + "if (!duration && !hasProcessDetails) return null", + ), + disclosureUsesWorkedFallback: source.includes( + '{duration ? "Worked for " : "Worked"}', + ), + renderConditionIncludesProcessDetails: source.includes( + "{!working && (duration || hasCompletedProcessDetails) && (", + ), + }).toEqual({ + disclosureAllowsMissingDuration: true, + disclosureUsesWorkedFallback: true, + renderConditionIncludesProcessDetails: true, + }); + }); - test("uses a subtle transcript-local reasoning indicator instead of the default Thought row", () => { - const chatTurnComponentSource = - source.match(/export const ChatTurnComponent = memo\([\s\S]*?\n\)/)?.[0] ?? "" + test("uses a subtle transcript-local reasoning indicator instead of the default Thought row", () => { + const chatTurnComponentSource = + source.match( + /export const ChatTurnComponent = memo\([\s\S]*?\n\)/, + )?.[0] ?? ""; - expect({ - definesTranscriptReasoningBlock: source.includes("function TranscriptReasoningBlock"), - definesTranscriptReasoningLiveCue: source.includes("function TranscriptReasoningLiveCue"), - usesLeftRailReasoningStyle: - source.includes("border-l") && - source.includes("aria-label=\"Reasoning details\""), - removesBareReasoningTrigger: !chatTurnComponentSource.includes(""), - keepsSharedReasoningTriggerUnchanged: - sharedReasoningSource.includes("export const ReasoningTrigger") && - sharedReasoningSource.includes("Thought for a few seconds"), - dropsVisibleThoughtCopyDependency: !source.includes("Thought for a few seconds"), - keepsActiveThinkingCue: source.includes("Thinking..."), - completedReasoningRendersDirectly: source.includes( - "", - ), - verboseReasoningRendersDirectly: source.includes( - "", - ), - }).toEqual({ - definesTranscriptReasoningBlock: true, - definesTranscriptReasoningLiveCue: true, - usesLeftRailReasoningStyle: true, - removesBareReasoningTrigger: true, - keepsSharedReasoningTriggerUnchanged: true, - dropsVisibleThoughtCopyDependency: true, - keepsActiveThinkingCue: true, - completedReasoningRendersDirectly: true, - verboseReasoningRendersDirectly: true, - }) - }) -}) + expect({ + definesTranscriptReasoningBlock: source.includes( + "function TranscriptReasoningBlock", + ), + definesTranscriptReasoningLiveCue: source.includes( + "function TranscriptReasoningLiveCue", + ), + usesLeftRailReasoningStyle: + source.includes("border-l") && + source.includes('aria-label="Reasoning details"'), + removesBareReasoningTrigger: !chatTurnComponentSource.includes( + "", + ), + keepsSharedReasoningTriggerUnchanged: + sharedReasoningSource.includes("export const ReasoningTrigger") && + sharedReasoningSource.includes("Thought for a few seconds"), + dropsVisibleThoughtCopyDependency: !source.includes( + "Thought for a few seconds", + ), + keepsActiveThinkingCue: source.includes("Thinking..."), + completedReasoningRendersDirectly: source.includes( + "", + ), + verboseReasoningRendersDirectly: source.includes( + "", + ), + }).toEqual({ + definesTranscriptReasoningBlock: true, + definesTranscriptReasoningLiveCue: true, + usesLeftRailReasoningStyle: true, + removesBareReasoningTrigger: true, + keepsSharedReasoningTriggerUnchanged: true, + dropsVisibleThoughtCopyDependency: true, + keepsActiveThinkingCue: true, + completedReasoningRendersDirectly: true, + verboseReasoningRendersDirectly: true, + }); + }); + + test("renders compaction lifecycle as a transcript divider", () => { + expect({ + filtersStartedTextFromAssistantResponse: + source.includes("isCompactionStatusText(part.text)") && + source.includes("continue"), + rendersDividerAfterResponse: source.includes( + "", + ), + updatesMemoWhenCompactionStatusChanges: source.includes( + "prev.compactionStatus !== next.compactionStatus", + ), + chatViewPassesSessionCompactionStatus: + chatViewSource.includes("compactionStatusFamily(agent.sessionId)") && + chatViewSource.includes("compactionStatus={compactionStatus}"), + usesRequestedIcons: + compactionDividerSource.includes("BubblesIcon") && + compactionDividerSource.includes("PackageCheckIcon"), + usesRequestedLabels: + compactionDividerSource.includes("Compacting context") && + compactionDividerSource.includes("Context compacted"), + keepsIconStyleConsistent: + compactionDividerSource.includes("size-3.5") && + compactionDividerSource.includes("stroke-[1.5]"), + handlesCompactionEvents: + eventProcessorSource.includes("session.compaction.started") && + eventProcessorSource.includes("session.compaction.completed") && + eventProcessorSource.includes("session.compaction.failed"), + bridgesRuntimeCompactionEvents: + clientSource.includes("sessionCompactionFromOriginalEvent") && + clientSource.includes("SessionCompactionCompleted") && + clientSource.includes("session.compaction.${compaction.status}"), + }).toEqual({ + filtersStartedTextFromAssistantResponse: true, + rendersDividerAfterResponse: true, + updatesMemoWhenCompactionStatusChanges: true, + chatViewPassesSessionCompactionStatus: true, + usesRequestedIcons: true, + usesRequestedLabels: true, + keepsIconStyleConsistent: true, + handlesCompactionEvents: true, + bridgesRuntimeCompactionEvents: true, + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.tsx b/apps/desktop/src/renderer/components/chat/chat-turn.tsx index d4cf65be..12b37889 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-turn.tsx @@ -22,14 +22,13 @@ import { CopyIcon, FileIcon, GitForkIcon, - ListOrderedIcon, Loader2Icon, - SendIcon, Undo2Icon, XIcon, } from "lucide-react" import { memo, type ReactNode, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react" import { useDisplayMode } from "../../hooks/use-agents" +import type { SessionCompactionStatus } from "../../atoms/compaction" import type { ChatMessageEntry, ChatTurn as ChatTurnType } from "../../hooks/use-session-chat" import { computeTurnCost, @@ -48,6 +47,10 @@ import type { ToolPart, } from "../../lib/types" import { ChatToolCall, getToolInfo, getToolSubtitle } from "./chat-tool-call" +import { + CompactionStatusDivider, + isCompactionStatusText, +} from "./compaction-status-divider" import { PermissionItem } from "./chat-permission" import { getToolCategory, type ToolCategory } from "./tool-card" @@ -55,6 +58,9 @@ import { getToolCategory, type ToolCategory } from "./tool-card" // Utility functions // ============================================================ +const DEVO_ITEM_KIND_META = "devo/itemKind" +const DEVO_RESEARCH_ARTIFACT_TITLE_META = "devo/researchArtifactTitle" + /** * Formats a timestamp (milliseconds) to relative or absolute time. */ @@ -261,7 +267,7 @@ function AttachmentThumbnail({ /** A renderable part — either a tool call, an intermediate text block, or reasoning */ type RenderablePart = | { kind: "tool"; part: ToolPart } - | { kind: "text"; id: string; text: string } + | { kind: "text"; id: string; text: string; metadata?: Record } | { kind: "reasoning"; part: ReasoningPart } type TextRenderablePart = Extract @@ -286,7 +292,9 @@ function getPartsAndTools(assistantMessages: ChatMessageEntry[]): { if (part.tool === "todoread" && part.state.status !== "completed") continue ordered.push({ kind: "tool", part }) } else if (part.type === "text" && !part.synthetic && part.text.trim()) { - ordered.push({ kind: "text", id: part.id, text: part.text }) + if (isCompactionStatusText(part.text)) continue + const metadata = (part as { metadata?: Record }).metadata + ordered.push({ kind: "text", id: part.id, text: part.text, metadata }) } else if (part.type === "reasoning") { // Strip OpenRouter's encrypted [REDACTED] chunks const cleaned = part.text.replace("[REDACTED]", "").trim() @@ -299,6 +307,12 @@ function getPartsAndTools(assistantMessages: ChatMessageEntry[]): { return { ordered, tools } } +function hasCompactionStatusMarker(assistantMessages: ChatMessageEntry[]): boolean { + return assistantMessages.some((msg) => + msg.parts.some((part) => part.type === "text" && isCompactionStatusText(part.text)), + ) +} + /** * Gets the last text part's content — used for the final streaming response * and the copy action. Returns undefined if no text parts exist. @@ -332,6 +346,39 @@ function splitCompletedTurnParts(orderedParts: RenderablePart[]): { return { completedProcessParts, finalResponsePart } } +function researchArtifactTitle(item: TextRenderablePart): string | undefined { + const metadata = item.metadata + if (metadata?.[DEVO_ITEM_KIND_META] !== "research_artifact") return undefined + const title = metadata[DEVO_RESEARCH_ARTIFACT_TITLE_META] + return typeof title === "string" && title.trim() ? title : undefined +} + +function ResearchArtifactBlock({ item }: { item: TextRenderablePart }) { + const title = researchArtifactTitle(item) + if (!title) { + return ( + + + {item.text} + + + ) + } + return ( +

+
+
+ + + {item.text} + + +
+ ) +} + function getError(assistantMessages: ChatMessageEntry[]): string | undefined { for (const msg of assistantMessages) { if (msg.info.role === "assistant" && msg.info.error) { @@ -399,9 +446,18 @@ function messageEntryFingerprint(entry: ChatMessageEntry): string { const completed = entry.info.role === "assistant" ? (entry.info.time.completed ?? 0) : 0 let textLen = 0 const toolSegments: string[] = [] + const textMetadataSegments: string[] = [] for (const part of entry.parts) { if (part.type === "text" || part.type === "reasoning") { textLen += part.text.length + if (part.type === "text") { + const metadata = (part as { metadata?: Record }).metadata + if (metadata?.[DEVO_ITEM_KIND_META] === "research_artifact") { + textMetadataSegments.push( + `${part.id}:${metadata[DEVO_ITEM_KIND_META]}:${metadata[DEVO_RESEARCH_ARTIFACT_TITLE_META] ?? ""}`, + ) + } + } } else if (part.type === "tool") { const outLen = part.state.status === "completed" @@ -412,7 +468,7 @@ function messageEntryFingerprint(entry: ChatMessageEntry): string { toolSegments.push(`${part.id}:${part.state.status}:${outLen}`) } } - return `${entry.info.id}:${completed}:${entry.parts.length}:${lastPart?.id ?? ""}:${textLen}:${toolSegments.join(",")}` + return `${entry.info.id}:${completed}:${entry.parts.length}:${lastPart?.id ?? ""}:${textLen}:${textMetadataSegments.join(",")}:${toolSegments.join(",")}` } /** Compare two turns by content fingerprint rather than reference equality */ @@ -448,7 +504,7 @@ function areTurnsEqual(a: ChatTurnType, b: ChatTurnType): boolean { * tool-group: { category: "run", tools: [bash] } */ type StreamItem = - | { kind: "text"; id: string; text: string } + | { kind: "text"; id: string; text: string; metadata?: Record } | { kind: "reasoning-process"; items: (RenderablePart & { kind: "reasoning" | "tool" })[] } | { kind: "tool-group"; category: ToolCategory; tools: ToolPart[] } @@ -488,7 +544,7 @@ function groupPartsForStream(ordered: RenderablePart[]): StreamItem[] { flushGroup() flushProcessGroup() if (part.kind === "text") { - items.push({ kind: "text", id: part.id, text: part.text }) + items.push({ kind: "text", id: part.id, text: part.text, metadata: part.metadata }) } } } @@ -654,6 +710,7 @@ interface ChatTurnProps { agent?: Agent pendingPermission?: PendingPermission isConnected?: boolean + compactionStatus?: SessionCompactionStatus | null onApprovePermission?: ( agent: Agent, permissionSessionId: string, @@ -667,8 +724,6 @@ interface ChatTurnProps { ) => Promise /** Revert to this turn's user message (for per-turn undo) */ onRevertToMessage?: (messageId: string) => Promise - /** Interrupt the current work and send this queued message immediately */ - onSendNow?: (turn: ChatTurnType) => Promise /** Fork the conversation from this turn boundary */ onForkFromTurn?: () => Promise /** Delete a specific part from a message (for error recovery) */ @@ -785,10 +840,10 @@ export const ChatTurnComponent = memo( agent, pendingPermission, isConnected = false, + compactionStatus, onApprovePermission, onDenyPermission, onRevertToMessage, - onSendNow, onForkFromTurn, onDeletePart, stepsExpanded: controlledStepsExpanded, @@ -834,6 +889,15 @@ export const ChatTurnComponent = memo( () => splitCompletedTurnParts(orderedParts), [orderedParts], ) + const hasCompactionMarker = useMemo( + () => hasCompactionStatusMarker(turn.assistantMessages), + [turn.assistantMessages], + ) + const displayedCompactionStatus: SessionCompactionStatus | null = hasCompactionMarker + ? compactionStatus === "completed" + ? "completed" + : "started" + : null // The last text for streaming display and copy action const rawResponseText = useMemo(() => getLastResponseText(orderedParts), [orderedParts]) @@ -852,8 +916,8 @@ export const ChatTurnComponent = memo( }, [turn.assistantMessages]) const working = isLast && isWorking - const isQueued = isWorking && turn.assistantMessages.length === 0 && !isLast - const isQueuedLast = isWorking && turn.assistantMessages.length === 0 && isLast + // User requirement: queue state belongs in the composer status stack; + // this transcript must not infer queued state from an empty assistant response. const processOrderedParts = working ? orderedParts : completedProcessParts const processToolParts = useMemo( () => processOrderedParts.flatMap((part) => (part.kind === "tool" ? [part.part] : [])), @@ -941,17 +1005,6 @@ export const ChatTurnComponent = memo( } }, [onForkFromTurn, forking]) - const [sendingNow, setSendingNow] = useState(false) - const handleSendNow = useCallback(async () => { - if (!onSendNow || sendingNow) return - setSendingNow(true) - try { - await onSendNow(turn) - } finally { - setSendingNow(false) - } - }, [onSendNow, sendingNow, turn]) - const handleDeleteFile = useCallback( async (file: FilePart) => { if (!onDeletePart) return @@ -1006,23 +1059,6 @@ export const ChatTurnComponent = memo( /> )}

{userText}

- {(isQueued || isQueuedLast) && ( - - - Queued - {onSendNow && ( - - )} - - )} )} @@ -1049,11 +1085,7 @@ export const ChatTurnComponent = memo( if (item.kind === "text") { return (
- - - {item.text} - - +
) } @@ -1176,11 +1208,7 @@ export const ChatTurnComponent = memo( } return (
- - - {item.text} - - +
) })} @@ -1209,11 +1237,15 @@ export const ChatTurnComponent = memo( {/* Completed final response */} {!working && finalResponsePart && responseText && ( - - - {responseText} - - + researchArtifactTitle(finalResponsePart) ? ( + + ) : ( + + + {responseText} + + + ) )} {/* Streaming response — visible while working, when text isn't already inline */} @@ -1225,6 +1257,12 @@ export const ChatTurnComponent = memo( )} + {/* User requirement: render compaction lifecycle as a transcript divider, + not as a normal assistant message that can hide the previous reply. */} + {displayedCompactionStatus && ( + + )} + {/* Per-turn metadata — shown on completed turns so badges are visible after long responses */} {!working && turn.assistantMessages.length > 0 && (turnModel || turnCostStr) && (
@@ -1274,6 +1312,7 @@ export const ChatTurnComponent = memo( if (prev.agent?.projectDirectory !== next.agent?.projectDirectory) return false if (prev.agent?.worktreePath !== next.agent?.worktreePath) return false if (prev.isConnected !== next.isConnected) return false + if (prev.compactionStatus !== next.compactionStatus) return false if (prev.stepsExpanded !== next.stepsExpanded) return false if ( pendingPermissionFingerprint(prev.pendingPermission) !== diff --git a/apps/desktop/src/renderer/components/chat/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index 345013ed..3f1590ae 100644 --- a/apps/desktop/src/renderer/components/chat/chat-view.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-view.tsx @@ -15,6 +15,7 @@ import { usePromptInputAttachments, usePromptInputController, } from "@devo/ui/components/ai-elements/prompt-input" +import { Tooltip, TooltipContent, TooltipTrigger } from "@devo/ui/components/tooltip" import { cn } from "@devo/ui/lib/utils" import { useVirtualizer } from "@tanstack/react-virtual" import { useAtomValue, useSetAtom } from "jotai" @@ -22,6 +23,8 @@ import { ArrowUpToLineIcon, ChevronUpIcon, GitForkIcon, + GoalIcon, + ListTodoIcon, Loader2Icon, PlusIcon, Redo2Icon, @@ -40,7 +43,8 @@ import { useRef, useState, } from "react" -import { messagesFamily, removeMessageAtom } from "../../atoms/messages" +import { compactionStatusFamily } from "../../atoms/compaction" +import { messagesFamily } from "../../atoms/messages" import { projectModelsAtom, setProjectModelAtom } from "../../atoms/preferences" import type { SessionSetupPhase } from "../../atoms/sessions" import { removePermissionAtom, sessionFamily } from "../../atoms/sessions" @@ -66,7 +70,7 @@ import { import type { ChatTurn } from "../../hooks/use-session-chat" import { createLogger } from "../../lib/logger" import { computeTurnWorkTimeSplit, formatWorkDuration } from "../../lib/session-metrics" -import type { Agent, FileAttachment, FilePart, QuestionAnswer, TextPart } from "../../lib/types" +import type { Agent, FileAttachment, QuestionAnswer } from "../../lib/types" import { persistRuntimeModelConfigOption, persistRuntimeModelSelection } from "../../lib/model-config-options" import { getProjectClient } from "../../services/connection-manager" @@ -83,6 +87,12 @@ import { import { PermissionItem } from "./chat-permission" import { ChatQuestionFlow } from "./chat-question" import { ChatTurnComponent } from "./chat-turn" +import { + ComposerStatusStack, + type ComposerGoal, + type ComposerGoalStatus, + type ComposerQueueItem, +} from "./composer-status-stack" import { ContextItems } from "./context-items" import type { MentionOption } from "./mention-popover" import { MentionPopover, type MentionPopoverHandle } from "./mention-popover" @@ -97,9 +107,64 @@ import { } from "./prompt-mentions" import { PromptToolbar } from "./prompt-toolbar" import { SessionTaskList } from "./session-task-list" -import { SkillPickerDialog } from "./skill-picker-dialog" import { SlashCommandPopover, type SlashCommandPopoverHandle } from "./slash-command-popover" +type ComposerTrigger = "goal" | "plan" +type ComposerGoalAction = "edit" | "pause" | "resume" | "clear" +type ComposerPromptPart = + | { type: "text"; text: string } + | { type: "file"; mime: string; filename?: string; url: string } + +function objectRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null +} + +function normalizeGoalStatus(value: unknown): ComposerGoalStatus | null { + if (value === "active" || value === "paused" || value === "complete") return value + if (value === "budgetLimited" || value === "budget_limited") return "budgetLimited" + return null +} + +function normalizeComposerGoal(value: unknown): ComposerGoal | null { + const record = objectRecord(value) + if (!record) return null + const objective = typeof record.objective === "string" ? record.objective.trim() : "" + const status = normalizeGoalStatus(record.status) + if (!objective || !status || status === "complete") return null + return { + objective, + status, + timeUsedSeconds: (record.timeUsedSeconds ?? record.time_used_seconds) as ComposerGoal["timeUsedSeconds"], + observedAtMs: Date.now(), + } +} + +function promptPartsFromTextAndFiles(text: string, files?: FileAttachment[]): ComposerPromptPart[] { + const parts: ComposerPromptPart[] = [{ type: "text", text }] + for (const file of files ?? []) { + parts.push({ + type: "file", + mime: file.mediaType ?? "application/octet-stream", + filename: file.filename, + url: file.url, + }) + } + return parts +} + +function chatTurnUserText(turn: ChatTurn): string { + return turn.userMessage.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n") + .trim() +} + +function chatTurnUserCreatedAt(turn: ChatTurn): number { + const created = turn.userMessage.info.time?.created + return typeof created === "number" && Number.isFinite(created) ? created : 0 +} + /** * Small "+" button that opens the file picker for attachments. * Must be rendered inside a so the attachments context is available. @@ -117,6 +182,52 @@ function AttachButton({ disabled }: { disabled?: boolean }) { ) } +function ComposerTriggerChip({ + trigger, + onRemove, +}: { + trigger: ComposerTrigger + onRemove: () => void +}) { + const isPlan = trigger === "plan" + const Icon = isPlan ? ListTodoIcon : GoalIcon + const label = isPlan ? "Plan" : "Goal" + const description = isPlan ? "Create a plan" : "Set a goal" + + return ( + + + } + > + + {label} + + +
{description}
+ {isPlan &&
Shift + Tab to toggle
} +
+
+ ) +} + /** * Instant-scroll when session content finishes loading. * @@ -549,6 +660,7 @@ export function ChatView({ const sessionEntry = useAtomValue(sessionFamily(agent.sessionId)) const sessionError = sessionEntry?.error const setupPhase = sessionEntry?.setupPhase + const compactionStatus = useAtomValue(compactionStatusFamily(agent.sessionId)) useLayoutEffect(() => { if (setupPhase) { @@ -651,52 +763,6 @@ export function ChatView({ [onDeny, removePermission], ) - const handleSendNow = useCallback( - async (turn: ChatTurn) => { - if (!isWorking) return - - // Extract text and files from the queued turn BEFORE aborting, because - // the abort may clean up state that we need. - const text = turn.userMessage.parts - .filter((p): p is TextPart => p.type === "text" && !p.synthetic) - .map((p) => p.text) - .join("\n") - const files: FileAttachment[] = turn.userMessage.parts - .filter((p): p is FilePart => p.type === "file") - .map((p) => ({ - type: "file" as const, - url: p.url, - mediaType: p.mime, - filename: p.filename, - })) - - if (!text.trim()) return - - // 1. Abort the currently running turn - if (onStop) { - await onStop(agent) - } - - // 2. Remove the orphaned message from the local store to prevent - // duplicates. After an abort the server discards queued prompt - // callbacks, so the user message is persisted on the server but no - // response will be generated. When we re-send below, a new user - // message + optimistic entry will be created. The server's loop - // reads full history and will respond to the newest user message, - // effectively ignoring the orphaned one in the context. - appStore.set(removeMessageAtom, { - sessionId: agent.sessionId, - messageId: turn.userMessage.info.id, - }) - - // 3. Re-send the queued message so the server actually processes it. - if (onSendMessage) { - await onSendMessage(agent, text, { files: files.length > 0 ? files : undefined }) - } - }, - [onStop, onSendMessage, isWorking, agent], - ) - // Keyboard shortcuts for undo/redo useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -756,12 +822,12 @@ export function ChatView({ agent={agent} pendingPermission={index === turns.length - 1 ? effectivePermission : undefined} isConnected={isConnected} + compactionStatus={compactionStatus} stepsExpanded={expandedStepTurnIds.has(turn.id)} onStepsExpandedChange={handleStepsExpandedChange} onApprovePermission={handleApprovePermission} onDenyPermission={handleDenyPermission} onRevertToMessage={onRevertToMessage} - onSendNow={isWorking ? handleSendNow : undefined} onForkFromTurn={ onForkFromTurn ? () => { @@ -776,10 +842,10 @@ export function ChatView({ [ agent, effectivePermission, + compactionStatus, expandedStepTurnIds, handleApprovePermission, handleDenyPermission, - handleSendNow, handleStepsExpandedChange, isConnected, isWorking, @@ -897,12 +963,10 @@ export function ChatView({ onReplyQuestion={onReplyQuestion} onRejectQuestion={onRejectQuestion} canRedo={canRedo} - onUndo={onUndo} onRedo={onRedo} isReverted={isReverted} scrollRef={scrollRef} reviewPanelOpen={reviewPanelOpen} - onForkFromTurn={onForkFromTurn} />
)} @@ -934,13 +998,10 @@ interface ChatInputSectionProps { onReplyQuestion?: ChatViewProps["onReplyQuestion"] onRejectQuestion?: ChatViewProps["onRejectQuestion"] canRedo?: boolean - onUndo?: () => Promise onRedo?: () => Promise isReverted?: boolean scrollRef: React.RefObject reviewPanelOpen?: boolean - /** Fork the current session (full fork, no cutoff) */ - onForkFromTurn?: (messageId?: string) => Promise } function ChatInputSection({ @@ -958,14 +1019,79 @@ function ChatInputSection({ onReplyQuestion, onRejectQuestion, canRedo, - onUndo, onRedo, isReverted, scrollRef, reviewPanelOpen, - onForkFromTurn, }: ChatInputSectionProps) { const [sending, setSending] = useState(false) + const [activeTrigger, setActiveTrigger] = useState(null) + const [activeGoal, setActiveGoal] = useState(null) + const [goalAction, setGoalAction] = useState(null) + const [queueItems, setQueueItems] = useState([]) + + // User requirement: the /goal footer chip is only an input trigger; + // the composer-adjacent status row reflects the real session goal state. + useEffect(() => { + setActiveTrigger(null) + setActiveGoal(null) + setGoalAction(null) + setQueueItems([]) + }, [agent.sessionId]) + + useEffect(() => { + setQueueItems((current) => + current.filter((item) => { + if (item.status !== "queued") return true + const createdAt = item.createdAtMs ?? 0 + return !turns.some( + (turn) => + chatTurnUserText(turn) === item.text && chatTurnUserCreatedAt(turn) >= createdAt - 1_000, + ) + }), + ) + }, [turns]) + + const loadGoalStatus = useCallback(async (): Promise => { + if (!agent.directory) return null + const client = getProjectClient(agent.directory) + if (!client?.goal?.status) return null + const result = await client.goal.status({ sessionID: agent.sessionId }) + return normalizeComposerGoal(result.data) + }, [agent.directory, agent.sessionId]) + + const refreshGoalStatus = useCallback(async () => { + try { + setActiveGoal(await loadGoalStatus()) + } catch (err) { + log.error("goal.status failed", { sessionId: agent.sessionId }, err) + } + }, [agent.sessionId, loadGoalStatus]) + + useEffect(() => { + let disposed = false + const load = async () => { + try { + const nextGoal = await loadGoalStatus() + if (!disposed) setActiveGoal(nextGoal) + } catch (err) { + if (!disposed) { + setActiveGoal(null) + log.error("goal.status failed", { sessionId: agent.sessionId }, err) + } + } + } + void load() + const interval = setInterval(load, 15_000) + return () => { + disposed = true + clearInterval(interval) + } + }, [agent.sessionId, loadGoalStatus]) + + useEffect(() => { + if (!isWorking) void refreshGoalStatus() + }, [isWorking, refreshGoalStatus]) // Tree-scoped interactive requests — bubbles up from sub-agent sessions. // These replace the direct `agent.permissions` / `agent.questions` arrays @@ -1181,6 +1307,167 @@ function ChatInputSection({ getText: () => string } | null>(null) + const focusComposer = useCallback(() => { + requestAnimationFrame(() => { + const textarea = document.querySelector("textarea[data-prompt-input]") + textarea?.focus() + }) + }, []) + + const handleEditGoal = useCallback(() => { + if (!activeGoal) return + setActiveTrigger("goal") + slashCommandRef.current?.setText(activeGoal.objective) + focusComposer() + }, [activeGoal, focusComposer]) + + const handlePauseGoal = useCallback(async () => { + if (!agent.directory || goalAction !== null) return + const client = getProjectClient(agent.directory) + if (!client?.goal?.pause) return + setGoalAction("pause") + try { + const result = await client.goal.pause({ sessionID: agent.sessionId }) + setActiveGoal(normalizeComposerGoal(result.data)) + } catch (err) { + log.error("goal.pause failed", { sessionId: agent.sessionId }, err) + } finally { + setGoalAction(null) + } + }, [agent.directory, agent.sessionId, goalAction]) + + const handleResumeGoal = useCallback(async () => { + if (!agent.directory || goalAction !== null) return + const client = getProjectClient(agent.directory) + if (!client?.goal?.resume) return + setGoalAction("resume") + try { + const result = await client.goal.resume({ sessionID: agent.sessionId }) + setActiveGoal(normalizeComposerGoal(result.data)) + } catch (err) { + log.error("goal.resume failed", { sessionId: agent.sessionId }, err) + } finally { + setGoalAction(null) + } + }, [agent.directory, agent.sessionId, goalAction]) + + const handleClearGoal = useCallback(async () => { + if (!agent.directory || goalAction !== null) return + const client = getProjectClient(agent.directory) + if (!client?.goal?.clear) return + setGoalAction("clear") + try { + await client.goal.clear({ sessionID: agent.sessionId }) + setActiveGoal(null) + } catch (err) { + log.error("goal.clear failed", { sessionId: agent.sessionId }, err) + } finally { + setGoalAction(null) + } + }, [agent.directory, agent.sessionId, goalAction]) + + const handleSteerQueueItem = useCallback( + async (item: ComposerQueueItem) => { + if (!agent.directory || !item.queuedInputId || !item.activeTurnId) return + const client = getProjectClient(agent.directory) + if (!client?.turn?.steerQueued) return + setQueueItems((current) => + current.map((entry) => (entry.id === item.id ? { ...entry, status: "steering" } : entry)), + ) + try { + await client.turn.steerQueued({ + sessionID: agent.sessionId, + expectedTurnID: item.activeTurnId, + queuedInputID: item.queuedInputId, + }) + setQueueItems((current) => current.filter((entry) => entry.id !== item.id)) + } catch (err) { + log.error("turn.queue.steer failed", { sessionId: agent.sessionId }, err) + setQueueItems((current) => + current.map((entry) => + entry.id === item.id + ? { ...entry, status: "error", error: err instanceof Error ? err.message : "Steer failed" } + : entry, + ), + ) + } + }, + [agent.directory, agent.sessionId], + ) + + const handleRemoveQueueItem = useCallback( + async (item: ComposerQueueItem) => { + if (!agent.directory || !item.queuedInputId) { + setQueueItems((current) => current.filter((entry) => entry.id !== item.id)) + return + } + const client = getProjectClient(agent.directory) + if (!client?.turn?.removeQueued) return + setQueueItems((current) => + current.map((entry) => (entry.id === item.id ? { ...entry, status: "removing" } : entry)), + ) + try { + await client.turn.removeQueued({ + sessionID: agent.sessionId, + queuedInputID: item.queuedInputId, + }) + setQueueItems((current) => current.filter((entry) => entry.id !== item.id)) + } catch (err) { + log.error("turn.queue.remove failed", { sessionId: agent.sessionId }, err) + setQueueItems((current) => + current.map((entry) => + entry.id === item.id + ? { ...entry, status: "error", error: err instanceof Error ? err.message : "Remove failed" } + : entry, + ), + ) + } + }, + [agent.directory, agent.sessionId], + ) + + const handleEditQueueItem = useCallback( + async (item: ComposerQueueItem) => { + if ((item.fileCount ?? 0) > 0) return + if (item.queuedInputId) { + if (!agent.directory) return + const client = getProjectClient(agent.directory) + if (!client?.turn?.removeQueued) return + setQueueItems((current) => + current.map((entry) => + entry.id === item.id ? { ...entry, status: "removing" } : entry, + ), + ) + try { + await client.turn.removeQueued({ + sessionID: agent.sessionId, + queuedInputID: item.queuedInputId, + }) + } catch (err) { + log.error("turn.queue.edit remove failed", { sessionId: agent.sessionId }, err) + setQueueItems((current) => + current.map((entry) => + entry.id === item.id + ? { + ...entry, + status: "error", + error: err instanceof Error ? err.message : "Edit failed", + } + : entry, + ), + ) + return + } + setQueueItems((current) => current.filter((entry) => entry.id !== item.id)) + } else { + setQueueItems((current) => current.filter((entry) => entry.id !== item.id)) + } + slashCommandRef.current?.setText(item.text) + focusComposer() + }, + [agent.directory, agent.sessionId, focusComposer], + ) + const handleSlashCommand = useCallback( async (text: string): Promise => { const trimmed = text.trim() @@ -1188,18 +1475,12 @@ function ChatInputSection({ const spaceIndex = trimmed.indexOf(" ") const cmdName = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex) - const cmdArgs = spaceIndex === -1 ? "" : trimmed.slice(spaceIndex + 1).trim() - // Client-only commands that don't go through the server + // Product requirement: Desktop slash commands are limited to first-party + // entries. Compact executes immediately; Goal/Plan become footer trigger + // chips; Research stays as slash text so ACP can run it after a question. switch (cmdName.toLowerCase()) { - case "undo": - if (onUndo) await onUndo() - return true - case "redo": - if (onRedo) await onRedo() - return true case "compact": - case "summarize": if (agent.directory && effectiveModel) { const client = getProjectClient(agent.directory) if (client) { @@ -1215,29 +1496,53 @@ function ChatInputSection({ } } return true + case "goal": + setActiveTrigger("goal") + return true + case "plan": + setActiveTrigger("plan") + return true + case "research": + return false default: - break + return false } + }, + [agent.directory, agent.sessionId, effectiveModel], + ) - if (agent.directory) { - const client = getProjectClient(agent.directory) - if (client) { - try { - await client.session.command({ - sessionID: agent.sessionId, - command: cmdName, - arguments: cmdArgs, - }) - return true - } catch { - // Not a recognized server command - } + const submitTriggeredPrompt = useCallback( + async (trigger: ComposerTrigger, text: string, files?: FileAttachment[]) => { + if (!agent.directory) throw new Error("No project directory for slash trigger") + const client = getProjectClient(agent.directory) + if (!client) throw new Error("Not connected to Devo server") + const parts: Array< + { type: "text"; text: string } | { + type: "file" + mime: string + filename?: string + url: string } + > = [{ type: "text", text: `/${trigger} ${text.trim()}` }] + for (const file of files ?? []) { + parts.push({ + type: "file", + mime: file.mediaType ?? "application/octet-stream", + filename: file.filename, + url: file.url, + }) } - - return false + await client.session.promptAsync({ + sessionID: agent.sessionId, + parts, + model: effectiveModel + ? { providerID: effectiveModel.providerID, modelID: effectiveModel.modelID } + : undefined, + agent: selectedAgent || undefined, + variant: selectedVariant, + }) }, - [agent, onUndo, onRedo, effectiveModel], + [agent.directory, agent.sessionId, effectiveModel, selectedAgent, selectedVariant], ) const handleSend = useCallback( @@ -1248,7 +1553,7 @@ function ChatInputSection({ sending, sessionId: agent.sessionId, }) - if (!text.trim() || !onSendMessage || sending) { + if (!text.trim() || (!onSendMessage && !activeTrigger) || sending) { log.warn("handleSend bailed", { emptyText: !text.trim(), noOnSendMessage: !onSendMessage, @@ -1257,7 +1562,7 @@ function ChatInputSection({ return } - if (text.trim().startsWith("/")) { + if (!activeTrigger && text.trim().startsWith("/")) { const handled = await handleSlashCommand(text) if (handled) { slashCommandRef.current?.setText("") @@ -1293,15 +1598,29 @@ function ChatInputSection({ const commentPrefix = serializeCommentsForChat(diffComments) const finalText = commentPrefix ? `${commentPrefix}${text.trim()}` : text.trim() - await onSendMessage(agent, finalText, { - model: effectiveModel ?? undefined, - agentName: selectedAgent || undefined, - variant: selectedVariant, - files, - }) - log.debug("handleSend onSendMessage completed", { sessionId: agent.sessionId }) + if (activeTrigger) { + const trigger = activeTrigger + await submitTriggeredPrompt(trigger, finalText, files) + log.debug("handleSend triggered prompt completed", { + sessionId: agent.sessionId, + trigger, + }) + if (trigger === "goal") { + setTimeout(() => void refreshGoalStatus(), 400) + setTimeout(() => void refreshGoalStatus(), 1_200) + } + } else { + await onSendMessage?.(agent, finalText, { + model: effectiveModel ?? undefined, + agentName: selectedAgent || undefined, + variant: selectedVariant, + files, + }) + log.debug("handleSend onSendMessage completed", { sessionId: agent.sessionId }) + } clearDraft() setMentions([]) + setActiveTrigger(null) // Clear diff comments after successful send if (diffComments.length > 0) { setDiffComments([]) @@ -1323,6 +1642,9 @@ function ChatInputSection({ selectedAgent, selectedVariant, clearDraft, + activeTrigger, + submitTriggeredPrompt, + refreshGoalStatus, handleSlashCommand, scrollRef, diffComments, @@ -1360,35 +1682,7 @@ function ChatInputSection({ const [mentionOpen, setMentionOpen] = useState(false) const [mentionQuery, setMentionQuery] = useState("") - // --- Skills picker dialog --- - const [skillsDialogOpen, setSkillsDialogOpen] = useState(false) - - const handleForkViaSlash = useCallback(async () => { - const ctrl = slashCommandRef.current - if (ctrl) ctrl.setText("") - await onForkFromTurn?.() - }, [onForkFromTurn]) - - const handleSkillsOpen = useCallback(() => { - const ctrl = slashCommandRef.current - if (ctrl) ctrl.setText("") - setSkillsDialogOpen(true) - }, []) - const handleSkillSelect = useCallback((skillName: string) => { - const ctrl = slashCommandRef.current - if (ctrl) { - ctrl.setText(`/${skillName} `) - } - requestAnimationFrame(() => { - const ta = document.querySelector("textarea[data-prompt-input]") - if (ta) { - ta.focus() - const len = `/${skillName} `.length - ta.setSelectionRange(len, len) - } - }) - }, []) const slashPopoverRef = useRef(null) const mentionPopoverRef = useRef(null) @@ -1491,6 +1785,14 @@ function ChatInputSection({ const handleTextareaKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (e.key === "Tab" && e.shiftKey) { + e.preventDefault() + handleSlashClose() + handleMentionClose() + setActiveTrigger((current) => (current === "plan" ? null : "plan")) + return + } + // Always delegate to popovers first — they guard on their own `open` prop // internally, so we don't need to check slashOpen/mentionOpen here. // This avoids stale-closure issues where the parent's boolean lags behind @@ -1502,7 +1804,7 @@ function ChatInputSection({ handleEscapeAbort() } }, - [handleEscapeAbort], + [handleEscapeAbort, handleSlashClose, handleMentionClose], ) // Width constraint class: remove max-w when review panel is open @@ -1582,10 +1884,7 @@ function ChatInputSection({ query={slashQuery} open={slashOpen} enabled={isConnected} - directory={agent.directory} onSelect={handleSlashSelect} - onSkillsOpen={handleSkillsOpen} - onFork={handleForkViaSlash} onClose={handleSlashClose} /> + + {activeTrigger && ( + setActiveTrigger(null)} + /> + )} - {/* Skills picker dialog — triggered by /skills command */} - ) } diff --git a/apps/desktop/src/renderer/components/chat/compaction-status-divider.tsx b/apps/desktop/src/renderer/components/chat/compaction-status-divider.tsx new file mode 100644 index 00000000..01cbaa55 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/compaction-status-divider.tsx @@ -0,0 +1,39 @@ +import { cn } from "@devo/ui/lib/utils"; +import { BubblesIcon, PackageCheckIcon } from "lucide-react"; +import type { SessionCompactionStatus } from "../../atoms/compaction"; + +export const COMPACTION_STARTED_TEXT = "Session compaction started."; + +export function isCompactionStatusText(text: string): boolean { + return text.trim() === COMPACTION_STARTED_TEXT; +} + +export function CompactionStatusDivider({ + status, + className, +}: { + status: SessionCompactionStatus; + className?: string; +}) { + const isCompleted = status === "completed"; + const Icon = isCompleted ? PackageCheckIcon : BubblesIcon; + const label = isCompleted ? "Context compacted" : "Compacting context"; + + return ( +
+