@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
|
@@ -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",
|
package/src/execution/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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(
|
|
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})`,
|