@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.
Files changed (137) hide show
  1. package/execution/FileReviewContext.d.ts +15 -1
  2. package/execution/FileReviewContext.d.ts.map +1 -1
  3. package/execution/FileReviewContext.js +20 -1
  4. package/execution/FileReviewContext.js.map +1 -1
  5. package/execution/MessageThread.d.ts +65 -2
  6. package/execution/MessageThread.d.ts.map +1 -1
  7. package/execution/MessageThread.js +42 -23
  8. package/execution/MessageThread.js.map +1 -1
  9. package/execution/TodoCard.d.ts +8 -0
  10. package/execution/TodoCard.d.ts.map +1 -1
  11. package/execution/TodoCard.js +6 -4
  12. package/execution/TodoCard.js.map +1 -1
  13. package/execution/TodoList.d.ts +20 -1
  14. package/execution/TodoList.d.ts.map +1 -1
  15. package/execution/TodoList.js +9 -6
  16. package/execution/TodoList.js.map +1 -1
  17. package/execution/ToolCallDetail.d.ts +3 -2
  18. package/execution/ToolCallDetail.d.ts.map +1 -1
  19. package/execution/ToolCallDetail.js +49 -16
  20. package/execution/ToolCallDetail.js.map +1 -1
  21. package/execution/WriteBackCard.d.ts +7 -1
  22. package/execution/WriteBackCard.d.ts.map +1 -1
  23. package/execution/WriteBackCard.js +10 -2
  24. package/execution/WriteBackCard.js.map +1 -1
  25. package/execution/file-review-status.d.ts +15 -0
  26. package/execution/file-review-status.d.ts.map +1 -1
  27. package/execution/file-review-status.js +22 -0
  28. package/execution/file-review-status.js.map +1 -1
  29. package/execution/index.d.ts +5 -5
  30. package/execution/index.d.ts.map +1 -1
  31. package/execution/index.js +3 -3
  32. package/execution/index.js.map +1 -1
  33. package/github/useGitHubFileReader.d.ts +4 -2
  34. package/github/useGitHubFileReader.d.ts.map +1 -1
  35. package/github/useGitHubFileReader.js +16 -3
  36. package/github/useGitHubFileReader.js.map +1 -1
  37. package/github/useGitHubTreeLister.d.ts +2 -1
  38. package/github/useGitHubTreeLister.d.ts.map +1 -1
  39. package/github/useGitHubTreeLister.js +7 -3
  40. package/github/useGitHubTreeLister.js.map +1 -1
  41. package/index.d.ts +3 -3
  42. package/index.d.ts.map +1 -1
  43. package/index.js +3 -3
  44. package/index.js.map +1 -1
  45. package/internal/VirtualizedThread.d.ts +3 -2
  46. package/internal/VirtualizedThread.d.ts.map +1 -1
  47. package/internal/VirtualizedThread.js +3 -2
  48. package/internal/VirtualizedThread.js.map +1 -1
  49. package/package.json +4 -4
  50. package/session/SessionViewer.d.ts +11 -1
  51. package/session/SessionViewer.d.ts.map +1 -1
  52. package/session/SessionViewer.js +24 -5
  53. package/session/SessionViewer.js.map +1 -1
  54. package/session/facets/ChangesTab.d.ts +24 -6
  55. package/session/facets/ChangesTab.d.ts.map +1 -1
  56. package/session/facets/ChangesTab.js +15 -6
  57. package/session/facets/ChangesTab.js.map +1 -1
  58. package/session/index.d.ts +1 -0
  59. package/session/index.d.ts.map +1 -1
  60. package/session/index.js +1 -0
  61. package/session/index.js.map +1 -1
  62. package/session/useSessionRailViews.d.ts +16 -1
  63. package/session/useSessionRailViews.d.ts.map +1 -1
  64. package/session/useSessionRailViews.js +9 -4
  65. package/session/useSessionRailViews.js.map +1 -1
  66. package/session/useWorkspaceReadRefs.d.ts +45 -0
  67. package/session/useWorkspaceReadRefs.d.ts.map +1 -0
  68. package/session/useWorkspaceReadRefs.js +104 -0
  69. package/session/useWorkspaceReadRefs.js.map +1 -0
  70. package/src/execution/FileReviewContext.ts +27 -1
  71. package/src/execution/MessageThread.tsx +110 -22
  72. package/src/execution/TodoCard.tsx +20 -4
  73. package/src/execution/TodoList.tsx +26 -4
  74. package/src/execution/ToolCallDetail.tsx +69 -16
  75. package/src/execution/WriteBackCard.tsx +18 -4
  76. package/src/execution/__tests__/ToolCallDetail.test.tsx +188 -2
  77. package/src/execution/__tests__/WriteBackCard.test.tsx +83 -0
  78. package/src/execution/__tests__/file-review-status.test.ts +47 -0
  79. package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
  80. package/src/execution/file-review-status.ts +24 -0
  81. package/src/execution/index.ts +13 -3
  82. package/src/github/__tests__/useGitHubFileReader.test.ts +35 -2
  83. package/src/github/__tests__/useGitHubTreeLister.test.ts +14 -0
  84. package/src/github/useGitHubFileReader.ts +19 -6
  85. package/src/github/useGitHubTreeLister.ts +7 -3
  86. package/src/index.ts +4 -0
  87. package/src/internal/VirtualizedThread.tsx +5 -1
  88. package/src/session/SessionViewer.tsx +42 -2
  89. package/src/session/__tests__/useSessionRailViews.test.tsx +12 -0
  90. package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
  91. package/src/session/facets/ChangesTab.tsx +43 -6
  92. package/src/session/facets/__tests__/ChangesTab.test.tsx +23 -0
  93. package/src/session/index.ts +2 -0
  94. package/src/session/useSessionRailViews.tsx +31 -3
  95. package/src/session/useWorkspaceReadRefs.ts +118 -0
  96. package/src/workflow/picker/__tests__/recents.test.ts +4 -4
  97. package/src/workspace/FileViewer.tsx +144 -38
  98. package/src/workspace/WorkspaceFileReader.ts +21 -0
  99. package/src/workspace/__tests__/FileViewer.test.tsx +138 -3
  100. package/src/workspace/__tests__/useWorkspaceFileContent.test.tsx +20 -0
  101. package/src/workspace/__tests__/workspaceListingCache.test.ts +114 -0
  102. package/src/workspace/index.ts +4 -1
  103. package/src/workspace/useWorkspaceEntries.ts +11 -0
  104. package/src/workspace/useWorkspaceFileContent.ts +4 -1
  105. package/src/workspace/useWorkspaceFileSearch.ts +8 -3
  106. package/src/workspace/useWorkspaceFiles.ts +3 -2
  107. package/src/workspace/workspaceListingCache.ts +21 -7
  108. package/styles.css +1 -1
  109. package/workspace/FileViewer.d.ts +1 -1
  110. package/workspace/FileViewer.d.ts.map +1 -1
  111. package/workspace/FileViewer.js +55 -5
  112. package/workspace/FileViewer.js.map +1 -1
  113. package/workspace/WorkspaceFileReader.d.ts +17 -0
  114. package/workspace/WorkspaceFileReader.d.ts.map +1 -1
  115. package/workspace/WorkspaceFileReader.js +18 -0
  116. package/workspace/WorkspaceFileReader.js.map +1 -1
  117. package/workspace/index.d.ts +1 -1
  118. package/workspace/index.d.ts.map +1 -1
  119. package/workspace/index.js +1 -1
  120. package/workspace/index.js.map +1 -1
  121. package/workspace/useWorkspaceEntries.d.ts +11 -0
  122. package/workspace/useWorkspaceEntries.d.ts.map +1 -1
  123. package/workspace/useWorkspaceEntries.js.map +1 -1
  124. package/workspace/useWorkspaceFileContent.d.ts.map +1 -1
  125. package/workspace/useWorkspaceFileContent.js +4 -1
  126. package/workspace/useWorkspaceFileContent.js.map +1 -1
  127. package/workspace/useWorkspaceFileSearch.d.ts.map +1 -1
  128. package/workspace/useWorkspaceFileSearch.js +8 -3
  129. package/workspace/useWorkspaceFileSearch.js.map +1 -1
  130. package/workspace/useWorkspaceFiles.d.ts +2 -1
  131. package/workspace/useWorkspaceFiles.d.ts.map +1 -1
  132. package/workspace/useWorkspaceFiles.js +3 -2
  133. package/workspace/useWorkspaceFiles.js.map +1 -1
  134. package/workspace/workspaceListingCache.d.ts +1 -1
  135. package/workspace/workspaceListingCache.d.ts.map +1 -1
  136. package/workspace/workspaceListingCache.js +20 -7
  137. 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: Date.now() },
85
- { kind: 123, timestamp: Date.now() }, // invalid: kind is not string
86
- { timestamp: Date.now() }, // invalid: missing kind
87
- { kind: "another_valid", timestamp: Date.now() },
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 { FileChangeType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
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 type { WorkspaceFileReader } from "./WorkspaceFileReader.js";
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
- {change && (
223
- <p className="border-b border-border-muted px-4 py-1.5 text-[0.65rem] text-muted-foreground">
224
- Live — may differ from the reviewed change.
225
- </p>
226
- )}
227
- <FileViewerBody
228
- basename={basename}
229
- content={content}
230
- isLoading={isLoading}
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
- role="status"
361
- className="space-y-2 p-4"
362
- aria-busy="true"
363
- aria-label="Loading file"
364
- >
365
- {SKELETON_LINE_WIDTHS.map((width, i) => (
366
- <div
367
- key={i}
368
- className="h-4 animate-pulse rounded bg-muted"
369
- style={{ width: `${width}%` }}
370
- aria-hidden="true"
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&rsquo;t available in the workspace source yet. The
400
+ agent&rsquo;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
- <ArtifactContentRenderer
421
- content={content.text}
422
- fileName={basename}
423
- isTruncated={content.truncated}
424
- reveal={reveal}
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&rsquo;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 type {
38
- WorkspaceFileContent,
39
- WorkspaceFileReader,
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(