@stigmer/react 3.1.3 → 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/composer/SessionComposer.d.ts +31 -0
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +12 -4
- package/composer/SessionComposer.js.map +1 -1
- package/execution/ArtifactApplyButton.d.ts +25 -0
- package/execution/ArtifactApplyButton.d.ts.map +1 -0
- package/execution/ArtifactApplyButton.js +21 -0
- package/execution/ArtifactApplyButton.js.map +1 -0
- package/execution/ArtifactContentBody.d.ts +36 -0
- package/execution/ArtifactContentBody.d.ts.map +1 -0
- package/execution/ArtifactContentBody.js +62 -0
- package/execution/ArtifactContentBody.js.map +1 -0
- package/execution/ArtifactDocument.d.ts +48 -0
- package/execution/ArtifactDocument.d.ts.map +1 -0
- package/execution/ArtifactDocument.js +89 -0
- package/execution/ArtifactDocument.js.map +1 -0
- package/execution/ArtifactPreviewModal.d.ts +4 -4
- package/execution/ArtifactPreviewModal.d.ts.map +1 -1
- package/execution/ArtifactPreviewModal.js +15 -142
- package/execution/ArtifactPreviewModal.js.map +1 -1
- package/execution/ArtifactRow.d.ts +49 -0
- package/execution/ArtifactRow.d.ts.map +1 -0
- package/execution/ArtifactRow.js +56 -0
- package/execution/ArtifactRow.js.map +1 -0
- package/execution/ArtifactsWidget.d.ts +6 -9
- package/execution/ArtifactsWidget.d.ts.map +1 -1
- package/execution/ArtifactsWidget.js +15 -19
- package/execution/ArtifactsWidget.js.map +1 -1
- 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 +13 -7
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +7 -4
- package/execution/index.js.map +1 -1
- package/execution/useArtifactContent.d.ts +6 -1
- package/execution/useArtifactContent.d.ts.map +1 -1
- package/execution/useArtifactContent.js +7 -2
- package/execution/useArtifactContent.js.map +1 -1
- package/execution/useArtifactInspection.d.ts +85 -0
- package/execution/useArtifactInspection.d.ts.map +1 -0
- package/execution/useArtifactInspection.js +143 -0
- package/execution/useArtifactInspection.js.map +1 -0
- package/execution/useCreateAgentExecution.d.ts +11 -0
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js +1 -0
- package/execution/useCreateAgentExecution.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 +4 -4
- package/index.d.ts.map +1 -1
- package/index.js +3 -3
- package/index.js.map +1 -1
- package/internal/MermaidDiagram.d.ts +47 -0
- package/internal/MermaidDiagram.d.ts.map +1 -0
- package/internal/MermaidDiagram.js +118 -0
- package/internal/MermaidDiagram.js.map +1 -0
- 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/internal/markdown-components.d.ts.map +1 -1
- package/internal/markdown-components.js +34 -0
- package/internal/markdown-components.js.map +1 -1
- package/internal/mermaid-loader.d.ts +26 -0
- package/internal/mermaid-loader.d.ts.map +1 -0
- package/internal/mermaid-loader.js +34 -0
- package/internal/mermaid-loader.js.map +1 -0
- package/internal/mermaid-theme-css.d.ts +41 -0
- package/internal/mermaid-theme-css.d.ts.map +1 -0
- package/internal/mermaid-theme-css.js +179 -0
- package/internal/mermaid-theme-css.js.map +1 -0
- package/internal/slug.d.ts +9 -5
- package/internal/slug.d.ts.map +1 -1
- package/internal/slug.js +10 -6
- package/internal/slug.js.map +1 -1
- package/package.json +5 -4
- package/session/SessionViewer.d.ts +11 -1
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +101 -21
- package/session/SessionViewer.js.map +1 -1
- package/session/artifact-document.d.ts +13 -0
- package/session/artifact-document.d.ts.map +1 -0
- package/session/artifact-document.js +22 -0
- package/session/artifact-document.js.map +1 -0
- package/session/facets/ArtifactsTab.d.ts +30 -4
- package/session/facets/ArtifactsTab.d.ts.map +1 -1
- package/session/facets/ArtifactsTab.js +32 -10
- package/session/facets/ArtifactsTab.js.map +1 -1
- package/session/index.d.ts +3 -1
- package/session/index.d.ts.map +1 -1
- package/session/index.js +5 -1
- package/session/index.js.map +1 -1
- package/session/useSessionArtifacts.d.ts +17 -0
- package/session/useSessionArtifacts.d.ts.map +1 -1
- package/session/useSessionArtifacts.js +16 -5
- package/session/useSessionArtifacts.js.map +1 -1
- package/session/useSessionConversation.d.ts +17 -1
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +32 -4
- package/session/useSessionConversation.js.map +1 -1
- package/session/useSessionPageFlow.d.ts.map +1 -1
- package/session/useSessionPageFlow.js +1 -0
- package/session/useSessionPageFlow.js.map +1 -1
- package/session/useSessionPanel.d.ts +20 -0
- package/session/useSessionPanel.d.ts.map +1 -1
- package/session/useSessionPanel.js +13 -0
- package/session/useSessionPanel.js.map +1 -1
- package/session/useSessionRailViews.d.ts +13 -1
- package/session/useSessionRailViews.d.ts.map +1 -1
- package/session/useSessionRailViews.js +5 -3
- 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/composer/SessionComposer.tsx +69 -1
- package/src/composer/__tests__/SessionComposer-editing.test.tsx +128 -0
- package/src/execution/ArtifactApplyButton.tsx +79 -0
- package/src/execution/ArtifactContentBody.tsx +255 -0
- package/src/execution/ArtifactDocument.tsx +344 -0
- package/src/execution/ArtifactPreviewModal.tsx +39 -405
- package/src/execution/ArtifactRow.tsx +165 -0
- package/src/execution/ArtifactsWidget.tsx +24 -33
- 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__/ArtifactContentBody.test.tsx +131 -0
- package/src/execution/__tests__/ArtifactDocument.test.tsx +100 -0
- package/src/execution/__tests__/ArtifactRow.test.tsx +139 -0
- package/src/execution/__tests__/ArtifactsWidget.test.tsx +78 -0
- 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-entry.test.tsx +50 -2
- package/src/execution/__tests__/message-thread-slots.test.tsx +463 -0
- package/src/execution/__tests__/useArtifactInspection.test.tsx +151 -0
- package/src/execution/__tests__/useCreateAgentExecution.test.tsx +35 -0
- package/src/execution/file-review-status.ts +24 -0
- package/src/execution/index.ts +27 -5
- package/src/execution/useArtifactContent.ts +7 -0
- package/src/execution/useArtifactInspection.ts +252 -0
- package/src/execution/useCreateAgentExecution.ts +12 -0
- 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 +15 -2
- package/src/internal/MermaidDiagram.tsx +164 -0
- package/src/internal/VirtualizedThread.tsx +5 -1
- package/src/internal/__tests__/a11y/mermaid-theme.a11y.test.tsx +122 -0
- package/src/internal/__tests__/markdown-components.test.tsx +93 -1
- package/src/internal/__tests__/mermaid-diagram.test.tsx +282 -0
- package/src/internal/markdown-components.tsx +36 -1
- package/src/internal/mermaid-loader.ts +44 -0
- package/src/internal/mermaid-theme-css.ts +178 -0
- package/src/internal/slug.ts +10 -6
- package/src/session/SessionViewer.tsx +204 -35
- package/src/session/__tests__/SessionViewer-artifactTabs.test.tsx +210 -0
- package/src/session/__tests__/SessionViewer-editResubmit.test.tsx +233 -0
- package/src/session/__tests__/useSessionArtifacts.test.ts +57 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +172 -1
- package/src/session/__tests__/useSessionPanel.test.ts +70 -1
- package/src/session/__tests__/useWorkspaceReadRefs.test.ts +245 -0
- package/src/session/artifact-document.ts +23 -0
- package/src/session/facets/ArtifactsTab.tsx +66 -18
- package/src/session/facets/__tests__/ArtifactsTab.test.tsx +111 -47
- package/src/session/index.ts +7 -1
- package/src/session/useSessionArtifacts.ts +16 -5
- package/src/session/useSessionConversation.ts +48 -6
- package/src/session/useSessionPageFlow.ts +1 -0
- package/src/session/useSessionPanel.ts +41 -0
- package/src/session/useSessionRailViews.tsx +21 -1
- 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
- package/execution/ArtifactCard.d.ts +0 -81
- package/execution/ArtifactCard.d.ts.map +0 -1
- package/execution/ArtifactCard.js +0 -130
- package/execution/ArtifactCard.js.map +0 -1
- package/src/execution/ArtifactCard.tsx +0 -320
|
@@ -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);
|
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
// Both surfaces that need a listing read through here: the Files accordion
|
|
4
4
|
// (`useWorkspaceFiles`, one entry) and workspace search (`useWorkspaceFileSearch`,
|
|
5
5
|
// many entries). Keeping one module-level cache — keyed by the stable-within-page
|
|
6
|
-
// `entry.id`
|
|
7
|
-
//
|
|
8
|
-
// accordion, and the truncation flag for
|
|
6
|
+
// `entry.id` plus the entry's effective read ref — guarantees the two never
|
|
7
|
+
// double-fetch or drift, and lets one fetch produce every shape a consumer needs
|
|
8
|
+
// (flat files for search, a tree for the accordion, and the truncation flag for
|
|
9
|
+
// the banner). The ref is part of the key because a listing is a snapshot of one
|
|
10
|
+
// ref: when a session's write-back advances `readRef`, the old listing is stale
|
|
11
|
+
// by definition and must not be served.
|
|
9
12
|
|
|
10
13
|
import { buildFileTree, type TreeNode } from "../internal/file-tree/index.js";
|
|
11
14
|
import type { WorkspaceEntry } from "./useWorkspaceEntries.js";
|
|
@@ -40,13 +43,24 @@ export interface LoadEntryFilesOptions {
|
|
|
40
43
|
|
|
41
44
|
const sharedCache = new Map<string, WorkspaceListing>();
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Cache key for one entry's listing: identity plus the ref the listing was
|
|
48
|
+
* taken at. `entry.id` alone is NOT sufficient — it anchors file-selection
|
|
49
|
+
* identity across the surface and stays stable while `readRef` advances with
|
|
50
|
+
* each write-back push, so the ref must contribute to the key or consumers
|
|
51
|
+
* would see the pre-push tree forever.
|
|
52
|
+
*/
|
|
53
|
+
function listingCacheKey(entry: WorkspaceEntry): string {
|
|
54
|
+
return `${entry.id}\u0000${entry.readRef ?? entry.gitBranch ?? ""}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
/**
|
|
44
58
|
* Returns a cached {@link WorkspaceListing} for an entry synchronously, or
|
|
45
59
|
* `undefined` when nothing is cached. Lets a consumer render a warm listing
|
|
46
60
|
* instantly (no loading flash) before deciding whether to fetch.
|
|
47
61
|
*/
|
|
48
|
-
export function peekEntryListing(
|
|
49
|
-
return sharedCache.get(
|
|
62
|
+
export function peekEntryListing(entry: WorkspaceEntry): WorkspaceListing | undefined {
|
|
63
|
+
return sharedCache.get(listingCacheKey(entry));
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
/**
|
|
@@ -63,7 +77,7 @@ export async function loadEntryFiles(
|
|
|
63
77
|
options: LoadEntryFilesOptions = {},
|
|
64
78
|
): Promise<WorkspaceListing | null> {
|
|
65
79
|
if (!options.bustCache) {
|
|
66
|
-
const cached = sharedCache.get(entry
|
|
80
|
+
const cached = sharedCache.get(listingCacheKey(entry));
|
|
67
81
|
if (cached) return cached;
|
|
68
82
|
}
|
|
69
83
|
|
|
@@ -71,7 +85,7 @@ export async function loadEntryFiles(
|
|
|
71
85
|
if (raw === null) return null;
|
|
72
86
|
|
|
73
87
|
const listing = buildListing(raw);
|
|
74
|
-
sharedCache.set(entry
|
|
88
|
+
sharedCache.set(listingCacheKey(entry), listing);
|
|
75
89
|
return listing;
|
|
76
90
|
}
|
|
77
91
|
|