@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
|
@@ -11,14 +11,21 @@ import {
|
|
|
11
11
|
} from "react";
|
|
12
12
|
import { cn } from "@stigmer/theme";
|
|
13
13
|
import type { FileChange } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
FileChangeCaptureLevel,
|
|
16
|
+
FileChangeType,
|
|
17
|
+
} from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
15
18
|
import { ArtifactContentRenderer } from "../execution/ArtifactContentRenderer.js";
|
|
16
19
|
import { FileChangeDiff } from "../execution/FileChangesView.js";
|
|
20
|
+
import { useFileChangeContent } from "../execution/useFileChangeContent.js";
|
|
17
21
|
import type { RevealTarget } from "../internal/useRevealLine.js";
|
|
18
22
|
import type { SelectedWorkspaceFile } from "../internal/store/workspace-file-selection-store.js";
|
|
19
23
|
import { useWorkspaceFileContent } from "./useWorkspaceFileContent.js";
|
|
20
24
|
import type { WorkspaceEntry } from "./useWorkspaceEntries.js";
|
|
21
|
-
import
|
|
25
|
+
import {
|
|
26
|
+
WorkspaceFileNotFoundError,
|
|
27
|
+
type WorkspaceFileReader,
|
|
28
|
+
} from "./WorkspaceFileReader.js";
|
|
22
29
|
|
|
23
30
|
/** Line-skeleton widths, mirroring `FileContentStateView` in `ArtifactPreviewModal`. */
|
|
24
31
|
const SKELETON_LINE_WIDTHS = [85, 72, 90, 65, 78, 88, 70, 82] as const;
|
|
@@ -218,22 +225,16 @@ export const FileViewer = forwardRef<FileViewerHandle, FileViewerProps>(
|
|
|
218
225
|
<FileChangeDiff change={change} showFileName={false} showStats />
|
|
219
226
|
</div>
|
|
220
227
|
) : (
|
|
221
|
-
|
|
222
|
-
{
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
isUnsupported={isUnsupported}
|
|
232
|
-
error={error}
|
|
233
|
-
onRetry={refetch}
|
|
234
|
-
reveal={reveal}
|
|
235
|
-
/>
|
|
236
|
-
</>
|
|
228
|
+
<FileViewerBody
|
|
229
|
+
basename={basename}
|
|
230
|
+
content={content}
|
|
231
|
+
isLoading={isLoading}
|
|
232
|
+
isUnsupported={isUnsupported}
|
|
233
|
+
error={error}
|
|
234
|
+
onRetry={refetch}
|
|
235
|
+
reveal={reveal}
|
|
236
|
+
change={change}
|
|
237
|
+
/>
|
|
237
238
|
)}
|
|
238
239
|
</div>
|
|
239
240
|
</div>
|
|
@@ -328,6 +329,23 @@ function ViewerModeToggle({
|
|
|
328
329
|
// Body — exactly one state, mirroring FileContentStateView + workspace extras
|
|
329
330
|
// ---------------------------------------------------------------------------
|
|
330
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Whether the session captured this change's full after-content — the
|
|
334
|
+
* precondition for serving it when the live substrate can't (a cloud file not
|
|
335
|
+
* yet pushed to any readable ref). A DELETE has no after side, and a
|
|
336
|
+
* HUNK_ONLY capture carries only the diff, never the whole file.
|
|
337
|
+
*/
|
|
338
|
+
function hasCapturedAfterContent(
|
|
339
|
+
change: FileChange | undefined,
|
|
340
|
+
): change is FileChange {
|
|
341
|
+
return (
|
|
342
|
+
change !== undefined &&
|
|
343
|
+
change.changeType !== FileChangeType.DELETE &&
|
|
344
|
+
change.captureLevel !== FileChangeCaptureLevel.HUNK_ONLY &&
|
|
345
|
+
change.after !== undefined
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
331
349
|
function FileViewerBody({
|
|
332
350
|
basename,
|
|
333
351
|
content,
|
|
@@ -336,6 +354,7 @@ function FileViewerBody({
|
|
|
336
354
|
error,
|
|
337
355
|
onRetry,
|
|
338
356
|
reveal,
|
|
357
|
+
change,
|
|
339
358
|
}: {
|
|
340
359
|
readonly basename: string;
|
|
341
360
|
readonly content: ReturnType<typeof useWorkspaceFileContent>["content"];
|
|
@@ -344,7 +363,20 @@ function FileViewerBody({
|
|
|
344
363
|
readonly error: Error | null;
|
|
345
364
|
readonly onRetry: () => void;
|
|
346
365
|
readonly reveal?: RevealTarget;
|
|
366
|
+
readonly change?: FileChange;
|
|
347
367
|
}) {
|
|
368
|
+
// A live read that can't be served (unsupported substrate, or the file
|
|
369
|
+
// isn't at the read ref yet) degrades to the session's captured
|
|
370
|
+
// after-content when the change carries it — the same bytes the Diff view
|
|
371
|
+
// renders, so nothing is fabricated. Other errors keep the retry path.
|
|
372
|
+
const isNotFound = error instanceof WorkspaceFileNotFoundError;
|
|
373
|
+
const canFallBackToCaptured =
|
|
374
|
+
(isUnsupported || isNotFound) && hasCapturedAfterContent(change);
|
|
375
|
+
|
|
376
|
+
if (canFallBackToCaptured) {
|
|
377
|
+
return <CapturedFileContent change={change} basename={basename} reveal={reveal} />;
|
|
378
|
+
}
|
|
379
|
+
|
|
348
380
|
// Unsupported comes first: a missing reader means there is nothing to load.
|
|
349
381
|
if (isUnsupported) {
|
|
350
382
|
return (
|
|
@@ -355,21 +387,25 @@ function FileViewerBody({
|
|
|
355
387
|
}
|
|
356
388
|
|
|
357
389
|
if (isLoading) {
|
|
390
|
+
return <LoadingSkeleton />;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (isNotFound) {
|
|
394
|
+
// Expected while the agent's write-back is still syncing to the ref —
|
|
395
|
+
// a calm notice (DD-006), not a failure.
|
|
358
396
|
return (
|
|
359
|
-
<div
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
/>
|
|
372
|
-
))}
|
|
397
|
+
<div className="flex flex-col items-center justify-center gap-2 p-8 text-center">
|
|
398
|
+
<p className="text-xs text-muted-foreground">
|
|
399
|
+
This file isn’t available in the workspace source yet. The
|
|
400
|
+
agent’s latest changes may still be syncing.
|
|
401
|
+
</p>
|
|
402
|
+
<button
|
|
403
|
+
type="button"
|
|
404
|
+
onClick={onRetry}
|
|
405
|
+
className="text-xs font-medium text-foreground underline underline-offset-2 hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-sm"
|
|
406
|
+
>
|
|
407
|
+
Check again
|
|
408
|
+
</button>
|
|
373
409
|
</div>
|
|
374
410
|
);
|
|
375
411
|
}
|
|
@@ -417,12 +453,82 @@ function FileViewerBody({
|
|
|
417
453
|
}
|
|
418
454
|
|
|
419
455
|
return (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
456
|
+
<>
|
|
457
|
+
{change && (
|
|
458
|
+
<p className="border-b border-border-muted px-4 py-1.5 text-[0.65rem] text-muted-foreground">
|
|
459
|
+
Live — may differ from the reviewed change.
|
|
460
|
+
</p>
|
|
461
|
+
)}
|
|
462
|
+
<ArtifactContentRenderer
|
|
463
|
+
content={content.text}
|
|
464
|
+
fileName={basename}
|
|
465
|
+
isTruncated={content.truncated}
|
|
466
|
+
reveal={reveal}
|
|
467
|
+
/>
|
|
468
|
+
</>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* The captured-content fallback: renders a change's after-side text (the
|
|
474
|
+
* bytes the session already holds — inline or offloaded to artifact storage)
|
|
475
|
+
* when no live substrate can serve the file. Mounted only when eligible, so
|
|
476
|
+
* its content fetch never runs for live-served files.
|
|
477
|
+
*/
|
|
478
|
+
function CapturedFileContent({
|
|
479
|
+
change,
|
|
480
|
+
basename,
|
|
481
|
+
reveal,
|
|
482
|
+
}: {
|
|
483
|
+
readonly change: FileChange;
|
|
484
|
+
readonly basename: string;
|
|
485
|
+
readonly reveal?: RevealTarget;
|
|
486
|
+
}) {
|
|
487
|
+
const { afterText, isBinary, isLoading, error } = useFileChangeContent(change);
|
|
488
|
+
|
|
489
|
+
if (isLoading) {
|
|
490
|
+
return <LoadingSkeleton />;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (isBinary) {
|
|
494
|
+
return <MessageState>Binary file — not shown.</MessageState>;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (error || afterText === null) {
|
|
498
|
+
return <MessageState>Content not available for preview.</MessageState>;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<>
|
|
503
|
+
<p className="border-b border-border-muted px-4 py-1.5 text-[0.65rem] text-muted-foreground">
|
|
504
|
+
As of the agent’s last change — not yet in the workspace source.
|
|
505
|
+
</p>
|
|
506
|
+
<ArtifactContentRenderer
|
|
507
|
+
content={afterText}
|
|
508
|
+
fileName={basename}
|
|
509
|
+
reveal={reveal}
|
|
510
|
+
/>
|
|
511
|
+
</>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function LoadingSkeleton() {
|
|
516
|
+
return (
|
|
517
|
+
<div
|
|
518
|
+
role="status"
|
|
519
|
+
className="space-y-2 p-4"
|
|
520
|
+
aria-busy="true"
|
|
521
|
+
aria-label="Loading file"
|
|
522
|
+
>
|
|
523
|
+
{SKELETON_LINE_WIDTHS.map((width, i) => (
|
|
524
|
+
<div
|
|
525
|
+
key={i}
|
|
526
|
+
className="h-4 animate-pulse rounded bg-muted"
|
|
527
|
+
style={{ width: `${width}%` }}
|
|
528
|
+
aria-hidden="true"
|
|
529
|
+
/>
|
|
530
|
+
))}
|
|
531
|
+
</div>
|
|
426
532
|
);
|
|
427
533
|
}
|
|
428
534
|
|
|
@@ -32,6 +32,25 @@ export interface WorkspaceFileContent {
|
|
|
32
32
|
readonly truncated?: boolean;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by a {@link WorkspaceFileReader} when the requested path does not
|
|
37
|
+
* exist at the entry's read ref — as opposed to a transport or permission
|
|
38
|
+
* failure.
|
|
39
|
+
*
|
|
40
|
+
* The distinction matters to consumers: a file the agent just created exists
|
|
41
|
+
* in session state before it exists at any readable ref, so "not found" is a
|
|
42
|
+
* recoverable, expected condition (the viewer falls back to the session's
|
|
43
|
+
* captured content), while a network error or 5xx is a genuine failure worth
|
|
44
|
+
* a retry affordance. Platform builders implementing custom readers should
|
|
45
|
+
* throw this class for their substrate's not-found condition.
|
|
46
|
+
*/
|
|
47
|
+
export class WorkspaceFileNotFoundError extends Error {
|
|
48
|
+
constructor(path: string) {
|
|
49
|
+
super(`"${path}" doesn't exist at the workspace's current ref.`);
|
|
50
|
+
this.name = "WorkspaceFileNotFoundError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
/**
|
|
36
55
|
* Platform-injected callback that reads the content of a single workspace file.
|
|
37
56
|
*
|
|
@@ -46,6 +65,8 @@ export interface WorkspaceFileContent {
|
|
|
46
65
|
* - **Throws** on a genuine failure (404, network error, 5xx, unreadable file,
|
|
47
66
|
* a directory path). Consumers catch this into a distinct error state — a
|
|
48
67
|
* clicked file that 404s is "failed to load," not "unsupported here."
|
|
68
|
+
* A missing path should throw {@link WorkspaceFileNotFoundError} so
|
|
69
|
+
* consumers can distinguish "not there yet" from "broken."
|
|
49
70
|
*
|
|
50
71
|
* `path` is repo-relative for git entries and root-relative for local entries —
|
|
51
72
|
* exactly the strings the matching `WorkspaceFileLister` emits, so a file-tree
|
|
@@ -34,9 +34,10 @@ beforeEach(() => {
|
|
|
34
34
|
};
|
|
35
35
|
});
|
|
36
36
|
import type { WorkspaceEntry } from "../useWorkspaceEntries";
|
|
37
|
-
import
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
import {
|
|
38
|
+
WorkspaceFileNotFoundError,
|
|
39
|
+
type WorkspaceFileContent,
|
|
40
|
+
type WorkspaceFileReader,
|
|
40
41
|
} from "../WorkspaceFileReader";
|
|
41
42
|
|
|
42
43
|
function wholeFileChange(path: string): FileChange {
|
|
@@ -346,6 +347,140 @@ describe("FileViewer — diff mode", () => {
|
|
|
346
347
|
});
|
|
347
348
|
});
|
|
348
349
|
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Not-found fallback — captured content when the live substrate lacks the file
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
describe("FileViewer — not-found and captured-content fallback", () => {
|
|
355
|
+
function notFoundReader(): WorkspaceFileReader {
|
|
356
|
+
return vi.fn(async (_e, path: string) => {
|
|
357
|
+
throw new WorkspaceFileNotFoundError(path);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
it("falls back to the captured after-content when the file isn't at the ref", async () => {
|
|
362
|
+
render(
|
|
363
|
+
<FileViewer
|
|
364
|
+
selectedFile={{ entryId: "e1", path: "notes.md" }}
|
|
365
|
+
entries={ENTRIES}
|
|
366
|
+
reader={notFoundReader()}
|
|
367
|
+
change={wholeFileChange("notes.md")}
|
|
368
|
+
/>,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
fireEvent.click(screen.getByRole("radio", { name: "File" }));
|
|
372
|
+
|
|
373
|
+
// The captured after side ("beta") renders with the provenance caption —
|
|
374
|
+
// not an error, not the live caption.
|
|
375
|
+
await waitFor(() =>
|
|
376
|
+
expect(screen.getByText(/As of the agent.s last change/i)).toBeTruthy(),
|
|
377
|
+
);
|
|
378
|
+
expect(screen.getByText("beta")).toBeTruthy();
|
|
379
|
+
expect(screen.queryByText("Retry")).toBeNull();
|
|
380
|
+
expect(screen.queryByText(/Live — may differ/i)).toBeNull();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("falls back to captured content when the substrate is unsupported (reader resolves null)", async () => {
|
|
384
|
+
render(
|
|
385
|
+
<FileViewer
|
|
386
|
+
selectedFile={{ entryId: "e1", path: "notes.md" }}
|
|
387
|
+
entries={ENTRIES}
|
|
388
|
+
reader={readerFor(null)}
|
|
389
|
+
change={wholeFileChange("notes.md")}
|
|
390
|
+
/>,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
fireEvent.click(screen.getByRole("radio", { name: "File" }));
|
|
394
|
+
|
|
395
|
+
await waitFor(() =>
|
|
396
|
+
expect(screen.getByText(/As of the agent.s last change/i)).toBeTruthy(),
|
|
397
|
+
);
|
|
398
|
+
expect(screen.getByText("beta")).toBeTruthy();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("shows the calm not-found state (no error styling) when nothing is captured", async () => {
|
|
402
|
+
render(
|
|
403
|
+
<FileViewer
|
|
404
|
+
selectedFile={{ entryId: "e1", path: "notes.md" }}
|
|
405
|
+
entries={ENTRIES}
|
|
406
|
+
reader={notFoundReader()}
|
|
407
|
+
/>,
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
await waitFor(() =>
|
|
411
|
+
expect(screen.getByText(/isn.t available in the workspace source yet/i)).toBeTruthy(),
|
|
412
|
+
);
|
|
413
|
+
// A calm notice, not a failure: retry is offered as "Check again".
|
|
414
|
+
expect(screen.getByText("Check again")).toBeTruthy();
|
|
415
|
+
expect(screen.queryByText("Retry")).toBeNull();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("Check again re-reads and renders content once the file lands at the ref", async () => {
|
|
419
|
+
let attempt = 0;
|
|
420
|
+
const reader: WorkspaceFileReader = vi.fn(async (_e, path: string) => {
|
|
421
|
+
attempt += 1;
|
|
422
|
+
if (attempt === 1) throw new WorkspaceFileNotFoundError(path);
|
|
423
|
+
return text("now it exists");
|
|
424
|
+
});
|
|
425
|
+
render(
|
|
426
|
+
<FileViewer
|
|
427
|
+
selectedFile={{ entryId: "e1", path: "notes.md" }}
|
|
428
|
+
entries={ENTRIES}
|
|
429
|
+
reader={reader}
|
|
430
|
+
/>,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
await waitFor(() => expect(screen.getByText("Check again")).toBeTruthy());
|
|
434
|
+
fireEvent.click(screen.getByText("Check again"));
|
|
435
|
+
|
|
436
|
+
await waitFor(() => expect(screen.getByText("now it exists")).toBeTruthy());
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("keeps the not-found state calm for a HUNK_ONLY change (no whole-file capture to serve)", async () => {
|
|
440
|
+
const hunkOnly = create(FileChangeSchema, {
|
|
441
|
+
path: "notes.md",
|
|
442
|
+
changeType: FileChangeType.MODIFY,
|
|
443
|
+
captureLevel: FileChangeCaptureLevel.HUNK_ONLY,
|
|
444
|
+
unifiedDiff: "@@ -1 +1 @@\n-alpha\n+beta\n",
|
|
445
|
+
});
|
|
446
|
+
render(
|
|
447
|
+
<FileViewer
|
|
448
|
+
selectedFile={{ entryId: "e1", path: "notes.md" }}
|
|
449
|
+
entries={ENTRIES}
|
|
450
|
+
reader={notFoundReader()}
|
|
451
|
+
change={hunkOnly}
|
|
452
|
+
/>,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
fireEvent.click(screen.getByRole("radio", { name: "File" }));
|
|
456
|
+
|
|
457
|
+
await waitFor(() =>
|
|
458
|
+
expect(screen.getByText(/isn.t available in the workspace source yet/i)).toBeTruthy(),
|
|
459
|
+
);
|
|
460
|
+
expect(screen.queryByText(/As of the agent.s last change/i)).toBeNull();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("a generic reader error still shows the error state with Retry, never the fallback", async () => {
|
|
464
|
+
const reader: WorkspaceFileReader = vi.fn(async () => {
|
|
465
|
+
throw new Error("rate limited (HTTP 429)");
|
|
466
|
+
});
|
|
467
|
+
render(
|
|
468
|
+
<FileViewer
|
|
469
|
+
selectedFile={{ entryId: "e1", path: "notes.md" }}
|
|
470
|
+
entries={ENTRIES}
|
|
471
|
+
reader={reader}
|
|
472
|
+
change={wholeFileChange("notes.md")}
|
|
473
|
+
/>,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
fireEvent.click(screen.getByRole("radio", { name: "File" }));
|
|
477
|
+
|
|
478
|
+
await waitFor(() => expect(screen.getByText(/rate limited/i)).toBeTruthy());
|
|
479
|
+
expect(screen.getByText("Retry")).toBeTruthy();
|
|
480
|
+
expect(screen.queryByText(/As of the agent.s last change/i)).toBeNull();
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
349
484
|
// ---------------------------------------------------------------------------
|
|
350
485
|
// Reveal (jump-to-line) — a line target opens File view and scrolls to it
|
|
351
486
|
// ---------------------------------------------------------------------------
|
|
@@ -172,6 +172,26 @@ describe("useWorkspaceFileContent", () => {
|
|
|
172
172
|
expect(result.current.content?.text).toBe("FAST");
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
+
it("re-reads when the entry's readRef advances (same id, new write-back push)", async () => {
|
|
176
|
+
const reader: WorkspaceFileReader = vi.fn(async (e) =>
|
|
177
|
+
textContent(`at:${e.readRef ?? e.gitBranch}`),
|
|
178
|
+
);
|
|
179
|
+
const { result, rerender } = renderHook(
|
|
180
|
+
(props: { entry: WorkspaceEntry }) =>
|
|
181
|
+
useWorkspaceFileContent({ entry: props.entry, path: "notes.md", reader }),
|
|
182
|
+
{ initialProps: { entry: gitEntry() } },
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
await waitFor(() => expect(result.current.content?.text).toBe("at:main"));
|
|
186
|
+
|
|
187
|
+
// A write-back push advances readRef while entry.id stays stable — the
|
|
188
|
+
// same path at a new ref is different content and must be re-read.
|
|
189
|
+
rerender({ entry: { ...gitEntry(), readRef: "sha-2" } });
|
|
190
|
+
|
|
191
|
+
await waitFor(() => expect(result.current.content?.text).toBe("at:sha-2"));
|
|
192
|
+
expect(reader).toHaveBeenCalledTimes(2);
|
|
193
|
+
});
|
|
194
|
+
|
|
175
195
|
it("goes idle when the entry is removed (entry -> null)", async () => {
|
|
176
196
|
const reader: WorkspaceFileReader = vi.fn(async () => textContent("x"));
|
|
177
197
|
const { result, rerender } = renderHook(
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { WorkspaceEntry } from "../useWorkspaceEntries";
|
|
3
|
+
import type { WorkspaceFileEntry, WorkspaceFileLister } from "../WorkspaceFileLister";
|
|
4
|
+
import {
|
|
5
|
+
loadEntryFiles,
|
|
6
|
+
peekEntryListing,
|
|
7
|
+
__clearWorkspaceListingCache,
|
|
8
|
+
} from "../workspaceListingCache";
|
|
9
|
+
|
|
10
|
+
function gitEntry(overrides?: Partial<WorkspaceEntry>): WorkspaceEntry {
|
|
11
|
+
return {
|
|
12
|
+
id: "ws-1",
|
|
13
|
+
name: "acme/api",
|
|
14
|
+
type: "git",
|
|
15
|
+
gitUrl: "https://github.com/acme/api",
|
|
16
|
+
gitBranch: "main",
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function listerFor(files: readonly WorkspaceFileEntry[]): WorkspaceFileLister {
|
|
22
|
+
return vi.fn(async () => [...files]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("workspaceListingCache", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
__clearWorkspaceListingCache();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("caches a listing and serves it without re-calling the lister", async () => {
|
|
31
|
+
const lister = listerFor([{ path: "a.ts", isDirectory: false }]);
|
|
32
|
+
const entry = gitEntry();
|
|
33
|
+
|
|
34
|
+
await loadEntryFiles(entry, lister);
|
|
35
|
+
await loadEntryFiles(entry, lister);
|
|
36
|
+
|
|
37
|
+
expect(lister).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(peekEntryListing(entry)?.files).toHaveLength(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("treats a readRef advance as a different listing (stale-tree regression)", async () => {
|
|
42
|
+
// The write-back pushed a commit that added notes.md; the pre-push listing
|
|
43
|
+
// must not be served for the post-push ref.
|
|
44
|
+
const prePush = gitEntry(); // reads at gitBranch
|
|
45
|
+
const postPush = gitEntry({ readRef: "sha-after-push" });
|
|
46
|
+
|
|
47
|
+
const lister = vi.fn(async (entry: WorkspaceEntry) =>
|
|
48
|
+
entry.readRef
|
|
49
|
+
? [
|
|
50
|
+
{ path: "a.ts", isDirectory: false },
|
|
51
|
+
{ path: "notes.md", isDirectory: false },
|
|
52
|
+
]
|
|
53
|
+
: [{ path: "a.ts", isDirectory: false }],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const before = await loadEntryFiles(prePush, lister);
|
|
57
|
+
expect(before?.files.map((f) => f.path)).toEqual(["a.ts"]);
|
|
58
|
+
|
|
59
|
+
// Same entry.id, advanced ref — must re-fetch, not serve the stale tree.
|
|
60
|
+
const after = await loadEntryFiles(postPush, lister);
|
|
61
|
+
expect(lister).toHaveBeenCalledTimes(2);
|
|
62
|
+
expect(after?.files.map((f) => f.path)).toContain("notes.md");
|
|
63
|
+
|
|
64
|
+
// Both snapshots remain independently peekable at their own refs.
|
|
65
|
+
expect(peekEntryListing(prePush)?.files).toHaveLength(1);
|
|
66
|
+
expect(peekEntryListing(postPush)?.files).toHaveLength(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("keys by entry id as well — two entries at the same ref never collide", async () => {
|
|
70
|
+
const one = gitEntry({ id: "ws-1" });
|
|
71
|
+
const two = gitEntry({ id: "ws-2", name: "acme/web", gitUrl: "https://github.com/acme/web" });
|
|
72
|
+
|
|
73
|
+
await loadEntryFiles(one, listerFor([{ path: "one.ts", isDirectory: false }]));
|
|
74
|
+
await loadEntryFiles(two, listerFor([{ path: "two.ts", isDirectory: false }]));
|
|
75
|
+
|
|
76
|
+
expect(peekEntryListing(one)?.files[0].path).toBe("one.ts");
|
|
77
|
+
expect(peekEntryListing(two)?.files[0].path).toBe("two.ts");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("bustCache re-calls the lister for the same entry and ref", async () => {
|
|
81
|
+
const lister = listerFor([{ path: "a.ts", isDirectory: false }]);
|
|
82
|
+
const entry = gitEntry();
|
|
83
|
+
|
|
84
|
+
await loadEntryFiles(entry, lister);
|
|
85
|
+
await loadEntryFiles(entry, lister, { bustCache: true });
|
|
86
|
+
|
|
87
|
+
expect(lister).toHaveBeenCalledTimes(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("never caches a null (unsupported) result", async () => {
|
|
91
|
+
const lister: WorkspaceFileLister = vi.fn(async () => null);
|
|
92
|
+
const entry = gitEntry();
|
|
93
|
+
|
|
94
|
+
expect(await loadEntryFiles(entry, lister)).toBeNull();
|
|
95
|
+
expect(peekEntryListing(entry)).toBeUndefined();
|
|
96
|
+
|
|
97
|
+
// A later, supporting lister must get its chance.
|
|
98
|
+
await loadEntryFiles(entry, listerFor([{ path: "a.ts", isDirectory: false }]));
|
|
99
|
+
expect(peekEntryListing(entry)?.files).toHaveLength(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("collapses advisory notice entries into the truncated flag", async () => {
|
|
103
|
+
const lister = listerFor([
|
|
104
|
+
{ path: "a.ts", isDirectory: false },
|
|
105
|
+
{ path: "... (tree truncated)", isDirectory: false, notice: true },
|
|
106
|
+
]);
|
|
107
|
+
const entry = gitEntry();
|
|
108
|
+
|
|
109
|
+
const listing = await loadEntryFiles(entry, lister);
|
|
110
|
+
|
|
111
|
+
expect(listing?.truncated).toBe(true);
|
|
112
|
+
expect(listing?.files.map((f) => f.path)).toEqual(["a.ts"]);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/workspace/index.ts
CHANGED
|
@@ -7,7 +7,10 @@ export type {
|
|
|
7
7
|
WorkspaceFileEntry,
|
|
8
8
|
WorkspaceFileLister,
|
|
9
9
|
} from "./WorkspaceFileLister.js";
|
|
10
|
-
export {
|
|
10
|
+
export {
|
|
11
|
+
MAX_WORKSPACE_FILE_READ_BYTES,
|
|
12
|
+
WorkspaceFileNotFoundError,
|
|
13
|
+
} from "./WorkspaceFileReader.js";
|
|
11
14
|
export type {
|
|
12
15
|
WorkspaceFileContent,
|
|
13
16
|
WorkspaceFileReader,
|
|
@@ -33,6 +33,17 @@ export interface WorkspaceEntry {
|
|
|
33
33
|
readonly gitUrl?: string;
|
|
34
34
|
/** Branch to clone. Set when `type` is `"git"`. */
|
|
35
35
|
readonly gitBranch?: string;
|
|
36
|
+
/**
|
|
37
|
+
* The git ref (branch or commit SHA) at which read-side operations —
|
|
38
|
+
* file content, tree listing, name search — resolve. Defaults to
|
|
39
|
+
* `gitBranch` when absent.
|
|
40
|
+
*
|
|
41
|
+
* Never user-configured: a session derives it from the latest workspace
|
|
42
|
+
* write-back so viewers read the agent's pushed commit instead of the
|
|
43
|
+
* stale base branch (see `useWorkspaceReadRefs`). A read-side projection
|
|
44
|
+
* only — never persisted, never part of the session input.
|
|
45
|
+
*/
|
|
46
|
+
readonly readRef?: string;
|
|
36
47
|
/** Absolute filesystem path. Set when `type` is `"local"`. */
|
|
37
48
|
readonly localPath?: string;
|
|
38
49
|
}
|
|
@@ -105,7 +105,10 @@ export function useWorkspaceFileContent({
|
|
|
105
105
|
? () => reader(entry, path).then((c) => ({ content: c }))
|
|
106
106
|
: null,
|
|
107
107
|
// Identity deps: a change to any of these is a different file to read.
|
|
108
|
-
|
|
108
|
+
// The read ref participates because the same path at a different ref is
|
|
109
|
+
// different content — a write-back push advances `readRef` while
|
|
110
|
+
// `entry.id` stays stable.
|
|
111
|
+
[entry?.id ?? null, entry?.readRef ?? null, path ?? null, reader],
|
|
109
112
|
EMPTY_CONTENT,
|
|
110
113
|
);
|
|
111
114
|
|
|
@@ -82,11 +82,16 @@ export function useWorkspaceFileSearch({
|
|
|
82
82
|
}: UseWorkspaceFileSearchOptions): UseWorkspaceFileSearchReturn {
|
|
83
83
|
const [states, setStates] = useState<Record<string, EntryListingState>>({});
|
|
84
84
|
|
|
85
|
-
// Latest entries read inside the effect without widening its dependency to
|
|
86
|
-
// (possibly per-render) array identity — the effect keys on the
|
|
85
|
+
// Latest entries read inside the effect without widening its dependency to
|
|
86
|
+
// the (possibly per-render) array identity — the effect keys on the set of
|
|
87
|
+
// (id, readRef) pairs instead. The ref participates because a listing is a
|
|
88
|
+
// snapshot of one ref: when a write-back advances an entry's readRef, its
|
|
89
|
+
// listing must be re-fetched even though the id set is unchanged.
|
|
87
90
|
const entriesRef = useRef(entries);
|
|
88
91
|
entriesRef.current = entries;
|
|
89
|
-
const entriesKey = entries
|
|
92
|
+
const entriesKey = entries
|
|
93
|
+
.map((entry) => `${entry.id}\u0001${entry.readRef ?? ""}`)
|
|
94
|
+
.join("\u0000");
|
|
90
95
|
|
|
91
96
|
// Ignore resolutions from a superseded entry set (id set or lister changed).
|
|
92
97
|
const generationRef = useRef(0);
|
|
@@ -43,7 +43,8 @@ const EMPTY_TREE: readonly TreeNode[] = [];
|
|
|
43
43
|
* {@link loadEntryFiles} cache) when the
|
|
44
44
|
* entry changes.
|
|
45
45
|
* - Reads the shared listing cache so tab switches, re-expands, and the search
|
|
46
|
-
* surface are instant without re-fetching (one cache, keyed by `entry.id`
|
|
46
|
+
* surface are instant without re-fetching (one cache, keyed by `entry.id` +
|
|
47
|
+
* effective read ref — a ref advance is a cache miss by design).
|
|
47
48
|
* - Returns an empty tree and skips the call when `lister` is
|
|
48
49
|
* `undefined` (graceful degradation — DD-011 opt-in).
|
|
49
50
|
* - Memoizes the return value for referential stability (DD-010).
|
|
@@ -72,7 +73,7 @@ export function useWorkspaceFiles({
|
|
|
72
73
|
if (!lister) return;
|
|
73
74
|
|
|
74
75
|
if (!bustCache) {
|
|
75
|
-
const cached = peekEntryListing(target
|
|
76
|
+
const cached = peekEntryListing(target);
|
|
76
77
|
if (cached) {
|
|
77
78
|
setTree(cached.tree);
|
|
78
79
|
setTruncated(cached.truncated);
|