@stigmer/react 3.1.4 → 3.1.5

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 (118) 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/file-review-status.d.ts +15 -0
  22. package/execution/file-review-status.d.ts.map +1 -1
  23. package/execution/file-review-status.js +22 -0
  24. package/execution/file-review-status.js.map +1 -1
  25. package/execution/index.d.ts +5 -5
  26. package/execution/index.d.ts.map +1 -1
  27. package/execution/index.js +3 -3
  28. package/execution/index.js.map +1 -1
  29. package/github/useGitHubFileReader.d.ts +4 -2
  30. package/github/useGitHubFileReader.d.ts.map +1 -1
  31. package/github/useGitHubFileReader.js +16 -3
  32. package/github/useGitHubFileReader.js.map +1 -1
  33. package/github/useGitHubTreeLister.d.ts +2 -1
  34. package/github/useGitHubTreeLister.d.ts.map +1 -1
  35. package/github/useGitHubTreeLister.js +7 -3
  36. package/github/useGitHubTreeLister.js.map +1 -1
  37. package/index.d.ts +3 -3
  38. package/index.d.ts.map +1 -1
  39. package/index.js +3 -3
  40. package/index.js.map +1 -1
  41. package/internal/VirtualizedThread.d.ts +3 -2
  42. package/internal/VirtualizedThread.d.ts.map +1 -1
  43. package/internal/VirtualizedThread.js +3 -2
  44. package/internal/VirtualizedThread.js.map +1 -1
  45. package/package.json +4 -4
  46. package/session/SessionViewer.d.ts +11 -1
  47. package/session/SessionViewer.d.ts.map +1 -1
  48. package/session/SessionViewer.js +12 -5
  49. package/session/SessionViewer.js.map +1 -1
  50. package/session/index.d.ts +1 -0
  51. package/session/index.d.ts.map +1 -1
  52. package/session/index.js +1 -0
  53. package/session/index.js.map +1 -1
  54. package/session/useWorkspaceReadRefs.d.ts +45 -0
  55. package/session/useWorkspaceReadRefs.d.ts.map +1 -0
  56. package/session/useWorkspaceReadRefs.js +104 -0
  57. package/session/useWorkspaceReadRefs.js.map +1 -0
  58. package/src/execution/FileReviewContext.ts +27 -1
  59. package/src/execution/MessageThread.tsx +110 -22
  60. package/src/execution/TodoCard.tsx +20 -4
  61. package/src/execution/TodoList.tsx +26 -4
  62. package/src/execution/ToolCallDetail.tsx +69 -16
  63. package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
  64. package/src/execution/__tests__/file-review-status.test.ts +47 -0
  65. package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
  66. package/src/execution/file-review-status.ts +24 -0
  67. package/src/execution/index.ts +13 -3
  68. package/src/github/__tests__/useGitHubFileReader.test.ts +35 -2
  69. package/src/github/__tests__/useGitHubTreeLister.test.ts +14 -0
  70. package/src/github/useGitHubFileReader.ts +19 -6
  71. package/src/github/useGitHubTreeLister.ts +7 -3
  72. package/src/index.ts +4 -0
  73. package/src/internal/VirtualizedThread.tsx +5 -1
  74. package/src/session/SessionViewer.tsx +27 -2
  75. package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
  76. package/src/session/index.ts +2 -0
  77. package/src/session/useWorkspaceReadRefs.ts +118 -0
  78. package/src/workspace/FileViewer.tsx +144 -38
  79. package/src/workspace/WorkspaceFileReader.ts +21 -0
  80. package/src/workspace/__tests__/FileViewer.test.tsx +138 -3
  81. package/src/workspace/__tests__/useWorkspaceFileContent.test.tsx +20 -0
  82. package/src/workspace/__tests__/workspaceListingCache.test.ts +114 -0
  83. package/src/workspace/index.ts +4 -1
  84. package/src/workspace/useWorkspaceEntries.ts +11 -0
  85. package/src/workspace/useWorkspaceFileContent.ts +4 -1
  86. package/src/workspace/useWorkspaceFileSearch.ts +8 -3
  87. package/src/workspace/useWorkspaceFiles.ts +3 -2
  88. package/src/workspace/workspaceListingCache.ts +21 -7
  89. package/styles.css +1 -1
  90. package/workspace/FileViewer.d.ts +1 -1
  91. package/workspace/FileViewer.d.ts.map +1 -1
  92. package/workspace/FileViewer.js +55 -5
  93. package/workspace/FileViewer.js.map +1 -1
  94. package/workspace/WorkspaceFileReader.d.ts +17 -0
  95. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  96. package/workspace/WorkspaceFileReader.js +18 -0
  97. package/workspace/WorkspaceFileReader.js.map +1 -1
  98. package/workspace/index.d.ts +1 -1
  99. package/workspace/index.d.ts.map +1 -1
  100. package/workspace/index.js +1 -1
  101. package/workspace/index.js.map +1 -1
  102. package/workspace/useWorkspaceEntries.d.ts +11 -0
  103. package/workspace/useWorkspaceEntries.d.ts.map +1 -1
  104. package/workspace/useWorkspaceEntries.js.map +1 -1
  105. package/workspace/useWorkspaceFileContent.d.ts.map +1 -1
  106. package/workspace/useWorkspaceFileContent.js +4 -1
  107. package/workspace/useWorkspaceFileContent.js.map +1 -1
  108. package/workspace/useWorkspaceFileSearch.d.ts.map +1 -1
  109. package/workspace/useWorkspaceFileSearch.js +8 -3
  110. package/workspace/useWorkspaceFileSearch.js.map +1 -1
  111. package/workspace/useWorkspaceFiles.d.ts +2 -1
  112. package/workspace/useWorkspaceFiles.d.ts.map +1 -1
  113. package/workspace/useWorkspaceFiles.js +3 -2
  114. package/workspace/useWorkspaceFiles.js.map +1 -1
  115. package/workspace/workspaceListingCache.d.ts +1 -1
  116. package/workspace/workspaceListingCache.d.ts.map +1 -1
  117. package/workspace/workspaceListingCache.js +20 -7
  118. package/workspace/workspaceListingCache.js.map +1 -1
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useMemo } from "react";
3
+ import { useMemo, type ComponentType } from "react";
4
4
  import type { TodoItem } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/todo_pb";
5
5
  import { TodoStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
6
6
  import { cn } from "@stigmer/theme";
@@ -14,6 +14,12 @@ export interface TodoListProps {
14
14
  readonly todos: { readonly [key: string]: TodoItem };
15
15
  /** Additional CSS class names for the root container. */
16
16
  readonly className?: string;
17
+ /**
18
+ * Replacement row component. Receives {@link TodoRowProps} for each
19
+ * item; defaults to the built-in {@link TodoRow}. Renders inside a
20
+ * `<ul role="list">`, so the component must produce an `<li>`.
21
+ */
22
+ readonly TodoRow?: ComponentType<TodoRowProps>;
17
23
  }
18
24
 
19
25
  const STATUS_SORT_ORDER: ReadonlyMap<TodoStatus, number> = new Map([
@@ -47,7 +53,11 @@ function todoSortKey(item: TodoItem): number {
47
53
  * <TodoList todos={todos} />
48
54
  * ```
49
55
  */
50
- export function TodoList({ todos, className }: TodoListProps) {
56
+ export function TodoList({
57
+ todos,
58
+ className,
59
+ TodoRow: RowComponent = TodoRow,
60
+ }: TodoListProps) {
51
61
  const sortedTodos = useMemo(() => {
52
62
  const items = Object.values(todos);
53
63
  if (items.length === 0) return [];
@@ -63,7 +73,7 @@ export function TodoList({ todos, className }: TodoListProps) {
63
73
  aria-label="Tasks"
64
74
  >
65
75
  {sortedTodos.map((item) => (
66
- <TodoRow key={item.id} item={item} />
76
+ <RowComponent key={item.id} item={item} />
67
77
  ))}
68
78
  </ul>
69
79
  );
@@ -108,7 +118,19 @@ export function todoCompletionSummary(
108
118
  // TodoRow — single todo item with status icon
109
119
  // ---------------------------------------------------------------------------
110
120
 
111
- function TodoRow({ item }: { readonly item: TodoItem }) {
121
+ /** Props for {@link TodoRow}. */
122
+ export interface TodoRowProps {
123
+ /** The todo item to render. */
124
+ readonly item: TodoItem;
125
+ }
126
+
127
+ /**
128
+ * One to-do checklist row: status icon plus task description, rendered as
129
+ * an `<li>`. The default row for {@link TodoList} — replace it via the
130
+ * `TodoRow` prop (or `MessageThread`'s `slots.TodoRow`) to restyle rows or
131
+ * bring host iconography while keeping the built-in card chrome.
132
+ */
133
+ export function TodoRow({ item }: TodoRowProps) {
112
134
  const Icon = TODO_ICONS[item.status] ?? TodoPendingIcon;
113
135
  const colorClass = TODO_COLORS[item.status] ?? "text-muted-foreground";
114
136
  const cancelled = item.status === TodoStatus.TODO_CANCELLED;
@@ -1,9 +1,14 @@
1
1
  "use client";
2
2
 
3
+ import { useMemo } from "react";
4
+ import type { CapturedFileChange } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
3
5
  import type { ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
6
+ import { toDisplayFileChange } from "@stigmer/sdk";
4
7
  import type { ToolResultView } from "@stigmer/sdk";
5
8
  import { cn } from "@stigmer/theme";
6
9
  import { BoundedContent } from "../internal/BoundedContent.js";
10
+ import { FileChangeDiff } from "./FileChangesView.js";
11
+ import { useFileReviewRowChange } from "./FileReviewContext.js";
7
12
  import { McpToolDetail } from "./McpToolDetail.js";
8
13
  import { ToolArgsView } from "./ToolArgsView.js";
9
14
  import { ResultView } from "./ResultView.js";
@@ -43,8 +48,9 @@ export interface ToolCallDetailProps {
43
48
  * degrade to readable JSON instead of a raw dump.
44
49
  *
45
50
  * Composition varies by category to avoid redundancy: an edit shows only the
46
- * diff (which already names the file and quantifies the change); a write shows
47
- * its input content; a read shows just the path.
51
+ * diff (which already names the file and quantifies the change); a stamped
52
+ * write shows its change set's captured diff, degrading to the proposed
53
+ * content when no capture resolves; a read shows just the path.
48
54
  *
49
55
  * Importable on its own by platform builders composing custom tool UIs.
50
56
  *
@@ -60,6 +66,16 @@ export function ToolCallDetail({
60
66
  }: ToolCallDetailProps) {
61
67
  const { category, result, primaryArg } = useToolPresentation(toolCall);
62
68
 
69
+ // The captured net change a stamped write row renders as its inline diff
70
+ // (see the write branch below). Resolved here unconditionally (Rules of
71
+ // Hooks); null for every other category's rows — they are never stamped —
72
+ // and for every honest-degradation case, so this costs nothing outside the
73
+ // stamped-write path.
74
+ const capturedChange = useFileReviewRowChange(
75
+ toolCall.fileChangeSetId,
76
+ primaryArg,
77
+ );
78
+
63
79
  return (
64
80
  <div className={cn("space-y-2 text-xs", className)}>
65
81
  <CategoryDetail
@@ -68,6 +84,7 @@ export function ToolCallDetail({
68
84
  result={result}
69
85
  primaryArg={primaryArg}
70
86
  primaryArgTruncated={primaryArgTruncated}
87
+ capturedChange={capturedChange}
71
88
  />
72
89
  </div>
73
90
  );
@@ -90,12 +107,14 @@ function CategoryDetail({
90
107
  result,
91
108
  primaryArg,
92
109
  primaryArgTruncated,
110
+ capturedChange,
93
111
  }: {
94
112
  toolCall: ToolCall;
95
113
  category: ToolCategory;
96
114
  result: ToolResultView;
97
115
  primaryArg: string | null;
98
116
  primaryArgTruncated: boolean;
117
+ capturedChange: CapturedFileChange | null;
99
118
  }) {
100
119
  const args = <ArgsSection toolCall={toolCall} />;
101
120
 
@@ -128,27 +147,50 @@ function CategoryDetail({
128
147
  );
129
148
 
130
149
  case "edit":
131
- case "write":
150
+ case "write": {
132
151
  // The diff already names the file and quantifies the change, so it stands
133
- // alone — showing the written content as an argument would duplicate it. A
134
- // write whose capture is unavailable degrades to a `file` result here
135
- // (ResultView shows the content), so the input is never lost. The owning
136
- // row already names the file, so the body suppresses the path.
152
+ // alone — showing the written content as an argument would duplicate it.
153
+ // The owning row already names the file, so the body suppresses the path.
137
154
  //
138
- // The diff is the one tool body with no internal truncation of its own, so
139
- // it is bounded by the shared BoundedContent budget (the same clamp the
140
- // approval gate diff uses) a large or still-streaming edit shows a
141
- // consistent, scannable window with one in-place reveal, never the whole
142
- // file. Every other category's body self-truncates (terminal / text via
143
- // CollapsiblePre), so only edit/write wrap here never double-bound.
155
+ // An EDIT's diff is reconstructed per-call from its own args (old/new
156
+ // strings, or the Cursor envelope's unified diff). A whole-file WRITE has
157
+ // no per-call before-image in its args, so a stamped write renders the
158
+ // captured NET change from its change set instead the same artifact the
159
+ // row's review badge refers to, resolved through the same path matcher so
160
+ // the two can never disagree. An unresolvable write (still streaming
161
+ // before the turn-boundary capture, no capture mode, legacy session, or a
162
+ // non-reviewable change) keeps the honest fallback: the proposed content
163
+ // from the args, as a `file` view.
164
+ if (category === "write" && capturedChange) {
165
+ return (
166
+ <>
167
+ <ProvenanceNote toolCall={toolCall} />
168
+ <CapturedRowDiff change={capturedChange} />
169
+ </>
170
+ );
171
+ }
172
+ // Bounding is keyed on the VIEW, not the category: BoundedContent is for
173
+ // bodies with no internal truncation of their own — the diff and a FAILED
174
+ // call's error `<pre>` — giving a large or still-streaming edit the same
175
+ // scannable clamp the approval gate uses. The `file` view self-truncates
176
+ // (CollapsibleCode's line cap), so wrapping it would stack two reveal
177
+ // controls on one block — exactly what BoundedContent's contract forbids.
178
+ const body = (
179
+ <ResultView view={result} showFileName={false} showStats={false} />
180
+ );
144
181
  return (
145
182
  <>
146
183
  <ProvenanceNote toolCall={toolCall} />
147
- <BoundedContent cursorTarget="tool-detail-expand">
148
- <ResultView view={result} showFileName={false} showStats={false} />
149
- </BoundedContent>
184
+ {result.type === "file" ? (
185
+ body
186
+ ) : (
187
+ <BoundedContent cursorTarget="tool-detail-expand">
188
+ {body}
189
+ </BoundedContent>
190
+ )}
150
191
  </>
151
192
  );
193
+ }
152
194
 
153
195
  case "read":
154
196
  case "delete":
@@ -245,6 +287,17 @@ function ThinkToolDetail({ toolCall }: { toolCall: ToolCall }) {
245
287
  // Shared sub-components
246
288
  // ---------------------------------------------------------------------------
247
289
 
290
+ // A stamped write row's inline diff: the captured change projected onto the
291
+ // display FileChange and rendered through the same bounded FileChangeDiff the
292
+ // review card's body uses — offloaded-body fetch, binary notices, and
293
+ // truncation states included. The file name is suppressed (the owning row
294
+ // header names it); the `+N −M` stats stay because a write row's header
295
+ // summary carries none.
296
+ function CapturedRowDiff({ change }: { change: CapturedFileChange }) {
297
+ const adapted = useMemo(() => toDisplayFileChange(change), [change]);
298
+ return <FileChangeDiff change={adapted} bounded showFileName={false} />;
299
+ }
300
+
248
301
  // The detail body's only metadata. Duration and the MCP slug live in the owning
249
302
  // row header (ToolCallItem / ApprovalCardHeader), so the body never restates
250
303
  // them; it surfaces the authorization provenance the runner stamped onto the
@@ -1,15 +1,47 @@
1
- import { describe, it, expect, afterEach } from "vitest";
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
2
2
  import { render, cleanup } from "@testing-library/react";
3
3
  import { create, type JsonObject } from "@bufbuild/protobuf";
4
4
  import {
5
5
  ToolCallSchema,
6
6
  type ToolCall,
7
7
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
8
+ import {
9
+ CapturedFileChangeSchema,
10
+ FileChangeSetSchema,
11
+ type FileChangeSet,
12
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
8
13
  import {
9
14
  ApprovalPolicySource,
15
+ FileChangeKind,
16
+ FileReviewBlockReason,
17
+ ToolCallStatus,
10
18
  ToolKind,
11
19
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
12
- import { ToolCallDetail } from "../ToolCallDetail";
20
+ import type { UseFileChangeContentReturn } from "../useFileChangeContent";
21
+
22
+ // Mock the diff-body content resolver (the seam FileChangesView.test.tsx also
23
+ // mocks): FileChangeDiff calls it unconditionally, and its real implementation
24
+ // needs a Stigmer client for offloaded bodies — irrelevant to these tests.
25
+ let mockContent: UseFileChangeContentReturn;
26
+
27
+ vi.mock("../useFileChangeContent", () => ({
28
+ useFileChangeContent: () => mockContent,
29
+ }));
30
+
31
+ const { ToolCallDetail } = await import("../ToolCallDetail");
32
+ const { FileReviewContext } = await import("../FileReviewContext");
33
+
34
+ beforeEach(() => {
35
+ mockContent = {
36
+ beforeText: "alpha\n",
37
+ afterText: "captured-after\n",
38
+ isBinary: false,
39
+ isLoading: false,
40
+ error: null,
41
+ isTruncated: false,
42
+ downloadUrl: null,
43
+ };
44
+ });
13
45
 
14
46
  afterEach(cleanup);
15
47
 
@@ -21,6 +53,9 @@ function makeToolCall(opts: {
21
53
  approvalPolicySource?: ApprovalPolicySource;
22
54
  startedAt?: string;
23
55
  completedAt?: string;
56
+ status?: ToolCallStatus;
57
+ error?: string;
58
+ fileChangeSetId?: string;
24
59
  }): ToolCall {
25
60
  return create(ToolCallSchema, {
26
61
  id: opts.name,
@@ -32,6 +67,9 @@ function makeToolCall(opts: {
32
67
  opts.approvalPolicySource ?? ApprovalPolicySource.UNSPECIFIED,
33
68
  startedAt: opts.startedAt ?? "",
34
69
  completedAt: opts.completedAt ?? "",
70
+ status: opts.status ?? ToolCallStatus.TOOL_CALL_COMPLETED,
71
+ error: opts.error ?? "",
72
+ fileChangeSetId: opts.fileChangeSetId ?? "",
35
73
  });
36
74
  }
37
75
 
@@ -193,3 +231,151 @@ describe("ToolCallDetail — edit body has no duplicate stats", () => {
193
231
  expect(container.textContent).not.toContain("+1");
194
232
  });
195
233
  });
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Write body — captured diff on a stamped row, single-bounded fallback
237
+ // ---------------------------------------------------------------------------
238
+
239
+ describe("ToolCallDetail — write body", () => {
240
+ const SET_ID = "aex-1:0";
241
+ // BoundedContent's collapsed pixel clamp (PREVIEW_MAX_HEIGHT). Its presence/
242
+ // absence is how these tests pin WHICH truncation mechanism bounds the body —
243
+ // jsdom has no layout, so the reveal control itself only appears for the
244
+ // line-count mechanism (CollapsibleCode), never for the pixel clamp.
245
+ const CLAMP = ".max-h-48";
246
+
247
+ /** A change set capturing one reviewable modify of `path`. */
248
+ function capturedSet(
249
+ path = "notes.md",
250
+ overrides?: { diffComplete?: boolean; blockedReason?: FileReviewBlockReason },
251
+ ): FileChangeSet {
252
+ return create(FileChangeSetSchema, {
253
+ id: SET_ID,
254
+ changes: [
255
+ create(CapturedFileChangeSchema, {
256
+ id: `${SET_ID}:${path}`,
257
+ pathBefore: path,
258
+ pathAfter: path,
259
+ kind: FileChangeKind.MODIFY,
260
+ diffComplete: overrides?.diffComplete ?? true,
261
+ blockedReason:
262
+ overrides?.blockedReason ?? FileReviewBlockReason.UNSPECIFIED,
263
+ }),
264
+ ],
265
+ });
266
+ }
267
+
268
+ function writeCall(opts?: Partial<Parameters<typeof makeToolCall>[0]>): ToolCall {
269
+ return makeToolCall({
270
+ name: "write_file",
271
+ toolKind: ToolKind.FILE_WRITE,
272
+ args: { file_path: "notes.md", contents: "proposed body\n" },
273
+ result: "Successfully wrote to 'notes.md'",
274
+ ...opts,
275
+ });
276
+ }
277
+
278
+ function renderWithSet(toolCall: ToolCall, set: FileChangeSet | null) {
279
+ const changeSetsById = new Map(set ? [[SET_ID, set]] : []);
280
+ return render(
281
+ <FileReviewContext.Provider value={{ changeSetsById }}>
282
+ <ToolCallDetail toolCall={toolCall} />
283
+ </FileReviewContext.Provider>,
284
+ );
285
+ }
286
+
287
+ it("renders the captured net diff (not the Content block) on a stamped row", () => {
288
+ const { container } = renderWithSet(
289
+ writeCall({ fileChangeSetId: SET_ID }),
290
+ capturedSet(),
291
+ );
292
+ // The captured before/after renders as a real diff table...
293
+ expect(container.querySelector('[data-cursor-target="file-diff"]')).not.toBeNull();
294
+ expect(container.textContent).toContain("captured-after");
295
+ // ...bounded by the shared pixel clamp, with the +N -M stats the write
296
+ // row's header does not carry, and without the fallback's raw args body.
297
+ // (The diff table itself carries a hidden a11y column header named
298
+ // "Content", so the discriminator is the body text, not the label.)
299
+ expect(container.querySelector(CLAMP)).not.toBeNull();
300
+ expect(container.textContent).toContain("+1");
301
+ expect(container.textContent).not.toContain("proposed body");
302
+ });
303
+
304
+ it("renders exactly one reveal control for the content fallback (double-expander regression)", () => {
305
+ const longBody = Array.from({ length: 55 }, (_, i) => `line ${i + 1}`).join("\n");
306
+ const { container, getAllByRole } = render(
307
+ <ToolCallDetail
308
+ toolCall={writeCall({ args: { file_path: "notes.md", contents: longBody } })}
309
+ />,
310
+ );
311
+ // The file view self-truncates (line-count mechanism, one control)...
312
+ const reveals = getAllByRole("button").filter((b) =>
313
+ /^Show /.test(b.textContent ?? ""),
314
+ );
315
+ expect(reveals).toHaveLength(1);
316
+ expect(reveals[0].textContent).toContain("Show all 55 lines");
317
+ // ...and is never ALSO wrapped in BoundedContent's pixel clamp.
318
+ expect(container.querySelector(CLAMP)).toBeNull();
319
+ });
320
+
321
+ it("falls back to the Content block for every unresolvable capture", () => {
322
+ const cases: Array<[string, ToolCall, FileChangeSet | null]> = [
323
+ ["unstamped row", writeCall(), capturedSet()],
324
+ ["set missing from context", writeCall({ fileChangeSetId: SET_ID }), null],
325
+ [
326
+ "path not in set",
327
+ writeCall({ fileChangeSetId: SET_ID }),
328
+ capturedSet("other.md"),
329
+ ],
330
+ [
331
+ "non-reviewable (secret-withheld) change",
332
+ writeCall({ fileChangeSetId: SET_ID }),
333
+ capturedSet("notes.md", {
334
+ diffComplete: false,
335
+ blockedReason: FileReviewBlockReason.SECRET_WITHHELD,
336
+ }),
337
+ ],
338
+ ];
339
+ for (const [label, toolCall, set] of cases) {
340
+ const { container } = renderWithSet(toolCall, set);
341
+ expect(container.textContent, label).toContain("Content");
342
+ expect(container.textContent, label).toContain("proposed body");
343
+ expect(
344
+ container.querySelector('[data-cursor-target="file-diff"]'),
345
+ label,
346
+ ).toBeNull();
347
+ cleanup();
348
+ }
349
+ });
350
+
351
+ it("keeps a FAILED write's error view inside the pixel clamp (bounding-rule lock)", () => {
352
+ const { container } = render(
353
+ <ToolCallDetail
354
+ toolCall={writeCall({
355
+ status: ToolCallStatus.TOOL_CALL_FAILED,
356
+ error: "EACCES: permission denied, open 'notes.md'",
357
+ })}
358
+ />,
359
+ );
360
+ expect(container.textContent).toContain("permission denied");
361
+ // The error <pre> has no internal truncation, so BoundedContent bounds it.
362
+ expect(container.querySelector(CLAMP)).not.toBeNull();
363
+ });
364
+
365
+ it("leaves a stamped EDIT row on its per-call args diff, never the captured net diff", () => {
366
+ const edit = makeToolCall({
367
+ name: "edit_file",
368
+ toolKind: ToolKind.FILE_EDIT,
369
+ args: { file_path: "notes.md", old_string: "alpha", new_string: "edit-args-b" },
370
+ result: "Successfully replaced 1 occurrence in 'notes.md'",
371
+ fileChangeSetId: SET_ID,
372
+ });
373
+ const { container } = renderWithSet(edit, capturedSet());
374
+ // The per-call diff from the row's own args renders...
375
+ expect(container.textContent).toContain("edit-args-b");
376
+ // ...not the change set's captured net content...
377
+ expect(container.textContent).not.toContain("captured-after");
378
+ // ...still inside the shared pixel clamp (unchanged behavior).
379
+ expect(container.querySelector(CLAMP)).not.toBeNull();
380
+ });
381
+ });
@@ -24,6 +24,7 @@ import {
24
24
  changeSetReviewability,
25
25
  deriveEffectiveVerdicts,
26
26
  fileReviewability,
27
+ fileReviewRowChange,
27
28
  fileReviewRowState,
28
29
  } from "../file-review-status";
29
30
 
@@ -291,6 +292,52 @@ describe("changeForRowPath", () => {
291
292
  });
292
293
  });
293
294
 
295
+ describe("fileReviewRowChange", () => {
296
+ it("returns null with no set or path", () => {
297
+ const set = reviewSet({
298
+ status: FileChangeSetStatus.AWAITING_REVIEW,
299
+ changes: [fileChange("f1", "a.ts")],
300
+ });
301
+ expect(fileReviewRowChange(undefined, "a.ts")).toBeNull();
302
+ expect(fileReviewRowChange(set, null)).toBeNull();
303
+ expect(fileReviewRowChange(set, "")).toBeNull();
304
+ });
305
+
306
+ it("resolves a reviewable change by row path (same matcher as the badge)", () => {
307
+ const set = reviewSet({
308
+ status: FileChangeSetStatus.AWAITING_REVIEW,
309
+ changes: [fileChange("f1", "src/a.ts")],
310
+ });
311
+ expect(fileReviewRowChange(set, "src/a.ts")?.id).toBe("f1");
312
+ expect(fileReviewRowChange(set, "/home/user/ws/src/a.ts")?.id).toBe("f1");
313
+ });
314
+
315
+ it("returns null for a path absent from the set", () => {
316
+ const set = reviewSet({
317
+ status: FileChangeSetStatus.AWAITING_REVIEW,
318
+ changes: [fileChange("f1", "a.ts")],
319
+ });
320
+ expect(fileReviewRowChange(set, "gone.ts")).toBeNull();
321
+ });
322
+
323
+ it("returns null for a non-reviewable change (honest degradation)", () => {
324
+ const set = reviewSet({
325
+ status: FileChangeSetStatus.AWAITING_REVIEW,
326
+ changes: [
327
+ create(CapturedFileChangeSchema, {
328
+ id: "f1",
329
+ pathBefore: "secret.env",
330
+ pathAfter: "secret.env",
331
+ kind: FileChangeKind.MODIFY,
332
+ diffComplete: false,
333
+ blockedReason: FileReviewBlockReason.SECRET_WITHHELD,
334
+ }),
335
+ ],
336
+ });
337
+ expect(fileReviewRowChange(set, "secret.env")).toBeNull();
338
+ });
339
+ });
340
+
294
341
  describe("fileReviewRowState", () => {
295
342
  it("returns null with no set (not yet projected / unknown id)", () => {
296
343
  expect(fileReviewRowState(undefined, "a.ts")).toBeNull();