@cueloop/client 0.1.0-alpha.96 → 0.1.0-alpha.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +5 -5
  2. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  3. package/src/annotations/components/AnnotationCards.tsx +29 -6
  4. package/src/annotations/use-annotation-surface.tsx +245 -30
  5. package/src/app/App.thread-agent.test.ts +12 -0
  6. package/src/app/App.tsx +217 -97
  7. package/src/app/app-screens.tsx +2 -0
  8. package/src/app/components/AppShell.zoom.test.tsx +4 -3
  9. package/src/app/intent-dispatch.test.ts +1 -0
  10. package/src/diff/components/AnnotatableFileView.tsx +6 -0
  11. package/src/diff/components/DiffContentView.tsx +9 -0
  12. package/src/markdown/components/ThreadView.tsx +60 -16
  13. package/src/settings/config.test.ts +15 -0
  14. package/src/settings/config.ts +14 -4
  15. package/src/stories/__snapshots__/stories.test.tsx.snap +74 -0
  16. package/src/thread/agent-continuation.test.ts +32 -0
  17. package/src/thread/agent-continuation.ts +13 -0
  18. package/src/thread/agent-transcript.test.ts +161 -0
  19. package/src/thread/agent-transcript.ts +332 -0
  20. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  21. package/src/thread/components/AgentConfigControls.test.tsx +110 -0
  22. package/src/thread/components/AgentConfigControls.tsx +114 -0
  23. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  24. package/src/thread/components/AgentThreadPane.test.tsx +780 -0
  25. package/src/thread/components/AgentThreadPane.tsx +356 -0
  26. package/src/thread/components/ThreadFooter.tsx +3 -0
  27. package/src/thread/components/ThreadTree.test.tsx +41 -0
  28. package/src/thread/components/ThreadTree.tsx +25 -5
  29. package/src/thread/thread-controller.ts +34 -0
  30. package/src/thread/use-thread-agent.test.tsx +65 -0
  31. package/src/thread/use-thread-agent.ts +101 -0
  32. package/src/workbench/components/GridTabContent.tsx +6 -0
@@ -144,6 +144,8 @@ export interface CueloopConfig {
144
144
  export interface ExperimentalConfig {
145
145
  /** Render a prototype as a pixel mockup (kitty graphics) instead of the default markdown design doc. */
146
146
  prototypePixels: boolean;
147
+ /** Allow the local session owner to invoke a harness inside the Thread. */
148
+ threadAgent: boolean;
147
149
  }
148
150
 
149
151
  /** Where a display name came from: typed by the user, or verified from a GitHub login. */
@@ -251,6 +253,7 @@ const ObsidianSchema = v.object({
251
253
  const IntegrationsSchema = v.object({ obsidian: v.optional(ObsidianSchema) });
252
254
  const ExperimentalSchema = v.object({
253
255
  prototype_pixels: v.fallback(v.optional(v.boolean()), undefined),
256
+ thread_agent: v.fallback(v.optional(v.boolean()), undefined),
254
257
  });
255
258
  const ReviewSchema = v.object({
256
259
  skill: v.fallback(v.optional(v.string()), undefined),
@@ -369,9 +372,7 @@ function layer(
369
372
  if (integrations.success && integrations.output.obsidian) {
370
373
  mergeObsidian(out.integrations.obsidian, integrations.output.obsidian);
371
374
  }
372
- if (experimental.success && experimental.output.prototype_pixels !== undefined) {
373
- out.experimental.prototypePixels = experimental.output.prototype_pixels;
374
- }
375
+ if (experimental.success) applyExperimental(out.experimental, experimental.output);
375
376
  if (review.success) applyReview(out.review, review.output);
376
377
 
377
378
  return out;
@@ -397,7 +398,7 @@ export function loadConfig(
397
398
  skillsPath: join(homedir(), ".agents", "skills"),
398
399
  review: { skill: "code-review", workspace: "worktree" },
399
400
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
400
- experimental: { prototypePixels: false },
401
+ experimental: { prototypePixels: false, threadAgent: false },
401
402
  };
402
403
  // Theme name and per-token overrides are separate concerns, composed once
403
404
  // after all layers: the last file to set [ui] theme wins, and every [theme]
@@ -629,3 +630,12 @@ export function persistAuthorName(id: string, name: string, userConfigPath?: str
629
630
  mkdirSync(dirname(path), { recursive: true });
630
631
  writeFileSync(path, text);
631
632
  }
633
+
634
+ /** Only explicitly configured experimental switches override the preceding layer. */
635
+ function applyExperimental(
636
+ config: ExperimentalConfig,
637
+ raw: v.InferOutput<typeof ExperimentalSchema>,
638
+ ): void {
639
+ if (raw.prototype_pixels !== undefined) config.prototypePixels = raw.prototype_pixels;
640
+ if (raw.thread_agent !== undefined) config.threadAgent = raw.thread_agent;
641
+ }
@@ -2282,3 +2282,77 @@ exports[`stories catalog Collaborator Join/JoinSplash renders, snapshots, and ca
2282
2282
 
2283
2283
  "
2284
2284
  `;
2285
+
2286
+ exports["stories catalog Surfaces/AgentThreadPane/CompletedAnswer renders, snapshots, and carries its colors: Surfaces/AgentThreadPane/CompletedAnswer 1"] = "\n\" Artifact Agent fx ready Tools (1) \nWorkspace: /project \n \n You \n ────────────────────────────────────────────────────────────────────── \n \n Why does the retry survive cancellation? \n \n 1 tool call · 1 reads · 0 commands \n \n fx \n ────────────────────────────────────────────────────────────────────── \n \n The timer survives cancellation. Clear it when the request ends. \n \n \n \n \n \n \n \n \n \n \n────────────────────────────────────────────────────────────────────────────────\n Ask fx… Ctrl+Enter sends · Esc reads · i composes \n \n Send message (0) \n\"\n";
2287
+
2288
+ exports["stories catalog Surfaces/AgentThreadPane/WaitingPermission renders, snapshots, and carries its colors: Surfaces/AgentThreadPane/WaitingPermission 1"] = "\n\" Artifact Agent fx working Tools (1) Stop \nWorkspace: /project \n \n You \n ────────────────────────────────────────────────────────────────────── \n \n Why does the retry survive cancellation? \n \n 1 tool call · 1 reads · 0 commands \n \n fx \n ────────────────────────────────────────────────────────────────────── \n \n The timer survives cancellation. Clear it when the request ends. \n \n \n \n \n \n \n┌──────────────────────────────────────────────────────────────────────────────┐\n│ Update retry.ts │\n│ Allow once Reject once │\n└──────────────────────────────────────────────────────────────────────────────┘\n────────────────────────────────────────────────────────────────────────────────\n Wait for fx, or stop the tur \n \n Send message (0) \n\"\n";
2289
+
2290
+ exports[`stories catalog Chrome/AgentConfigControls/Choices renders, snapshots, and carries its colors: Chrome/AgentConfigControls/Choices 1`] = `
2291
+ "Test model Medium
2292
+
2293
+ "
2294
+ `;
2295
+
2296
+ exports[`stories catalog Surfaces/AgentThreadPane/CompletedAnswer renders, snapshots, and carries its colors: Surfaces/AgentThreadPane/CompletedAnswer 1`] = `
2297
+ "
2298
+ Keep the retry bounded.
2299
+
2300
+ Why does the retry survive cancellation?
2301
+
2302
+ The timer survives cancellation. Clear it when the request ends.
2303
+
2304
+
2305
+
2306
+
2307
+
2308
+
2309
+
2310
+
2311
+
2312
+
2313
+
2314
+
2315
+
2316
+
2317
+
2318
+
2319
+
2320
+
2321
+
2322
+
2323
+
2324
+ type to comment · esc for nav mode
2325
+ "
2326
+ `;
2327
+
2328
+ exports[`stories catalog Surfaces/AgentThreadPane/WaitingPermission renders, snapshots, and carries its colors: Surfaces/AgentThreadPane/WaitingPermission 1`] = `
2329
+ "
2330
+ Keep the retry bounded.
2331
+
2332
+ Why does the retry survive cancellation?
2333
+
2334
+ The timer survives cancellation. Clear it when the request ends.
2335
+ Thinking…
2336
+ Update retry.ts
2337
+ Allow once Reject once
2338
+
2339
+
2340
+
2341
+
2342
+
2343
+
2344
+
2345
+
2346
+
2347
+
2348
+
2349
+
2350
+
2351
+
2352
+
2353
+
2354
+
2355
+
2356
+ type to comment · esc for nav mode
2357
+ "
2358
+ `;
@@ -0,0 +1,32 @@
1
+ import { expect, test } from "bun:test";
2
+ import { agentContinuation, type AgentContinuation } from "./agent-continuation";
3
+ import type { ThreadAgentState } from "@cueloop/schema";
4
+
5
+ const state: ThreadAgentState = {
6
+ threadId: "thread",
7
+ phase: { kind: "idle" },
8
+ messages: [],
9
+ comments: [],
10
+ tools: [],
11
+ };
12
+
13
+ test("only a completed reply in an idle conversation offers continuation", () => {
14
+ expect(agentContinuation(state)).toEqual({ kind: "waiting" });
15
+ const message = {
16
+ id: "answer",
17
+ role: "agent" as const,
18
+ text: "Done",
19
+ complete: true,
20
+ revision: 1,
21
+ };
22
+ const completed: AgentContinuation = agentContinuation({ ...state, messages: [message] });
23
+
24
+ expect(completed.kind).toBe("ready");
25
+ if (completed.kind === "ready") expect(completed.replyId).toBe("answer");
26
+ expect(agentContinuation({ ...state, phase: { kind: "running" }, messages: [message] })).toEqual({
27
+ kind: "waiting",
28
+ });
29
+ expect(agentContinuation({ ...state, messages: [{ ...message, complete: false }] })).toEqual({
30
+ kind: "waiting",
31
+ });
32
+ });
@@ -0,0 +1,13 @@
1
+ import type { ThreadAgentState } from "@cueloop/schema";
2
+
3
+ /** Chat continuation names a completed reply only when the harness has returned to idle. */
4
+ export type AgentContinuation = { kind: "waiting" } | { kind: "ready"; replyId: string };
5
+
6
+ /** Streaming replies cannot request the next prompt's focus. */
7
+ export function agentContinuation(state: ThreadAgentState): AgentContinuation {
8
+ const reply = state.messages.findLast((message) => message.role === "agent");
9
+
10
+ if (state.phase.kind !== "idle" || !reply?.complete) return { kind: "waiting" };
11
+
12
+ return { kind: "ready", replyId: reply.id };
13
+ }
@@ -0,0 +1,161 @@
1
+ import { expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Thread, type ThreadAgentState } from "@cueloop/schema";
3
+ import {
4
+ projectAgentTranscript,
5
+ projectThreadConversation,
6
+ commentOnAgentSpan,
7
+ agentToolFilePath,
8
+ } from "./agent-transcript";
9
+
10
+ import { buildDisplay, marksByDisplay } from "../markdown/view-plan";
11
+
12
+ const thread: Thread = {
13
+ schemaVersion: SCHEMA_VERSION,
14
+ id: "thread",
15
+ workspace: { repoRoot: "/tmp", branch: "main" },
16
+ artifact: { type: "plan", content: "Original artifact", meta: {} },
17
+ annotations: [],
18
+ revisions: [],
19
+ status: "pending",
20
+ message: null,
21
+ createdAt: "2026-10-04",
22
+ };
23
+ const state: ThreadAgentState = {
24
+ threadId: "thread",
25
+ phase: { kind: "idle" },
26
+ tools: [],
27
+ comments: [],
28
+ messages: [
29
+ { id: "question", role: "user", text: "Why?", complete: true, revision: 1 },
30
+ { id: "answer-1", role: "agent", text: "Same quote.", complete: true, revision: 1 },
31
+ { id: "answer-2", role: "agent", text: "Same quote.", complete: true, revision: 1 },
32
+ ],
33
+ };
34
+
35
+ test("repeated quotes bind to the chosen answer instead of an earlier message", () => {
36
+ const comment = commentOnAgentSpan(
37
+ thread,
38
+ state,
39
+ { start: { blockIndex: 5, char: 0 }, end: { blockIndex: 5, char: 4 } },
40
+ "Explain",
41
+ "note",
42
+ );
43
+ const projected = projectAgentTranscript(thread, { ...state, comments: [comment] });
44
+
45
+ expect(comment.messageId).toBe("answer-2");
46
+ expect(comment.anchor.quote).toBe("Same");
47
+ expect(projected.marks.has(5)).toBe(true);
48
+ expect(projected.marks.has(3)).toBe(false);
49
+ expect(thread.artifact.content).toBe("Original artifact");
50
+ });
51
+
52
+ test("a question, a cross-message selection, or a changing answer cannot accept feedback", () => {
53
+ const span = { start: { blockIndex: 1, char: 0 }, end: { blockIndex: 1, char: 3 } };
54
+
55
+ expect(() => commentOnAgentSpan(thread, state, span, "note", "id")).toThrow();
56
+ expect(() =>
57
+ commentOnAgentSpan(
58
+ thread,
59
+ state,
60
+ { ...span, start: { blockIndex: 3, char: 0 }, end: { blockIndex: 5, char: 4 } },
61
+ "note",
62
+ "id",
63
+ ),
64
+ ).toThrow();
65
+ expect(() =>
66
+ commentOnAgentSpan(
67
+ thread,
68
+ { ...state, messages: state.messages.map((message) => ({ ...message, complete: false })) },
69
+ { ...span, start: { blockIndex: 3, char: 0 }, end: { blockIndex: 3, char: 4 } },
70
+ "note",
71
+ "id",
72
+ ),
73
+ ).toThrow();
74
+ });
75
+
76
+ test("tool locations open the correct repository file from a subdirectory cwd", () => {
77
+ const nested: Thread = {
78
+ ...thread,
79
+ workspace: { repoRoot: "/repo", branch: "main" },
80
+ artifact: { ...thread.artifact, meta: { cwd: "/repo/packages/client" } },
81
+ };
82
+
83
+ expect(agentToolFilePath(nested, "/repo/packages/client/src/App.tsx")).toBe(
84
+ "packages/client/src/App.tsx",
85
+ );
86
+ expect(agentToolFilePath(nested, "src/App.tsx")).toBe("packages/client/src/App.tsx");
87
+ expect(agentToolFilePath(nested, "../../README.md")).toBe("README.md");
88
+ expect(agentToolFilePath(nested, "/outside/README.md")).toBeUndefined();
89
+ expect(agentToolFilePath(nested, "../../../outside.ts")).toBeUndefined();
90
+ });
91
+
92
+ test("editable artifact and agent comments stay inline until accepted for invocation", () => {
93
+ const comment = commentOnAgentSpan(
94
+ thread,
95
+ state,
96
+ { start: { blockIndex: 3, char: 0 }, end: { blockIndex: 3, char: 4 } },
97
+ "Explain the answer",
98
+ "agent-note",
99
+ );
100
+ const artifactNote = {
101
+ id: "artifact-note",
102
+ kind: "comment" as const,
103
+ body: "Explain the artifact",
104
+ createdAt: thread.createdAt,
105
+ anchor: { quote: "Original", prefix: "", suffix: " artifact", blockIndex: 0, start: 0, end: 8 },
106
+ };
107
+ const annotated = { ...thread, annotations: [artifactNote] };
108
+ const pending = { ...state, comments: [comment] };
109
+ const display = buildDisplay(thread.artifact.content);
110
+ const marks = marksByDisplay(annotated.annotations, display);
111
+ const inline = projectThreadConversation(annotated, pending, display, marks);
112
+
113
+ expect(inline.mirrors.size).toBe(0);
114
+ expect(inline.session.annotations.some((entry) => entry.id === artifactNote.id)).toBe(true);
115
+ expect(inline.session.annotations.some((entry) => entry.id === comment.id)).toBe(true);
116
+ const submitted = projectThreadConversation(
117
+ annotated,
118
+ {
119
+ ...pending,
120
+ submissions: [
121
+ {
122
+ id: "accepted",
123
+ commentId: artifactNote.id,
124
+ prompt: artifactNote.body,
125
+ quote: artifactNote.anchor.quote,
126
+ status: "queued",
127
+ },
128
+ ],
129
+ },
130
+ display,
131
+ marks,
132
+ );
133
+
134
+ expect(submitted.mirrors.size).toBe(1);
135
+ expect(submitted.mirrors.get("mirror:accepted")?.commentId).toBe(artifactNote.id);
136
+ });
137
+
138
+ test.each([undefined, "plain-prompt"])(
139
+ "accepted bottom prompts remain user messages without synthetic comment mirrors (%s)",
140
+ (commentId) => {
141
+ const emptyThread = { ...thread, artifact: { ...thread.artifact, content: "" } };
142
+ const projected = projectThreadConversation(
143
+ emptyThread,
144
+ {
145
+ ...state,
146
+ messages: [],
147
+ submissions: [
148
+ { id: "plain-prompt", commentId, prompt: "Hello from the prompt", status: "completed" },
149
+ ],
150
+ },
151
+ [],
152
+ new Map(),
153
+ );
154
+
155
+ expect(projected.mirrors.size).toBe(0);
156
+ expect(projected.session.annotations).toEqual([]);
157
+ expect(projected.display.some((block) => block.work?.text === "Hello from the prompt")).toBe(
158
+ true,
159
+ );
160
+ },
161
+ );
@@ -0,0 +1,332 @@
1
+ import { resolve, relative, isAbsolute } from "node:path";
2
+ import {
3
+ makeAnchor,
4
+ parseBlocks,
5
+ type AgentComment,
6
+ type Thread,
7
+ type ThreadAgentState,
8
+ } from "@cueloop/schema";
9
+ import {
10
+ buildDisplay,
11
+ marksByDisplay,
12
+ renderedSpanToWork,
13
+ type DisplayBlock,
14
+ type Mark,
15
+ } from "../markdown/view-plan";
16
+ import type { TextSpan } from "../annotations/thread-selection";
17
+
18
+ /** One parsed agent transcript owns both display blocks and message selection coordinates. */
19
+ export function projectAgentTranscript(thread: Thread, state: ThreadAgentState) {
20
+ const display: DisplayBlock[] = [];
21
+ const offsets = new Map<string, number>();
22
+ const sources: { messageId: string; blockIndex: number }[] = [];
23
+ const annotations = state.comments.map((comment) => ({
24
+ ...comment,
25
+ kind: "comment",
26
+ createdAt: thread.createdAt,
27
+ }));
28
+
29
+ for (const message of state.messages) {
30
+ display.push(
31
+ ...buildDisplay(`## ${message.role === "user" ? "You" : (state.harness?.label ?? "Agent")}`),
32
+ );
33
+ sources.push({ messageId: "", blockIndex: -1 });
34
+ offsets.set(message.id, display.length);
35
+ const blocks = buildDisplay(message.text);
36
+
37
+ display.push(...blocks);
38
+ sources.push(...blocks.map((_, blockIndex) => ({ messageId: message.id, blockIndex })));
39
+ if (message.role === "user") {
40
+ const tools = state.tools.filter((tool) => tool.turnId === message.id);
41
+
42
+ if (tools.length) {
43
+ display.push(
44
+ ...buildDisplay(
45
+ `${tools.length} tool ${tools.length === 1 ? "call" : "calls"} · ${tools.filter((tool) => tool.kind === "read").length} reads · ${tools.filter((tool) => tool.kind === "execute").length} commands`,
46
+ ),
47
+ );
48
+ sources.push({ messageId: "", blockIndex: -1 });
49
+ }
50
+ }
51
+ }
52
+ const marks = new Map<number, Mark[]>();
53
+
54
+ for (const message of state.messages) {
55
+ const local = annotations.filter((annotation) => annotation.messageId === message.id);
56
+ const offset = offsets.get(message.id)!;
57
+
58
+ for (const [index, values] of marksByDisplay(local, buildDisplay(message.text)))
59
+ marks.set(
60
+ index + offset,
61
+ values.map((mark) => ({
62
+ ...mark,
63
+ span: mark.span
64
+ ? {
65
+ start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + offset },
66
+ end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + offset },
67
+ }
68
+ : undefined,
69
+ })),
70
+ );
71
+ }
72
+ const session: Thread = {
73
+ ...thread,
74
+ history: undefined,
75
+ workingCopy: undefined,
76
+ textCuts: undefined,
77
+ artifact: { type: "plan", content: "", meta: {} },
78
+ annotations,
79
+ status: "pending",
80
+ message: null,
81
+ };
82
+
83
+ return { display, marks, session, sources };
84
+ }
85
+
86
+ /** Agent feedback cannot span messages or bind to an unfinished answer. */
87
+ export function commentOnAgentSpan(
88
+ thread: Thread,
89
+ state: ThreadAgentState,
90
+ span: TextSpan,
91
+ body: string,
92
+ id: string,
93
+ ): AgentComment {
94
+ const projection = projectAgentTranscript(thread, state);
95
+ const start = projection.sources[span.start.blockIndex];
96
+ const end = projection.sources[span.end.blockIndex];
97
+ const message = state.messages.find((message) => message.id === start?.messageId);
98
+
99
+ if (
100
+ !message ||
101
+ !start ||
102
+ !end ||
103
+ start.messageId !== end.messageId ||
104
+ message.role !== "agent" ||
105
+ !message.complete
106
+ ) {
107
+ throw new Error("Agent comment requires a selection within one completed answer");
108
+ }
109
+
110
+ const range = renderedSpanToWork(
111
+ buildDisplay(message.text),
112
+ start.blockIndex,
113
+ end.blockIndex,
114
+ span.start.char,
115
+ span.end.char,
116
+ );
117
+
118
+ return {
119
+ id,
120
+ messageId: message.id,
121
+ anchor: makeAnchor(
122
+ parseBlocks(message.text),
123
+ start.blockIndex,
124
+ range.start,
125
+ range.end,
126
+ end.blockIndex,
127
+ ),
128
+ body,
129
+ sent: false,
130
+ };
131
+ }
132
+
133
+ /** Tool locations use the agent cwd; the file reader uses the repository root. */
134
+ export function agentToolFilePath(thread: Thread, path: string): string | undefined {
135
+ const root = thread.workspace.repoRoot;
136
+ const absolute = resolve(thread.artifact.meta.cwd ?? root, path);
137
+ const local = relative(root, absolute);
138
+
139
+ if (!local || local === ".." || local.startsWith("../") || isAbsolute(local)) return undefined;
140
+
141
+ return local;
142
+ }
143
+
144
+ /** The artifact, mirrored discussions, answers, and tail share one navigation projection. */
145
+ export function projectThreadConversation(
146
+ thread: Thread,
147
+ state: ThreadAgentState,
148
+ artifactDisplay: DisplayBlock[],
149
+ artifactMarks: Map<number, Mark[]>,
150
+ showActivity = state.phase.kind !== "idle",
151
+ ) {
152
+ const display = [...artifactDisplay];
153
+ const marks = new Map(artifactMarks);
154
+ const annotations = [...thread.annotations];
155
+ const sources: (
156
+ | { kind: "artifact" }
157
+ | { kind: "message"; messageId: string; blockIndex: number }
158
+ | { kind: "mirror"; commentId?: string }
159
+ | { kind: "tail" }
160
+ | { kind: "activity" }
161
+ )[] = artifactDisplay.map(() => ({ kind: "artifact" }));
162
+ const mirrors = new Map<string, { commentId?: string; submissionId?: string }>();
163
+ const destinations = new Map<string, number>();
164
+ const mirror = (
165
+ id: string,
166
+ body: string,
167
+ quote: string,
168
+ commentId?: string,
169
+ submissionId?: string,
170
+ ): void => {
171
+ const blocks: DisplayBlock[] = buildDisplay(quote);
172
+ if (!blocks.length)
173
+ blocks.push({
174
+ type: "same",
175
+ kind: "p",
176
+ work: { kind: "p", text: "", lineStart: 0, lineEnd: 0 },
177
+ });
178
+ const offset = display.length;
179
+ const annotation = {
180
+ id: `mirror:${id}`,
181
+ kind: "comment",
182
+ body,
183
+ createdAt: thread.createdAt,
184
+ anchor: {
185
+ quote,
186
+ prefix: "",
187
+ suffix: "",
188
+ blockIndex: 0,
189
+ start: 0,
190
+ end: (blocks[0]?.work ?? blocks[0]?.base)?.text.length ?? 0,
191
+ },
192
+ };
193
+
194
+ display.push(...blocks);
195
+ sources.push(...blocks.map(() => ({ kind: "mirror" as const, commentId })));
196
+ annotations.push(annotation);
197
+ if (commentId) {
198
+ const replies = [...thread.annotations, ...state.comments].filter(
199
+ (entry) => entry.replyTo === commentId,
200
+ );
201
+ for (const reply of replies) {
202
+ const replica = {
203
+ ...annotation,
204
+ id: `${annotation.id}:${reply.id}`,
205
+ body: reply.body,
206
+ author: reply.author,
207
+ replyTo: annotation.id,
208
+ };
209
+
210
+ annotations.push(replica);
211
+ mirrors.set(replica.id, { commentId: reply.id });
212
+ }
213
+ }
214
+ const localMarks = quote.trim()
215
+ ? marksByDisplay([annotation], blocks)
216
+ : new Map<number, Mark[]>([
217
+ [
218
+ 0,
219
+ [
220
+ {
221
+ start: 0,
222
+ end: 0,
223
+ role: "mark-comment",
224
+ annotationId: annotation.id,
225
+ span: { start: { blockIndex: 0, char: 0 }, end: { blockIndex: 0, char: 0 } },
226
+ },
227
+ ],
228
+ ],
229
+ ]);
230
+ for (const [index, values] of localMarks) {
231
+ marks.set(
232
+ index + offset,
233
+ values.map((mark) => ({
234
+ ...mark,
235
+ span: mark.span
236
+ ? {
237
+ start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + offset },
238
+ end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + offset },
239
+ }
240
+ : undefined,
241
+ })),
242
+ );
243
+ }
244
+ mirrors.set(annotation.id, { commentId, submissionId });
245
+ if (submissionId) destinations.set(submissionId, offset);
246
+ };
247
+ const message = (entry: ThreadAgentState["messages"][number]): void => {
248
+ const projection = projectAgentTranscript(thread, {
249
+ ...state,
250
+ messages: [entry],
251
+ tools: [],
252
+ comments: state.comments.filter((comment) => comment.messageId === entry.id),
253
+ });
254
+ const offset = display.length;
255
+
256
+ display.push(...projection.display.slice(1));
257
+ sources.push(
258
+ ...projection.sources.slice(1).map((source) => ({ kind: "message" as const, ...source })),
259
+ );
260
+ annotations.push(...projection.session.annotations);
261
+ for (const [index, values] of projection.marks) {
262
+ marks.set(
263
+ index - 1 + offset,
264
+ values.map((mark) => ({
265
+ ...mark,
266
+ span: mark.span
267
+ ? {
268
+ start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + offset - 1 },
269
+ end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + offset - 1 },
270
+ }
271
+ : undefined,
272
+ })),
273
+ );
274
+ }
275
+ };
276
+ if (state.submissions?.length) {
277
+ for (const submission of state.submissions) {
278
+ // A prompt may use its submission ID as a discussion root; that is not an inline comment.
279
+ if (submission.commentId && submission.commentId !== submission.id) {
280
+ mirror(
281
+ submission.id,
282
+ submission.prompt,
283
+ submission.quote ?? " ",
284
+ submission.commentId,
285
+ submission.id,
286
+ );
287
+ } else {
288
+ destinations.set(submission.id, display.length);
289
+ message({
290
+ id: submission.id,
291
+ role: "user",
292
+ text: submission.prompt,
293
+ complete: true,
294
+ revision: 1,
295
+ });
296
+ }
297
+ for (const entry of state.messages.filter(
298
+ (entry) => entry.role === "agent" && entry.submissionId === submission.id,
299
+ ))
300
+ message(entry);
301
+ }
302
+ } else for (const entry of state.messages) message(entry);
303
+ const activityIndex = showActivity ? display.length : -1;
304
+
305
+ if (showActivity) {
306
+ display.push({
307
+ type: "same",
308
+ kind: "p",
309
+ work: { kind: "p", text: "", lineStart: 0, lineEnd: 0 },
310
+ });
311
+ sources.push({ kind: "activity" });
312
+ }
313
+ const tailIndex = display.length;
314
+
315
+ display.push({
316
+ type: "same",
317
+ kind: "p",
318
+ work: { kind: "p", text: "", lineStart: 0, lineEnd: 0 },
319
+ });
320
+ sources.push({ kind: "tail" });
321
+
322
+ return {
323
+ display,
324
+ marks,
325
+ sources,
326
+ mirrors,
327
+ destinations,
328
+ activityIndex,
329
+ tailIndex,
330
+ session: { ...thread, annotations },
331
+ };
332
+ }