diff --git a/desktop/src/features/messages/lib/projectThreadMissionControl.test.mjs b/desktop/src/features/messages/lib/projectThreadMissionControl.test.mjs index c9f50dca08a..330bd8310d1 100644 --- a/desktop/src/features/messages/lib/projectThreadMissionControl.test.mjs +++ b/desktop/src/features/messages/lib/projectThreadMissionControl.test.mjs @@ -4,8 +4,10 @@ import { describe, it } from "node:test"; import { createProjectThreadPeekFeedSelector, deriveProjectThreadPhaseStates, + formatProjectThreadPeekText, mapProjectThreadPeekFeedItems, mergeProjectThreadPeekEvents, + previewProjectThreadPeekText, resolveProjectThreadPeekMode, } from "./projectThreadMissionControl.ts"; @@ -175,6 +177,35 @@ describe("project thread transcript peek", () => { assert.equal(feed[1].headline, "Run tests · pnpm test"); assert.equal(feed[1].result, "127 tests passed"); assert.equal(feed[1].failed, false); + assert.equal(feed[1].status, "done"); + }); + + it("marks executing tools as running in the peek feed", () => { + const feed = mapProjectThreadPeekFeedItems([ + { + ...tool, + id: "tool-running", + status: "executing", + result: "", + completedAt: null, + }, + ]); + + assert.equal(feed[0].kind, "tool"); + assert.equal(feed[0].status, "running"); + assert.equal(feed[0].result, null); + }); + + it("humanizes escaped one-line dumps for peek previews", () => { + const escaped = + "3-line story:\\n- assigned work\\n- current status\\n- what was omitted"; + const formatted = formatProjectThreadPeekText(escaped); + assert.ok(formatted.includes("\n")); + assert.equal(formatted.includes("\\n"), false); + + const preview = previewProjectThreadPeekText(escaped); + assert.equal(preview.truncated, true); + assert.equal(preview.preview, "3-line story:"); }); it("sources history mode from archived events scoped to the conversation", () => { diff --git a/desktop/src/features/messages/lib/projectThreadMissionControl.ts b/desktop/src/features/messages/lib/projectThreadMissionControl.ts index 57912148fbc..233e5576cff 100644 --- a/desktop/src/features/messages/lib/projectThreadMissionControl.ts +++ b/desktop/src/features/messages/lib/projectThreadMissionControl.ts @@ -91,6 +91,8 @@ export function deriveProjectThreadPhaseStates({ }; } +export type ProjectThreadPeekToolStatus = "running" | "done" | "failed"; + export type ProjectThreadPeekFeedItem = | { id: string; @@ -103,9 +105,12 @@ export type ProjectThreadPeekFeedItem = headline: string; result: string | null; failed: boolean; + status: ProjectThreadPeekToolStatus; }; const PROJECT_THREAD_PEEK_ITEM_CAP = 50; +/** Collapsed thinking / result preview before the founder expands. */ +export const PROJECT_THREAD_PEEK_PREVIEW_CHARS = 140; export function mapProjectThreadPeekFeedItems( transcript: readonly TranscriptItem[], @@ -118,17 +123,78 @@ export function mapProjectThreadPeekFeedItems( continue; } if (item.type !== "tool" || item.renderClass === "suppressed") continue; + const failed = item.isError || item.status === "failed"; feed.push({ id: item.id, kind: "tool", headline: getActivityHeadline(item) ?? item.title, result: item.result.trim() || null, - failed: item.isError || item.status === "failed", + failed, + status: peekToolStatus(item.status, failed), }); } return feed.slice(-PROJECT_THREAD_PEEK_ITEM_CAP); } +function peekToolStatus( + status: "executing" | "completed" | "failed" | "pending", + failed: boolean, +): ProjectThreadPeekToolStatus { + if (failed || status === "failed") return "failed"; + if (status === "completed") return "done"; + return "running"; +} + +/** + * Turn wire dumps into readable peek text: pretty-print JSON, then expand + * literal `\n` / `\t` when the blob is still a single escaped line. + */ +export function formatProjectThreadPeekText(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return raw; + + let text = trimmed; + try { + text = JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + // keep trimmed + } + + if (!text.includes("\n") && /\\[ntr]/.test(text)) { + text = text.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, ""); + } + + return text; +} + +/** First line / char-clamped preview for collapsed peek rows. */ +export function previewProjectThreadPeekText( + raw: string, + maxChars = PROJECT_THREAD_PEEK_PREVIEW_CHARS, +): { preview: string; truncated: boolean } { + const formatted = formatProjectThreadPeekText(raw).trim(); + if (!formatted) return { preview: "", truncated: false }; + + const firstLine = + formatted + .split("\n") + .find((line) => line.trim()) + ?.trim() ?? ""; + const hasMoreLines = + formatted.includes("\n") && formatted.trim() !== firstLine; + + if (firstLine.length <= maxChars && !hasMoreLines) { + return { preview: firstLine, truncated: false }; + } + if (firstLine.length <= maxChars) { + return { preview: firstLine, truncated: true }; + } + return { + preview: `${firstLine.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`, + truncated: true, + }; +} + export function createProjectThreadPeekFeedSelector() { let previousTranscript: readonly TranscriptItem[] | null = null; let previousFeed: ProjectThreadPeekFeedItem[] = []; @@ -161,7 +227,8 @@ function projectThreadPeekFeedItemsEqual( return ( left.headline === right.headline && left.result === right.result && - left.failed === right.failed + left.failed === right.failed && + left.status === right.status ); } return false; diff --git a/desktop/src/features/messages/ui/ProjectThreadActivityPeek.tsx b/desktop/src/features/messages/ui/ProjectThreadActivityPeek.tsx index 67bc672ab01..2436ed69db7 100644 --- a/desktop/src/features/messages/ui/ProjectThreadActivityPeek.tsx +++ b/desktop/src/features/messages/ui/ProjectThreadActivityPeek.tsx @@ -11,12 +11,23 @@ import { import { cn } from "@/shared/lib/cn"; import { createProjectThreadPeekFeedSelector, + formatProjectThreadPeekText, getProjectThreadPeekHeadline, mergeProjectThreadPeekEvents, + previewProjectThreadPeekText, resolveProjectThreadPeekMode, + type ProjectThreadPeekFeedItem, + type ProjectThreadPeekToolStatus, } from "../lib/projectThreadMissionControl"; import type { ProjectThreadWorkspaceModel } from "./useProjectThreadWorkspaceModel"; +/** + * Live / history activity strip above the thread composer. + * + * Visual language mirrors Declared Plans (#190): muted uppercase chrome, + * soft bordered cards, scannable rows — not a raw ACP dump. Tool output and + * long thoughts stay collapsed until the founder opens them. + */ export function ProjectThreadActivityPeek({ channelId, model, @@ -63,54 +74,34 @@ export function ProjectThreadActivityPeek({ return (
{expanded ? (
- {feed.map((item) => - item.kind === "thinking" ? ( -

- {item.text} -

- ) : ( -
-

- {item.headline} -

- {item.result ? ( -

- {item.result} -

- ) : null} -
- ), - )} +

+ {mode === "history" ? "Recent activity" : "Live activity"} +

+
+ {feed.map((item) => + item.kind === "thinking" ? ( + + ) : ( + + ), + )} +
) : null}
); } + +function PeekThinkingRow({ + item, +}: { + item: Extract; +}) { + const { preview, truncated } = previewProjectThreadPeekText(item.text); + const full = formatProjectThreadPeekText(item.text); + + if (!truncated) { + return ( +

+ + Thinking + + {preview} +

+ ); + } + + return ( +
+ + + Thinking + + + {preview} + + + Hide + + + +

+ {full} +

+
+ ); +} + +function PeekToolRow({ + item, +}: { + item: Extract; +}) { + const resultPreview = item.result + ? previewProjectThreadPeekText(item.result) + : null; + const fullResult = item.result + ? formatProjectThreadPeekText(item.result) + : null; + const showResultDisclosure = Boolean(fullResult && resultPreview?.truncated); + + return ( +
+
+ +

+ {item.headline} +

+ + {peekToolStatusLabel(item.status)} + +
+ {fullResult && !showResultDisclosure ? ( +

+ {resultPreview?.preview} +

+ ) : null} + {fullResult && showResultDisclosure ? ( +
+ + + + {resultPreview?.preview || "Show output"} + + Hide output + +
+            {fullResult}
+          
+
+ ) : null} +
+ ); +} + +function PeekToolStatusDot({ + status, +}: { + status: ProjectThreadPeekToolStatus; +}) { + return ( + + ); +} + +function peekToolStatusLabel(status: ProjectThreadPeekToolStatus): string { + switch (status) { + case "running": + return "Running"; + case "done": + return "Done"; + case "failed": + return "Failed"; + default: { + const exhaustive: never = status; + return exhaustive; + } + } +}