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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { describe, it } from "node:test";
import {
createProjectThreadPeekFeedSelector,
deriveProjectThreadPhaseStates,
formatProjectThreadPeekText,
mapProjectThreadPeekFeedItems,
mergeProjectThreadPeekEvents,
previewProjectThreadPeekText,
resolveProjectThreadPeekMode,
} from "./projectThreadMissionControl.ts";

Expand Down Expand Up @@ -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", () => {
Expand Down
71 changes: 69 additions & 2 deletions desktop/src/features/messages/lib/projectThreadMissionControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export function deriveProjectThreadPhaseStates({
};
}

export type ProjectThreadPeekToolStatus = "running" | "done" | "failed";

export type ProjectThreadPeekFeedItem =
| {
id: string;
Expand All @@ -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[],
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading