@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.
- package/execution/FileReviewContext.d.ts +15 -1
- package/execution/FileReviewContext.d.ts.map +1 -1
- package/execution/FileReviewContext.js +20 -1
- package/execution/FileReviewContext.js.map +1 -1
- package/execution/MessageThread.d.ts +65 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +42 -23
- package/execution/MessageThread.js.map +1 -1
- package/execution/TodoCard.d.ts +8 -0
- package/execution/TodoCard.d.ts.map +1 -1
- package/execution/TodoCard.js +6 -4
- package/execution/TodoCard.js.map +1 -1
- package/execution/TodoList.d.ts +20 -1
- package/execution/TodoList.d.ts.map +1 -1
- package/execution/TodoList.js +9 -6
- package/execution/TodoList.js.map +1 -1
- package/execution/ToolCallDetail.d.ts +3 -2
- package/execution/ToolCallDetail.d.ts.map +1 -1
- package/execution/ToolCallDetail.js +49 -16
- package/execution/ToolCallDetail.js.map +1 -1
- package/execution/file-review-status.d.ts +15 -0
- package/execution/file-review-status.d.ts.map +1 -1
- package/execution/file-review-status.js +22 -0
- package/execution/file-review-status.js.map +1 -1
- package/execution/index.d.ts +5 -5
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +3 -3
- package/execution/index.js.map +1 -1
- package/github/useGitHubFileReader.d.ts +4 -2
- package/github/useGitHubFileReader.d.ts.map +1 -1
- package/github/useGitHubFileReader.js +16 -3
- package/github/useGitHubFileReader.js.map +1 -1
- package/github/useGitHubTreeLister.d.ts +2 -1
- package/github/useGitHubTreeLister.d.ts.map +1 -1
- package/github/useGitHubTreeLister.js +7 -3
- package/github/useGitHubTreeLister.js.map +1 -1
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +3 -3
- package/index.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +3 -2
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +3 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.d.ts +11 -1
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +12 -5
- package/session/SessionViewer.js.map +1 -1
- package/session/index.d.ts +1 -0
- package/session/index.d.ts.map +1 -1
- package/session/index.js +1 -0
- package/session/index.js.map +1 -1
- package/session/useWorkspaceReadRefs.d.ts +45 -0
- package/session/useWorkspaceReadRefs.d.ts.map +1 -0
- package/session/useWorkspaceReadRefs.js +104 -0
- package/session/useWorkspaceReadRefs.js.map +1 -0
- package/src/execution/FileReviewContext.ts +27 -1
- package/src/execution/MessageThread.tsx +110 -22
- package/src/execution/TodoCard.tsx +20 -4
- package/src/execution/TodoList.tsx +26 -4
- package/src/execution/ToolCallDetail.tsx +69 -16
- package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
- package/src/execution/__tests__/file-review-status.test.ts +47 -0
- package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
- package/src/execution/file-review-status.ts +24 -0
- package/src/execution/index.ts +13 -3
- package/src/github/__tests__/useGitHubFileReader.test.ts +35 -2
- package/src/github/__tests__/useGitHubTreeLister.test.ts +14 -0
- package/src/github/useGitHubFileReader.ts +19 -6
- package/src/github/useGitHubTreeLister.ts +7 -3
- package/src/index.ts +4 -0
- package/src/internal/VirtualizedThread.tsx +5 -1
- package/src/session/SessionViewer.tsx +27 -2
- package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
- package/src/session/index.ts +2 -0
- package/src/session/useWorkspaceReadRefs.ts +118 -0
- package/src/workspace/FileViewer.tsx +144 -38
- package/src/workspace/WorkspaceFileReader.ts +21 -0
- package/src/workspace/__tests__/FileViewer.test.tsx +138 -3
- package/src/workspace/__tests__/useWorkspaceFileContent.test.tsx +20 -0
- package/src/workspace/__tests__/workspaceListingCache.test.ts +114 -0
- package/src/workspace/index.ts +4 -1
- package/src/workspace/useWorkspaceEntries.ts +11 -0
- package/src/workspace/useWorkspaceFileContent.ts +4 -1
- package/src/workspace/useWorkspaceFileSearch.ts +8 -3
- package/src/workspace/useWorkspaceFiles.ts +3 -2
- package/src/workspace/workspaceListingCache.ts +21 -7
- package/styles.css +1 -1
- package/workspace/FileViewer.d.ts +1 -1
- package/workspace/FileViewer.d.ts.map +1 -1
- package/workspace/FileViewer.js +55 -5
- package/workspace/FileViewer.js.map +1 -1
- package/workspace/WorkspaceFileReader.d.ts +17 -0
- package/workspace/WorkspaceFileReader.d.ts.map +1 -1
- package/workspace/WorkspaceFileReader.js +18 -0
- package/workspace/WorkspaceFileReader.js.map +1 -1
- package/workspace/index.d.ts +1 -1
- package/workspace/index.d.ts.map +1 -1
- package/workspace/index.js +1 -1
- package/workspace/index.js.map +1 -1
- package/workspace/useWorkspaceEntries.d.ts +11 -0
- package/workspace/useWorkspaceEntries.d.ts.map +1 -1
- package/workspace/useWorkspaceEntries.js.map +1 -1
- package/workspace/useWorkspaceFileContent.d.ts.map +1 -1
- package/workspace/useWorkspaceFileContent.js +4 -1
- package/workspace/useWorkspaceFileContent.js.map +1 -1
- package/workspace/useWorkspaceFileSearch.d.ts.map +1 -1
- package/workspace/useWorkspaceFileSearch.js +8 -3
- package/workspace/useWorkspaceFileSearch.js.map +1 -1
- package/workspace/useWorkspaceFiles.d.ts +2 -1
- package/workspace/useWorkspaceFiles.d.ts.map +1 -1
- package/workspace/useWorkspaceFiles.js +3 -2
- package/workspace/useWorkspaceFiles.js.map +1 -1
- package/workspace/workspaceListingCache.d.ts +1 -1
- package/workspace/workspaceListingCache.d.ts.map +1 -1
- package/workspace/workspaceListingCache.js +20 -7
- 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({
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
|
47
|
-
* its
|
|
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.
|
|
134
|
-
//
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 {
|
|
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();
|