@stigmer/react 3.1.4 → 3.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/WriteBackCard.d.ts +7 -1
- package/execution/WriteBackCard.d.ts.map +1 -1
- package/execution/WriteBackCard.js +10 -2
- package/execution/WriteBackCard.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 +24 -5
- package/session/SessionViewer.js.map +1 -1
- package/session/facets/ChangesTab.d.ts +24 -6
- package/session/facets/ChangesTab.d.ts.map +1 -1
- package/session/facets/ChangesTab.js +15 -6
- package/session/facets/ChangesTab.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/useSessionRailViews.d.ts +16 -1
- package/session/useSessionRailViews.d.ts.map +1 -1
- package/session/useSessionRailViews.js +9 -4
- package/session/useSessionRailViews.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/WriteBackCard.tsx +18 -4
- package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
- package/src/execution/__tests__/WriteBackCard.test.tsx +83 -0
- 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 +42 -2
- package/src/session/__tests__/useSessionRailViews.test.tsx +12 -0
- package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
- package/src/session/facets/ChangesTab.tsx +43 -6
- package/src/session/facets/__tests__/ChangesTab.test.tsx +23 -0
- package/src/session/index.ts +2 -0
- package/src/session/useSessionRailViews.tsx +31 -3
- package/src/session/useWorkspaceReadRefs.ts +118 -0
- package/src/workflow/picker/__tests__/recents.test.ts +4 -4
- 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,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
5
|
+
import type { WorkspaceEntry } from "../workspace/useWorkspaceEntries.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pure derivation hook that projects the session's workspace write-backs onto
|
|
9
|
+
* its workspace entries as per-entry read refs.
|
|
10
|
+
*
|
|
11
|
+
* The agent's file changes land on a write-back branch (`stigmer/<id>`), not
|
|
12
|
+
* the branch the session was configured with — so read-side surfaces (file
|
|
13
|
+
* viewer, tree listing, name search) pointed at the configured branch would
|
|
14
|
+
* 404 on agent-created files and silently show stale content for modified
|
|
15
|
+
* ones. This hook returns the entries with `readRef` set to the latest
|
|
16
|
+
* write-back commit SHA, which those surfaces resolve in preference to
|
|
17
|
+
* `gitBranch`. A commit SHA (not the branch name) so reads are immutable-ref
|
|
18
|
+
* exact, immune to branch eventual-consistency.
|
|
19
|
+
*
|
|
20
|
+
* **Ref selection:** the latest write-back with a non-empty `commitSha` wins
|
|
21
|
+
* per workspace entry, across all executions in chronological order. FAILED
|
|
22
|
+
* write-back records carry no SHA and never regress the ref.
|
|
23
|
+
*
|
|
24
|
+
* **Entry matching** mirrors the runner's `WriteBackCoordinator.resolveEntry`:
|
|
25
|
+
* single-entry sessions provision with an empty `workspaceEntryName`, so when
|
|
26
|
+
* the session has exactly one git entry every write-back applies to it;
|
|
27
|
+
* multi-entry sessions match strictly by name.
|
|
28
|
+
*
|
|
29
|
+
* **Reference stability (DD-010):** the decorated array is memoized on the
|
|
30
|
+
* entries array and a value signature of the derived refs — streaming frames
|
|
31
|
+
* that don't change any write-back SHA return the identical array, so
|
|
32
|
+
* downstream listing/content effects never churn.
|
|
33
|
+
*
|
|
34
|
+
* @param executions - All executions for the session, in chronological order
|
|
35
|
+
* (same input contract as {@link useSessionWriteBacks}).
|
|
36
|
+
* @param entries - The session's workspace entries. Returned as-is (same
|
|
37
|
+
* reference) when no entry derives a ref.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* const surfaceEntries = useWorkspaceReadRefs(flow.allExecutions, flow.workspace.entries);
|
|
42
|
+
* <WorkspaceSurface entries={surfaceEntries} ... />
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @see useSessionWriteBacks — the write-back list this projection derives from
|
|
46
|
+
* @see WorkspaceEntry.readRef — the field this hook populates
|
|
47
|
+
*/
|
|
48
|
+
export function useWorkspaceReadRefs(
|
|
49
|
+
executions: readonly AgentExecution[],
|
|
50
|
+
entries: readonly WorkspaceEntry[],
|
|
51
|
+
): readonly WorkspaceEntry[] {
|
|
52
|
+
// Cheap per-render pass; memoizing on `executions` would defeat the point —
|
|
53
|
+
// its identity changes every streaming frame while the SHAs rarely do. The
|
|
54
|
+
// signature collapses those frames into a stable memo key.
|
|
55
|
+
const refByName = collectLatestShas(executions);
|
|
56
|
+
const signature = [...refByName]
|
|
57
|
+
.map(([name, sha]) => `${name}\u0001${sha}`)
|
|
58
|
+
.join("\u0000");
|
|
59
|
+
|
|
60
|
+
return useMemo(() => {
|
|
61
|
+
if (refByName.size === 0) return entries;
|
|
62
|
+
|
|
63
|
+
const gitEntries = entries.filter((entry) => entry.type === "git");
|
|
64
|
+
let decorated = false;
|
|
65
|
+
|
|
66
|
+
const next = entries.map((entry) => {
|
|
67
|
+
const readRef = resolveReadRef(entry, gitEntries.length, refByName);
|
|
68
|
+
if (!readRef || readRef === entry.readRef) return entry;
|
|
69
|
+
decorated = true;
|
|
70
|
+
return { ...entry, readRef };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return decorated ? next : entries;
|
|
74
|
+
// `signature` is the value-equality key for `refByName` (rebuilt each
|
|
75
|
+
// render); the map itself must stay out of the deps.
|
|
76
|
+
}, [entries, signature]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Latest non-empty write-back commit SHA per workspace entry name. */
|
|
80
|
+
function collectLatestShas(
|
|
81
|
+
executions: readonly AgentExecution[],
|
|
82
|
+
): Map<string, string> {
|
|
83
|
+
const refByName = new Map<string, string>();
|
|
84
|
+
for (const execution of executions) {
|
|
85
|
+
for (const wb of execution.status?.workspaceWriteBacks ?? []) {
|
|
86
|
+
if (wb.commitSha) refByName.set(wb.workspaceEntryName, wb.commitSha);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return refByName;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The write-back SHA for one entry, honoring the runner's single-entry
|
|
94
|
+
* convention: with exactly one git entry, any write-back applies to it
|
|
95
|
+
* (single-entry sessions write back under an empty entry name).
|
|
96
|
+
*/
|
|
97
|
+
function resolveReadRef(
|
|
98
|
+
entry: WorkspaceEntry,
|
|
99
|
+
gitEntryCount: number,
|
|
100
|
+
refByName: ReadonlyMap<string, string>,
|
|
101
|
+
): string | undefined {
|
|
102
|
+
if (entry.type !== "git") return undefined;
|
|
103
|
+
|
|
104
|
+
const byName = refByName.get(entry.name);
|
|
105
|
+
if (byName) return byName;
|
|
106
|
+
|
|
107
|
+
if (gitEntryCount === 1) {
|
|
108
|
+
// Single-entry sessions write back under an empty entry name; a lone
|
|
109
|
+
// unambiguous name is equally safe to claim. Anything else (multiple
|
|
110
|
+
// names against one entry) is malformed status data — decorate nothing
|
|
111
|
+
// rather than guess.
|
|
112
|
+
const unnamed = refByName.get("");
|
|
113
|
+
if (unnamed) return unnamed;
|
|
114
|
+
if (refByName.size === 1) return refByName.values().next().value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
@@ -81,10 +81,10 @@ describe("TaskKindRecentsStore", () => {
|
|
|
81
81
|
|
|
82
82
|
it("filters out invalid entries from localStorage", () => {
|
|
83
83
|
const data = [
|
|
84
|
-
{ kind: "valid_kind", timestamp:
|
|
85
|
-
{ kind: 123, timestamp:
|
|
86
|
-
{ timestamp:
|
|
87
|
-
{ kind: "another_valid", timestamp:
|
|
84
|
+
{ kind: "valid_kind", timestamp: 2 },
|
|
85
|
+
{ kind: 123, timestamp: 3 }, // invalid: kind is not string
|
|
86
|
+
{ timestamp: 4 }, // invalid: missing kind
|
|
87
|
+
{ kind: "another_valid", timestamp: 1 },
|
|
88
88
|
];
|
|
89
89
|
globalThis.localStorage.setItem(TEST_KEY, JSON.stringify(data));
|
|
90
90
|
|
|
@@ -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(
|