@stigmer/react 3.1.4 → 3.1.6

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 (137) hide show
  1. package/execution/FileReviewContext.d.ts +15 -1
  2. package/execution/FileReviewContext.d.ts.map +1 -1
  3. package/execution/FileReviewContext.js +20 -1
  4. package/execution/FileReviewContext.js.map +1 -1
  5. package/execution/MessageThread.d.ts +65 -2
  6. package/execution/MessageThread.d.ts.map +1 -1
  7. package/execution/MessageThread.js +42 -23
  8. package/execution/MessageThread.js.map +1 -1
  9. package/execution/TodoCard.d.ts +8 -0
  10. package/execution/TodoCard.d.ts.map +1 -1
  11. package/execution/TodoCard.js +6 -4
  12. package/execution/TodoCard.js.map +1 -1
  13. package/execution/TodoList.d.ts +20 -1
  14. package/execution/TodoList.d.ts.map +1 -1
  15. package/execution/TodoList.js +9 -6
  16. package/execution/TodoList.js.map +1 -1
  17. package/execution/ToolCallDetail.d.ts +3 -2
  18. package/execution/ToolCallDetail.d.ts.map +1 -1
  19. package/execution/ToolCallDetail.js +49 -16
  20. package/execution/ToolCallDetail.js.map +1 -1
  21. package/execution/WriteBackCard.d.ts +7 -1
  22. package/execution/WriteBackCard.d.ts.map +1 -1
  23. package/execution/WriteBackCard.js +10 -2
  24. package/execution/WriteBackCard.js.map +1 -1
  25. package/execution/file-review-status.d.ts +15 -0
  26. package/execution/file-review-status.d.ts.map +1 -1
  27. package/execution/file-review-status.js +22 -0
  28. package/execution/file-review-status.js.map +1 -1
  29. package/execution/index.d.ts +5 -5
  30. package/execution/index.d.ts.map +1 -1
  31. package/execution/index.js +3 -3
  32. package/execution/index.js.map +1 -1
  33. package/github/useGitHubFileReader.d.ts +4 -2
  34. package/github/useGitHubFileReader.d.ts.map +1 -1
  35. package/github/useGitHubFileReader.js +16 -3
  36. package/github/useGitHubFileReader.js.map +1 -1
  37. package/github/useGitHubTreeLister.d.ts +2 -1
  38. package/github/useGitHubTreeLister.d.ts.map +1 -1
  39. package/github/useGitHubTreeLister.js +7 -3
  40. package/github/useGitHubTreeLister.js.map +1 -1
  41. package/index.d.ts +3 -3
  42. package/index.d.ts.map +1 -1
  43. package/index.js +3 -3
  44. package/index.js.map +1 -1
  45. package/internal/VirtualizedThread.d.ts +3 -2
  46. package/internal/VirtualizedThread.d.ts.map +1 -1
  47. package/internal/VirtualizedThread.js +3 -2
  48. package/internal/VirtualizedThread.js.map +1 -1
  49. package/package.json +4 -4
  50. package/session/SessionViewer.d.ts +11 -1
  51. package/session/SessionViewer.d.ts.map +1 -1
  52. package/session/SessionViewer.js +24 -5
  53. package/session/SessionViewer.js.map +1 -1
  54. package/session/facets/ChangesTab.d.ts +24 -6
  55. package/session/facets/ChangesTab.d.ts.map +1 -1
  56. package/session/facets/ChangesTab.js +15 -6
  57. package/session/facets/ChangesTab.js.map +1 -1
  58. package/session/index.d.ts +1 -0
  59. package/session/index.d.ts.map +1 -1
  60. package/session/index.js +1 -0
  61. package/session/index.js.map +1 -1
  62. package/session/useSessionRailViews.d.ts +16 -1
  63. package/session/useSessionRailViews.d.ts.map +1 -1
  64. package/session/useSessionRailViews.js +9 -4
  65. package/session/useSessionRailViews.js.map +1 -1
  66. package/session/useWorkspaceReadRefs.d.ts +45 -0
  67. package/session/useWorkspaceReadRefs.d.ts.map +1 -0
  68. package/session/useWorkspaceReadRefs.js +104 -0
  69. package/session/useWorkspaceReadRefs.js.map +1 -0
  70. package/src/execution/FileReviewContext.ts +27 -1
  71. package/src/execution/MessageThread.tsx +110 -22
  72. package/src/execution/TodoCard.tsx +20 -4
  73. package/src/execution/TodoList.tsx +26 -4
  74. package/src/execution/ToolCallDetail.tsx +69 -16
  75. package/src/execution/WriteBackCard.tsx +18 -4
  76. package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
  77. package/src/execution/__tests__/WriteBackCard.test.tsx +83 -0
  78. package/src/execution/__tests__/file-review-status.test.ts +47 -0
  79. package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
  80. package/src/execution/file-review-status.ts +24 -0
  81. package/src/execution/index.ts +13 -3
  82. package/src/github/__tests__/useGitHubFileReader.test.ts +35 -2
  83. package/src/github/__tests__/useGitHubTreeLister.test.ts +14 -0
  84. package/src/github/useGitHubFileReader.ts +19 -6
  85. package/src/github/useGitHubTreeLister.ts +7 -3
  86. package/src/index.ts +4 -0
  87. package/src/internal/VirtualizedThread.tsx +5 -1
  88. package/src/session/SessionViewer.tsx +42 -2
  89. package/src/session/__tests__/useSessionRailViews.test.tsx +12 -0
  90. package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
  91. package/src/session/facets/ChangesTab.tsx +43 -6
  92. package/src/session/facets/__tests__/ChangesTab.test.tsx +23 -0
  93. package/src/session/index.ts +2 -0
  94. package/src/session/useSessionRailViews.tsx +31 -3
  95. package/src/session/useWorkspaceReadRefs.ts +118 -0
  96. package/src/workflow/picker/__tests__/recents.test.ts +4 -4
  97. package/src/workspace/FileViewer.tsx +144 -38
  98. package/src/workspace/WorkspaceFileReader.ts +21 -0
  99. package/src/workspace/__tests__/FileViewer.test.tsx +138 -3
  100. package/src/workspace/__tests__/useWorkspaceFileContent.test.tsx +20 -0
  101. package/src/workspace/__tests__/workspaceListingCache.test.ts +114 -0
  102. package/src/workspace/index.ts +4 -1
  103. package/src/workspace/useWorkspaceEntries.ts +11 -0
  104. package/src/workspace/useWorkspaceFileContent.ts +4 -1
  105. package/src/workspace/useWorkspaceFileSearch.ts +8 -3
  106. package/src/workspace/useWorkspaceFiles.ts +3 -2
  107. package/src/workspace/workspaceListingCache.ts +21 -7
  108. package/styles.css +1 -1
  109. package/workspace/FileViewer.d.ts +1 -1
  110. package/workspace/FileViewer.d.ts.map +1 -1
  111. package/workspace/FileViewer.js +55 -5
  112. package/workspace/FileViewer.js.map +1 -1
  113. package/workspace/WorkspaceFileReader.d.ts +17 -0
  114. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  115. package/workspace/WorkspaceFileReader.js +18 -0
  116. package/workspace/WorkspaceFileReader.js.map +1 -1
  117. package/workspace/index.d.ts +1 -1
  118. package/workspace/index.d.ts.map +1 -1
  119. package/workspace/index.js +1 -1
  120. package/workspace/index.js.map +1 -1
  121. package/workspace/useWorkspaceEntries.d.ts +11 -0
  122. package/workspace/useWorkspaceEntries.d.ts.map +1 -1
  123. package/workspace/useWorkspaceEntries.js.map +1 -1
  124. package/workspace/useWorkspaceFileContent.d.ts.map +1 -1
  125. package/workspace/useWorkspaceFileContent.js +4 -1
  126. package/workspace/useWorkspaceFileContent.js.map +1 -1
  127. package/workspace/useWorkspaceFileSearch.d.ts.map +1 -1
  128. package/workspace/useWorkspaceFileSearch.js +8 -3
  129. package/workspace/useWorkspaceFileSearch.js.map +1 -1
  130. package/workspace/useWorkspaceFiles.d.ts +2 -1
  131. package/workspace/useWorkspaceFiles.d.ts.map +1 -1
  132. package/workspace/useWorkspaceFiles.js +3 -2
  133. package/workspace/useWorkspaceFiles.js.map +1 -1
  134. package/workspace/workspaceListingCache.d.ts +1 -1
  135. package/workspace/workspaceListingCache.d.ts.map +1 -1
  136. package/workspace/workspaceListingCache.js +20 -7
  137. package/workspace/workspaceListingCache.js.map +1 -1
@@ -0,0 +1,463 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3
+ import React, { memo } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import {
6
+ AgentExecutionSchema,
7
+ AgentExecutionStatusSchema,
8
+ type AgentExecution,
9
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
10
+ import {
11
+ AgentExecutionSpecSchema,
12
+ ExecutionConfigSchema,
13
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
14
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
15
+ import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
16
+ import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
17
+ import { TodoItemSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/todo_pb";
18
+ import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
19
+ import {
20
+ ApprovalAction,
21
+ ExecutionArtifactKind,
22
+ ExecutionPhase,
23
+ InteractionMode,
24
+ MessageType,
25
+ TodoStatus,
26
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
27
+
28
+ import { MessageThread, type MessageThreadSlots } from "../MessageThread";
29
+ import type { MessageEntryProps } from "../MessageEntry";
30
+ import type { ApprovalCardProps } from "../ApprovalCard";
31
+ import type { TodoCardProps } from "../TodoCard";
32
+ import type { TodoRowProps } from "../TodoList";
33
+ import type { SetupProgressProps } from "../SetupProgress";
34
+ import type { PlanCompletionCardProps } from "../PlanCompletionCard";
35
+ import type { PlanArtifactCardProps } from "../PlanArtifactCard";
36
+ import type { PlanStreamingCardProps } from "../PlanStreamingCard";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Mock react-virtuoso (same pattern as virtualized-thread.test.tsx) so the
40
+ // virtualized path renders its items synchronously in happy-dom.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ vi.mock("react-virtuoso", () => ({
44
+ Virtuoso: React.forwardRef(function MockVirtuoso(
45
+ props: Record<string, unknown>,
46
+ ref: React.Ref<unknown>,
47
+ ) {
48
+ const data = props.data as { key: string }[];
49
+ const itemContent = props.itemContent as (
50
+ index: number,
51
+ item: unknown,
52
+ ) => React.ReactNode;
53
+ React.useImperativeHandle(ref, () => ({ scrollToIndex: vi.fn() }));
54
+ return (
55
+ <div data-testid="virtuoso-scroller">
56
+ {data.map((item, i) => (
57
+ <div key={item.key}>{itemContent(i, item)}</div>
58
+ ))}
59
+ </div>
60
+ );
61
+ }),
62
+ }));
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // DOM stubs — useAutoScroll depends on browser APIs not available in happy-dom
66
+ // ---------------------------------------------------------------------------
67
+
68
+ beforeEach(() => {
69
+ vi.stubGlobal(
70
+ "IntersectionObserver",
71
+ vi.fn(() => ({
72
+ observe: vi.fn(),
73
+ unobserve: vi.fn(),
74
+ disconnect: vi.fn(),
75
+ takeRecords: vi.fn(() => []),
76
+ root: null,
77
+ rootMargin: "",
78
+ thresholds: [0],
79
+ })),
80
+ );
81
+ vi.stubGlobal(
82
+ "ResizeObserver",
83
+ vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
84
+ );
85
+ vi.stubGlobal(
86
+ "requestAnimationFrame",
87
+ vi.fn((cb: FrameRequestCallback) => {
88
+ cb(performance.now());
89
+ return 1;
90
+ }),
91
+ );
92
+ vi.stubGlobal("cancelAnimationFrame", vi.fn());
93
+ });
94
+
95
+ afterEach(() => {
96
+ cleanup();
97
+ vi.restoreAllMocks();
98
+ });
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Fixtures
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function makeExecution(opts: {
105
+ id: string;
106
+ specMessage?: string;
107
+ phase?: ExecutionPhase;
108
+ interactionMode?: InteractionMode;
109
+ aiContent?: string;
110
+ todos?: Record<string, { content: string; status: TodoStatus }>;
111
+ pendingApprovalToolCallId?: string;
112
+ planArtifactName?: string;
113
+ }): AgentExecution {
114
+ const exec = create(AgentExecutionSchema);
115
+
116
+ const meta = create(ApiResourceMetadataSchema);
117
+ meta.id = opts.id;
118
+ exec.metadata = meta;
119
+
120
+ const spec = create(AgentExecutionSpecSchema);
121
+ spec.message = opts.specMessage ?? "Hello";
122
+ if (opts.interactionMode !== undefined) {
123
+ const config = create(ExecutionConfigSchema);
124
+ config.interactionMode = opts.interactionMode;
125
+ spec.executionConfig = config;
126
+ }
127
+ exec.spec = spec;
128
+
129
+ const status = create(AgentExecutionStatusSchema);
130
+ status.phase = opts.phase ?? ExecutionPhase.EXECUTION_COMPLETED;
131
+
132
+ const aiMsg = create(AgentMessageSchema);
133
+ aiMsg.type = MessageType.MESSAGE_AI;
134
+ aiMsg.content = opts.aiContent ?? "Hi there!";
135
+ status.messages = [aiMsg];
136
+
137
+ if (opts.todos) {
138
+ for (const [id, todo] of Object.entries(opts.todos)) {
139
+ status.todos[id] = create(TodoItemSchema, {
140
+ id,
141
+ content: todo.content,
142
+ status: todo.status,
143
+ });
144
+ }
145
+ }
146
+
147
+ if (opts.pendingApprovalToolCallId) {
148
+ status.pendingApprovals = [
149
+ create(PendingApprovalSchema, {
150
+ toolCallId: opts.pendingApprovalToolCallId,
151
+ toolName: "shell_exec",
152
+ argsPreview: "{}",
153
+ }),
154
+ ];
155
+ }
156
+
157
+ if (opts.planArtifactName) {
158
+ status.artifacts = [
159
+ create(ExecutionArtifactSchema, {
160
+ name: opts.planArtifactName,
161
+ kind: ExecutionArtifactKind.FILE,
162
+ }),
163
+ ];
164
+ }
165
+
166
+ exec.status = status;
167
+ return exec;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Slot overrides — each slot renders in place of its built-in
172
+ // ---------------------------------------------------------------------------
173
+
174
+ describe("MessageThread slots", () => {
175
+ it("renders the MessageEntry slot for every message, including the failed-send bubble", () => {
176
+ const CustomEntry = ({ message }: MessageEntryProps) => (
177
+ <div data-testid="custom-entry">{message.content}</div>
178
+ );
179
+ const slots: MessageThreadSlots = { MessageEntry: CustomEntry };
180
+
181
+ render(
182
+ <MessageThread
183
+ executions={[makeExecution({ id: "e1", specMessage: "prompt text" })]}
184
+ pendingUserMessage="failed follow-up"
185
+ pendingMessageFailed
186
+ onRetrySend={vi.fn()}
187
+ slots={slots}
188
+ />,
189
+ );
190
+
191
+ const entries = screen.getAllByTestId("custom-entry");
192
+ // Synthesized human bubble + AI message + the failed pending bubble.
193
+ expect(entries.length).toBe(3);
194
+ expect(entries.map((e) => e.textContent)).toContain("failed follow-up");
195
+ // The built-in bubble chrome must not render.
196
+ expect(screen.queryByRole("article", { name: "User message" })).toBeNull();
197
+ // The failed-send Retry affordance stays built-in around the slot.
198
+ expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
199
+ });
200
+
201
+ it("renders the ApprovalCard slot with the row's stabilized onSubmit wiring intact", () => {
202
+ const onApprovalSubmit = vi.fn();
203
+ const CustomApproval = ({ pendingApproval, onSubmit }: ApprovalCardProps) => (
204
+ <button
205
+ type="button"
206
+ data-testid="custom-approval"
207
+ onClick={() => onSubmit(ApprovalAction.APPROVE)}
208
+ >
209
+ approve {pendingApproval.toolName}
210
+ </button>
211
+ );
212
+
213
+ render(
214
+ <MessageThread
215
+ executions={[
216
+ makeExecution({
217
+ id: "e1",
218
+ phase: ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
219
+ pendingApprovalToolCallId: "tc-1",
220
+ }),
221
+ ]}
222
+ onApprovalSubmit={onApprovalSubmit}
223
+ slots={{ ApprovalCard: CustomApproval }}
224
+ />,
225
+ );
226
+
227
+ fireEvent.click(screen.getByTestId("custom-approval"));
228
+ // The memoized ApprovalCardRow still injects the toolCallId — the slot's
229
+ // onSubmit contract is identical to the built-in's.
230
+ expect(onApprovalSubmit).toHaveBeenCalledWith(
231
+ "tc-1",
232
+ ApprovalAction.APPROVE,
233
+ undefined,
234
+ );
235
+ });
236
+
237
+ it("renders the TodoCard slot with the TodoRow slot forwarded through its props", () => {
238
+ const forwarded: Array<TodoRowProps["item"]["content"]> = [];
239
+ const CustomRow = ({ item }: TodoRowProps) => {
240
+ forwarded.push(item.content);
241
+ return <li data-testid="custom-row">{item.content}</li>;
242
+ };
243
+ const CustomCard = ({ todos, TodoRow }: TodoCardProps) => (
244
+ <div data-testid="custom-todo-card">
245
+ <ul>
246
+ {Object.values(todos).map((item) =>
247
+ TodoRow ? <TodoRow key={item.id} item={item} /> : null,
248
+ )}
249
+ </ul>
250
+ </div>
251
+ );
252
+
253
+ render(
254
+ <MessageThread
255
+ executions={[
256
+ makeExecution({
257
+ id: "e1",
258
+ todos: { t1: { content: "write tests", status: TodoStatus.TODO_IN_PROGRESS } },
259
+ }),
260
+ ]}
261
+ slots={{ TodoCard: CustomCard, TodoRow: CustomRow }}
262
+ />,
263
+ );
264
+
265
+ expect(screen.getByTestId("custom-todo-card")).toBeTruthy();
266
+ expect(forwarded).toEqual(["write tests"]);
267
+ });
268
+
269
+ it("renders the TodoRow slot inside the built-in TodoCard when only TodoRow is overridden", () => {
270
+ const CustomRow = ({ item }: TodoRowProps) => (
271
+ <li data-testid="custom-row">ROW: {item.content}</li>
272
+ );
273
+
274
+ render(
275
+ <MessageThread
276
+ executions={[
277
+ makeExecution({
278
+ id: "e1",
279
+ todos: { t1: { content: "write tests", status: TodoStatus.TODO_IN_PROGRESS } },
280
+ }),
281
+ ]}
282
+ slots={{ TodoRow: CustomRow }}
283
+ />,
284
+ );
285
+
286
+ // The built-in card chrome (region + disclosure) hosts the custom row —
287
+ // auto-disclosure opens the card because an item is in progress.
288
+ expect(screen.getByRole("region", { name: "Agent to-dos" })).toBeTruthy();
289
+ expect(screen.getByTestId("custom-row").textContent).toBe("ROW: write tests");
290
+ });
291
+
292
+ it("renders the SetupProgress slot for the pre-first-token window", () => {
293
+ const CustomSetup = ({ isAwaitingResponse }: SetupProgressProps) => (
294
+ <div data-testid="custom-setup">{isAwaitingResponse ? "waiting" : "setup"}</div>
295
+ );
296
+
297
+ const exec = makeExecution({
298
+ id: "e-live",
299
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
300
+ });
301
+ // No renderable response yet: the setup-progress item is emitted.
302
+ exec.status!.messages = [];
303
+
304
+ render(
305
+ <MessageThread
306
+ executions={[]}
307
+ activeStreamExecution={exec}
308
+ slots={{ SetupProgress: CustomSetup }}
309
+ />,
310
+ );
311
+
312
+ expect(screen.getByTestId("custom-setup").textContent).toBe("waiting");
313
+ });
314
+
315
+ it("renders the PlanCompletionCard slot for an artifact-less completed Plan turn", () => {
316
+ const CustomCompletion = ({ onImplement }: PlanCompletionCardProps) => (
317
+ <button type="button" data-testid="custom-completion" onClick={onImplement}>
318
+ custom build
319
+ </button>
320
+ );
321
+ const onBuildFromPlan = vi.fn();
322
+
323
+ render(
324
+ <MessageThread
325
+ executions={[
326
+ makeExecution({
327
+ id: "e-plan",
328
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
329
+ interactionMode: InteractionMode.PLAN,
330
+ }),
331
+ ]}
332
+ onBuildFromPlan={onBuildFromPlan}
333
+ slots={{ PlanCompletionCard: CustomCompletion }}
334
+ />,
335
+ );
336
+
337
+ fireEvent.click(screen.getByTestId("custom-completion"));
338
+ expect(onBuildFromPlan).toHaveBeenCalledOnce();
339
+ });
340
+
341
+ it("renders the PlanArtifactCard slot for a completed Plan turn with a published artifact", () => {
342
+ const CustomArtifact = ({ artifact, title }: PlanArtifactCardProps) => (
343
+ <div data-testid="custom-artifact">
344
+ {title ?? "Plan"}: {artifact.name}
345
+ </div>
346
+ );
347
+
348
+ render(
349
+ <MessageThread
350
+ executions={[
351
+ makeExecution({
352
+ id: "e-plan",
353
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
354
+ interactionMode: InteractionMode.PLAN,
355
+ aiContent: "# Rollout Plan\n\n1. First step",
356
+ planArtifactName: "rollout_abc123.plan.md",
357
+ }),
358
+ ]}
359
+ onBuildFromPlan={vi.fn()}
360
+ slots={{ PlanArtifactCard: CustomArtifact }}
361
+ />,
362
+ );
363
+
364
+ expect(screen.getByTestId("custom-artifact").textContent).toBe(
365
+ "Rollout Plan: rollout_abc123.plan.md",
366
+ );
367
+ });
368
+
369
+ it("renders the PlanStreamingCard slot while a plan is being written", () => {
370
+ const CustomStreaming = ({ title }: PlanStreamingCardProps) => (
371
+ <div data-testid="custom-streaming">writing: {title}</div>
372
+ );
373
+
374
+ render(
375
+ <MessageThread
376
+ executions={[]}
377
+ activeStreamExecution={makeExecution({
378
+ id: "e-live",
379
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
380
+ interactionMode: InteractionMode.PLAN,
381
+ aiContent: "# Rollout Plan\n\n1. First step",
382
+ })}
383
+ onOpenPlan={vi.fn()}
384
+ slots={{ PlanStreamingCard: CustomStreaming }}
385
+ />,
386
+ );
387
+
388
+ expect(screen.getByTestId("custom-streaming").textContent).toBe(
389
+ "writing: Rollout Plan",
390
+ );
391
+ });
392
+
393
+ // -------------------------------------------------------------------------
394
+ // Backward compatibility and memo guarantees
395
+ // -------------------------------------------------------------------------
396
+
397
+ it("renders every built-in unchanged when slots is omitted", () => {
398
+ render(
399
+ <MessageThread
400
+ executions={[
401
+ makeExecution({
402
+ id: "e1",
403
+ phase: ExecutionPhase.EXECUTION_WAITING_FOR_APPROVAL,
404
+ todos: { t1: { content: "write tests", status: TodoStatus.TODO_IN_PROGRESS } },
405
+ pendingApprovalToolCallId: "tc-1",
406
+ }),
407
+ ]}
408
+ onApprovalSubmit={vi.fn()}
409
+ />,
410
+ );
411
+
412
+ expect(screen.getByRole("article", { name: "User message" })).toBeTruthy();
413
+ expect(screen.getByRole("region", { name: "Agent to-dos" })).toBeTruthy();
414
+ expect(
415
+ screen.getByRole("alert", { name: "Approval required for shell_exec" }),
416
+ ).toBeTruthy();
417
+ });
418
+
419
+ it("renders slot overrides identically under the virtualized path", async () => {
420
+ const CustomEntry = ({ message }: MessageEntryProps) => (
421
+ <div data-testid="custom-entry">{message.content}</div>
422
+ );
423
+
424
+ render(
425
+ <MessageThread
426
+ executions={[makeExecution({ id: "e1", specMessage: "prompt text" })]}
427
+ virtualized
428
+ slots={{ MessageEntry: CustomEntry }}
429
+ />,
430
+ );
431
+
432
+ // The lazy VirtualizedThread resolves asynchronously.
433
+ await vi.waitFor(() => {
434
+ expect(screen.getByTestId("virtuoso-scroller")).toBeTruthy();
435
+ });
436
+ const entries = screen.getAllByTestId("custom-entry");
437
+ expect(entries.length).toBe(2); // synthesized human bubble + AI message
438
+ expect(screen.queryByRole("article", { name: "User message" })).toBeNull();
439
+ });
440
+
441
+ it("does not re-render a memoized slot when the thread re-renders with identical data", () => {
442
+ const renderCount = vi.fn();
443
+ const CustomCard = memo(function CustomCard({ todos }: TodoCardProps) {
444
+ renderCount();
445
+ return <div data-testid="custom-todo-card">{Object.keys(todos).length} todos</div>;
446
+ });
447
+ const slots: MessageThreadSlots = { TodoCard: CustomCard };
448
+
449
+ const exec = makeExecution({
450
+ id: "e1",
451
+ todos: { t1: { content: "write tests", status: TodoStatus.TODO_PENDING } },
452
+ });
453
+
454
+ const { rerender } = render(
455
+ <MessageThread executions={[exec]} slots={slots} />,
456
+ );
457
+ rerender(<MessageThread executions={[exec]} slots={slots} />);
458
+
459
+ // Same execution reference, same slots object: the renderer must not
460
+ // manufacture fresh props that defeat the slot's React.memo (DD-010).
461
+ expect(renderCount).toHaveBeenCalledTimes(1);
462
+ });
463
+ });
@@ -177,6 +177,30 @@ export function changeForRowPath(
177
177
  return null;
178
178
  }
179
179
 
180
+ /**
181
+ * The captured change a stamped transcript row should render as its inline
182
+ * diff, or `null` to keep the row's own content view. The row-diff sibling of
183
+ * {@link fileReviewRowState}: both resolve through {@link changeForRowPath},
184
+ * so the badge and the diff can never disagree about which file a row refers
185
+ * to. What renders is the set's captured NET change for the file — the same
186
+ * artifact the badge's verdict applies to.
187
+ *
188
+ * `null` follows the badge's honest-degradation discipline, plus one rule of
189
+ * its own: a non-`reviewable` change ({@link fileReviewability} — binary,
190
+ * secret-withheld, size-elided) resolves to `null` so the row falls back to
191
+ * showing the tool's own args content rather than a diff that cannot honestly
192
+ * render.
193
+ */
194
+ export function fileReviewRowChange(
195
+ set: FileChangeSet | undefined,
196
+ rowPath: string | null,
197
+ ): CapturedFileChange | null {
198
+ if (!set || !rowPath) return null;
199
+ const change = changeForRowPath(set, rowPath);
200
+ if (!change) return null;
201
+ return fileReviewability(change).kind === "reviewable" ? change : null;
202
+ }
203
+
180
204
  /**
181
205
  * The review state a stamped transcript row should badge — how this file edit
182
206
  * stands in its change set's review lifecycle. `null` means "show no badge",
@@ -169,7 +169,11 @@ export { MessageEntry } from "./MessageEntry.js";
169
169
  export type { MessageEntryProps } from "./MessageEntry.js";
170
170
 
171
171
  export { MessageThread } from "./MessageThread.js";
172
- export type { MessageThreadProps, ThreadContentColumn } from "./MessageThread.js";
172
+ export type {
173
+ MessageThreadProps,
174
+ MessageThreadSlots,
175
+ ThreadContentColumn,
176
+ } from "./MessageThread.js";
173
177
 
174
178
  export { ThreadSkeleton } from "./ThreadSkeleton.js";
175
179
  export type { ThreadSkeletonProps } from "./ThreadSkeleton.js";
@@ -182,11 +186,12 @@ export type { ExecutionProgressProps } from "./ExecutionProgress.js";
182
186
 
183
187
  export {
184
188
  TodoList,
189
+ TodoRow,
185
190
  TodoInProgressIcon,
186
191
  findActiveTodo,
187
192
  todoCompletionSummary,
188
193
  } from "./TodoList.js";
189
- export type { TodoListProps } from "./TodoList.js";
194
+ export type { TodoListProps, TodoRowProps } from "./TodoList.js";
190
195
 
191
196
  export { TodoCard } from "./TodoCard.js";
192
197
  export type { TodoCardProps } from "./TodoCard.js";
@@ -204,6 +209,7 @@ export {
204
209
  deriveEffectiveVerdicts,
205
210
  changeForRowPath,
206
211
  fileReviewRowState,
212
+ fileReviewRowChange,
207
213
  } from "./file-review-status.js";
208
214
  export type {
209
215
  FileReviewability,
@@ -239,7 +245,11 @@ export type { FileChangeProgressBarProps } from "./FileChangeProgressBar.js";
239
245
  export { ApprovalContext, useApproval } from "./ApprovalContext.js";
240
246
  export type { ApprovalContextValue, UseApprovalResult } from "./ApprovalContext.js";
241
247
 
242
- export { FileReviewContext, useFileReviewRowState } from "./FileReviewContext.js";
248
+ export {
249
+ FileReviewContext,
250
+ useFileReviewRowState,
251
+ useFileReviewRowChange,
252
+ } from "./FileReviewContext.js";
243
253
  export type { FileReviewContextValue } from "./FileReviewContext.js";
244
254
 
245
255
  export { ArtifactRow } from "./ArtifactRow.js";
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { renderHook } from "@testing-library/react";
3
3
  import { useGitHubFileReader } from "../useGitHubFileReader";
4
+ import { WorkspaceFileNotFoundError } from "../../workspace/WorkspaceFileReader";
4
5
  import type { WorkspaceEntry } from "../../workspace/useWorkspaceEntries";
5
6
 
6
7
  function gitEntry(overrides?: Partial<WorkspaceEntry>): WorkspaceEntry {
@@ -120,11 +121,43 @@ describe("useGitHubFileReader", () => {
120
121
  );
121
122
  });
122
123
 
123
- it("throws on a non-OK response (404 = failed to load, not unsupported)", async () => {
124
+ it("throws the typed WorkspaceFileNotFoundError on a 404", async () => {
124
125
  vi.mocked(globalThis.fetch).mockResolvedValue(new Response("nope", { status: 404 }));
125
126
 
126
127
  const { result } = renderHook(() => useGitHubFileReader("ghp_abc"));
127
- await expect(result.current!(gitEntry(), "missing.ts")).rejects.toThrow("HTTP 404");
128
+ await expect(result.current!(gitEntry(), "missing.ts")).rejects.toThrow(
129
+ WorkspaceFileNotFoundError,
130
+ );
131
+ });
132
+
133
+ it("throws a generic error (not the typed not-found) on other non-OK responses", async () => {
134
+ vi.mocked(globalThis.fetch).mockResolvedValue(new Response("boom", { status: 500 }));
135
+
136
+ const { result } = renderHook(() => useGitHubFileReader("ghp_abc"));
137
+ const error = await result.current!(gitEntry(), "src/index.ts").then(
138
+ () => null,
139
+ (e: unknown) => e,
140
+ );
141
+ expect(error).toBeInstanceOf(Error);
142
+ expect((error as Error).message).toContain("HTTP 500");
143
+ expect(error).not.toBeInstanceOf(WorkspaceFileNotFoundError);
144
+ });
145
+
146
+ it("reads at readRef in preference to gitBranch when both are set", async () => {
147
+ vi.mocked(globalThis.fetch).mockResolvedValue(
148
+ jsonResponse({ encoding: "base64", size: 1, content: b64("x"), sha: "s1" }),
149
+ );
150
+
151
+ const { result } = renderHook(() => useGitHubFileReader("ghp_abc"));
152
+ await result.current!(
153
+ gitEntry({ gitBranch: "main", readRef: "abc123def456" }),
154
+ "notes.md",
155
+ );
156
+
157
+ expect(globalThis.fetch).toHaveBeenCalledWith(
158
+ expect.stringContaining("?ref=abc123def456"),
159
+ expect.anything(),
160
+ );
128
161
  });
129
162
 
130
163
  it("throws when the path is a directory (Contents API returns an array)", async () => {
@@ -148,6 +148,20 @@ describe("useGitHubTreeLister", () => {
148
148
  );
149
149
  });
150
150
 
151
+ it("lists at readRef in preference to gitBranch when both are set", async () => {
152
+ vi.mocked(globalThis.fetch).mockResolvedValue(
153
+ new Response(JSON.stringify(SAMPLE_TREE_RESPONSE), { status: 200 }),
154
+ );
155
+
156
+ const { result } = renderHook(() => useGitHubTreeLister("ghp_abc"));
157
+ await result.current!(gitEntry({ gitBranch: "main", readRef: "abc123def456" }));
158
+
159
+ expect(vi.mocked(globalThis.fetch)).toHaveBeenCalledWith(
160
+ expect.stringContaining("/git/trees/abc123def456?"),
161
+ expect.anything(),
162
+ );
163
+ });
164
+
151
165
  it("encodes branch names with special characters", async () => {
152
166
  vi.mocked(globalThis.fetch).mockResolvedValue(
153
167
  new Response(JSON.stringify(SAMPLE_TREE_RESPONSE), { status: 200 }),
@@ -2,9 +2,10 @@
2
2
 
3
3
  import { useCallback } from "react";
4
4
  import type { WorkspaceEntry } from "../workspace/useWorkspaceEntries.js";
5
- import type {
6
- WorkspaceFileContent,
7
- WorkspaceFileReader,
5
+ import {
6
+ WorkspaceFileNotFoundError,
7
+ type WorkspaceFileContent,
8
+ type WorkspaceFileReader,
8
9
  } from "../workspace/WorkspaceFileReader.js";
9
10
  import { base64ToBytes, normalizeGitHubContent } from "./decodeGitHubContent.js";
10
11
  import { parseGitUrl } from "./parseGitUrl.js";
@@ -58,7 +59,9 @@ function encodePath(path: string): string {
58
59
  * (non-git entry, missing/unparseable URL) — the "unsupported here" state.
59
60
  * - **Throws** on a real failure: a non-OK response, or a directory path
60
61
  * (which the Contents API returns as a JSON array). Consumers surface these
61
- * as an error, distinct from the null "unsupported" state.
62
+ * as an error, distinct from the null "unsupported" state. A 404 throws the
63
+ * typed {@link WorkspaceFileNotFoundError} so consumers can fall back to
64
+ * session-captured content for files not yet pushed to the ref.
62
65
  *
63
66
  * @example
64
67
  * ```tsx
@@ -80,14 +83,24 @@ export function useGitHubFileReader(
80
83
  const parsed = parseGitUrl(entry.gitUrl);
81
84
  if (!parsed) return null;
82
85
 
83
- const branch = entry.gitBranch || "main";
86
+ // readRef (the session's write-back commit SHA, when one exists) wins
87
+ // over the configured branch: agent-created files live on the pushed
88
+ // write-back commit, not the base branch. The Contents API accepts a
89
+ // branch name or a commit SHA interchangeably as `ref`.
90
+ const ref = entry.readRef || entry.gitBranch || "main";
84
91
  const url =
85
92
  `${GITHUB_API}/${parsed.owner}/${parsed.repo}/contents/${encodePath(path)}` +
86
- `?ref=${encodeURIComponent(branch)}`;
93
+ `?ref=${encodeURIComponent(ref)}`;
87
94
 
88
95
  const resp = await fetch(url, {
89
96
  headers: { Authorization: `Bearer ${token}` },
90
97
  });
98
+ // 404 is typed: a file that isn't at this ref (yet) is an expected,
99
+ // recoverable state — e.g. an agent-created file whose write-back
100
+ // hasn't pushed — that consumers handle differently from a failure.
101
+ if (resp.status === 404) {
102
+ throw new WorkspaceFileNotFoundError(path);
103
+ }
91
104
  if (!resp.ok) {
92
105
  throw new Error(
93
106
  `GitHub content fetch failed for ${path} (HTTP ${resp.status})`,