@stigmer/react 3.1.2 → 3.1.3

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 (171) hide show
  1. package/composer/SessionComposer.d.ts +6 -5
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +1 -1
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/execution/ArtifactPreviewModal.d.ts +5 -5
  6. package/execution/ArtifactPreviewModal.js +4 -4
  7. package/execution/ArtifactPreviewModal.js.map +1 -1
  8. package/execution/FileChangeProgressBar.d.ts +47 -0
  9. package/execution/FileChangeProgressBar.d.ts.map +1 -0
  10. package/execution/FileChangeProgressBar.js +49 -0
  11. package/execution/FileChangeProgressBar.js.map +1 -0
  12. package/execution/FileChangesView.d.ts.map +1 -1
  13. package/execution/FileChangesView.js +19 -4
  14. package/execution/FileChangesView.js.map +1 -1
  15. package/execution/FileReviewAtoms.d.ts +27 -0
  16. package/execution/FileReviewAtoms.d.ts.map +1 -0
  17. package/execution/FileReviewAtoms.js +64 -0
  18. package/execution/FileReviewAtoms.js.map +1 -0
  19. package/execution/FileReviewCard.d.ts +2 -2
  20. package/execution/FileReviewCard.d.ts.map +1 -1
  21. package/execution/FileReviewCard.js +32 -62
  22. package/execution/FileReviewCard.js.map +1 -1
  23. package/execution/MessageEntry.d.ts +0 -9
  24. package/execution/MessageEntry.d.ts.map +1 -1
  25. package/execution/MessageEntry.js +1 -11
  26. package/execution/MessageEntry.js.map +1 -1
  27. package/execution/MessageThread.d.ts +3 -4
  28. package/execution/MessageThread.d.ts.map +1 -1
  29. package/execution/MessageThread.js +25 -11
  30. package/execution/MessageThread.js.map +1 -1
  31. package/execution/PlanArtifactCard.d.ts +16 -10
  32. package/execution/PlanArtifactCard.d.ts.map +1 -1
  33. package/execution/PlanArtifactCard.js +31 -7
  34. package/execution/PlanArtifactCard.js.map +1 -1
  35. package/execution/PlanCompletionCard.d.ts +4 -4
  36. package/execution/PlanCompletionCard.d.ts.map +1 -1
  37. package/execution/PlanCompletionCard.js +4 -4
  38. package/execution/PlanCompletionCard.js.map +1 -1
  39. package/execution/PlanStreamingCard.d.ts +7 -2
  40. package/execution/PlanStreamingCard.d.ts.map +1 -1
  41. package/execution/PlanStreamingCard.js +11 -6
  42. package/execution/PlanStreamingCard.js.map +1 -1
  43. package/execution/index.d.ts +4 -0
  44. package/execution/index.d.ts.map +1 -1
  45. package/execution/index.js +2 -0
  46. package/execution/index.js.map +1 -1
  47. package/execution/use-build-from-plan-hotkey.d.ts +1 -1
  48. package/execution/use-build-from-plan-hotkey.js +1 -1
  49. package/execution/useArtifactCopy.d.ts +51 -0
  50. package/execution/useArtifactCopy.d.ts.map +1 -0
  51. package/execution/useArtifactCopy.js +75 -0
  52. package/execution/useArtifactCopy.js.map +1 -0
  53. package/execution/useArtifactDownload.d.ts.map +1 -1
  54. package/execution/useArtifactDownload.js +17 -5
  55. package/execution/useArtifactDownload.js.map +1 -1
  56. package/execution/useCreateAgentExecution.d.ts +5 -3
  57. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  58. package/execution/useCreateAgentExecution.js.map +1 -1
  59. package/index.d.ts +3 -3
  60. package/index.d.ts.map +1 -1
  61. package/index.js +2 -2
  62. package/index.js.map +1 -1
  63. package/internal/file-tree/FileTreeNode.d.ts.map +1 -1
  64. package/internal/file-tree/FileTreeNode.js +2 -2
  65. package/internal/file-tree/FileTreeNode.js.map +1 -1
  66. package/library/detect-plan-artifact.d.ts +27 -9
  67. package/library/detect-plan-artifact.d.ts.map +1 -1
  68. package/library/detect-plan-artifact.js +26 -6
  69. package/library/detect-plan-artifact.js.map +1 -1
  70. package/library/index.d.ts +1 -1
  71. package/library/index.d.ts.map +1 -1
  72. package/library/index.js +1 -1
  73. package/library/index.js.map +1 -1
  74. package/package.json +4 -4
  75. package/session/PlanEditor.js +1 -1
  76. package/session/PlanEditor.js.map +1 -1
  77. package/session/SessionViewer.d.ts.map +1 -1
  78. package/session/SessionViewer.js +32 -13
  79. package/session/SessionViewer.js.map +1 -1
  80. package/session/plan-document.d.ts +14 -2
  81. package/session/plan-document.d.ts.map +1 -1
  82. package/session/plan-document.js +14 -3
  83. package/session/plan-document.js.map +1 -1
  84. package/session/usePlanDraft.d.ts +1 -1
  85. package/session/useSessionConversation.d.ts +9 -2
  86. package/session/useSessionConversation.d.ts.map +1 -1
  87. package/session/useSessionConversation.js +19 -2
  88. package/session/useSessionConversation.js.map +1 -1
  89. package/src/composer/SessionComposer.tsx +7 -6
  90. package/src/execution/ArtifactPreviewModal.tsx +9 -9
  91. package/src/execution/FileChangeProgressBar.tsx +121 -0
  92. package/src/execution/FileChangesView.tsx +18 -4
  93. package/src/execution/FileReviewAtoms.tsx +89 -0
  94. package/src/execution/FileReviewCard.tsx +57 -90
  95. package/src/execution/MessageEntry.tsx +0 -62
  96. package/src/execution/MessageThread.tsx +29 -15
  97. package/src/execution/PlanArtifactCard.tsx +130 -52
  98. package/src/execution/PlanCompletionCard.tsx +5 -5
  99. package/src/execution/PlanStreamingCard.tsx +15 -11
  100. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
  101. package/src/execution/__tests__/FileChangeProgressBar.test.tsx +125 -0
  102. package/src/execution/__tests__/FileReviewCard.test.tsx +66 -7
  103. package/src/execution/__tests__/MessageThread.test.tsx +1 -1
  104. package/src/execution/__tests__/PlanArtifactCard.test.tsx +52 -18
  105. package/src/execution/__tests__/PlanCompletionCard.test.tsx +2 -2
  106. package/src/execution/__tests__/PlanStreamingCard.test.tsx +11 -4
  107. package/src/execution/__tests__/buildThreadItems-plan-completion.test.ts +104 -8
  108. package/src/execution/__tests__/message-entry.test.tsx +6 -37
  109. package/src/execution/__tests__/useArtifactCopy.test.tsx +90 -0
  110. package/src/execution/__tests__/useArtifactDownload.test.tsx +2 -0
  111. package/src/execution/index.ts +6 -0
  112. package/src/execution/use-build-from-plan-hotkey.ts +1 -1
  113. package/src/execution/useArtifactCopy.ts +109 -0
  114. package/src/execution/useArtifactDownload.ts +17 -5
  115. package/src/execution/useCreateAgentExecution.ts +5 -3
  116. package/src/index.ts +6 -0
  117. package/src/internal/file-tree/FileTreeNode.tsx +13 -2
  118. package/src/library/__tests__/detect-plan-artifact.test.ts +21 -0
  119. package/src/library/detect-plan-artifact.ts +32 -10
  120. package/src/library/index.ts +2 -0
  121. package/src/session/PlanEditor.tsx +1 -1
  122. package/src/session/SessionViewer.tsx +50 -18
  123. package/src/session/__tests__/PlanEditor.test.tsx +4 -4
  124. package/src/session/__tests__/PlanStreamingDocument.test.tsx +1 -1
  125. package/src/session/__tests__/SessionViewer-fileReviewDock.test.tsx +57 -0
  126. package/src/session/__tests__/useSessionConversation.test.tsx +44 -0
  127. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +7 -7
  128. package/src/session/plan-document.ts +14 -3
  129. package/src/session/usePlanDraft.ts +1 -1
  130. package/src/session/useSessionConversation.ts +32 -4
  131. package/src/workspace/EditorTabs.tsx +38 -9
  132. package/src/workspace/ExplorerTree.tsx +10 -4
  133. package/src/workspace/FileViewer.tsx +27 -6
  134. package/src/workspace/WorkspaceContentSearch.tsx +44 -20
  135. package/src/workspace/WorkspaceEditor.tsx +19 -16
  136. package/src/workspace/WorkspaceFileSearch.tsx +51 -28
  137. package/src/workspace/WorkspaceSurface.tsx +106 -48
  138. package/src/workspace/__tests__/EditorTabs.test.tsx +4 -2
  139. package/src/workspace/__tests__/ExplorerTree.test.tsx +52 -0
  140. package/src/workspace/__tests__/FileViewer.test.tsx +18 -0
  141. package/src/workspace/__tests__/WorkspaceContentSearch.test.tsx +38 -5
  142. package/src/workspace/__tests__/WorkspaceEditor-initialPanel.test.tsx +19 -0
  143. package/src/workspace/__tests__/WorkspaceFileSearch.test.tsx +27 -6
  144. package/src/workspace/__tests__/WorkspaceSurface.test.tsx +70 -0
  145. package/src/workspace/__tests__/a11y/harness.tsx +140 -0
  146. package/src/workspace/__tests__/a11y/mock-capabilities.ts +138 -0
  147. package/src/workspace/__tests__/a11y/workspace-editor.a11y.test.tsx +65 -0
  148. package/src/workspace/__tests__/a11y/workspace-explorer-search.a11y.test.tsx +112 -0
  149. package/src/workspace/__tests__/a11y/workspace-viewer-tabs.a11y.test.tsx +69 -0
  150. package/styles.css +1 -1
  151. package/workspace/EditorTabs.d.ts +21 -3
  152. package/workspace/EditorTabs.d.ts.map +1 -1
  153. package/workspace/EditorTabs.js +18 -6
  154. package/workspace/EditorTabs.js.map +1 -1
  155. package/workspace/ExplorerTree.js +8 -4
  156. package/workspace/ExplorerTree.js.map +1 -1
  157. package/workspace/FileViewer.d.ts.map +1 -1
  158. package/workspace/FileViewer.js +19 -8
  159. package/workspace/FileViewer.js.map +1 -1
  160. package/workspace/WorkspaceContentSearch.d.ts.map +1 -1
  161. package/workspace/WorkspaceContentSearch.js +23 -2
  162. package/workspace/WorkspaceContentSearch.js.map +1 -1
  163. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  164. package/workspace/WorkspaceEditor.js +8 -10
  165. package/workspace/WorkspaceEditor.js.map +1 -1
  166. package/workspace/WorkspaceFileSearch.d.ts.map +1 -1
  167. package/workspace/WorkspaceFileSearch.js +23 -3
  168. package/workspace/WorkspaceFileSearch.js.map +1 -1
  169. package/workspace/WorkspaceSurface.d.ts.map +1 -1
  170. package/workspace/WorkspaceSurface.js +54 -18
  171. package/workspace/WorkspaceSurface.js.map +1 -1
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { renderHook, act, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import type { Stigmer } from "@stigmer/sdk";
5
+ import { StigmerContext } from "../../context";
6
+ import { useArtifactCopy } from "../useArtifactCopy";
7
+
8
+ function wrapperFor(stigmer: Stigmer) {
9
+ return ({ children }: { children: ReactNode }) => (
10
+ <StigmerContext.Provider value={stigmer}>{children}</StigmerContext.Provider>
11
+ );
12
+ }
13
+
14
+ function makeStigmer(getArtifactContent = vi.fn()) {
15
+ return { agentExecution: { getArtifactContent } } as unknown as Stigmer;
16
+ }
17
+
18
+ const writeText = vi.fn().mockResolvedValue(undefined);
19
+
20
+ beforeEach(() => {
21
+ writeText.mockClear();
22
+ vi.stubGlobal("navigator", { clipboard: { writeText } });
23
+ });
24
+
25
+ describe("useArtifactCopy", () => {
26
+ it("fetches content at click time and writes decoded text to the clipboard", async () => {
27
+ const bytes = new TextEncoder().encode("# Plan\n\nsteps");
28
+ const fn = vi.fn().mockResolvedValue({ content: bytes });
29
+ const { result } = renderHook(() => useArtifactCopy("aex_1"), {
30
+ wrapper: wrapperFor(makeStigmer(fn)),
31
+ });
32
+
33
+ expect(result.current.copied).toBe(false);
34
+ await act(async () => {
35
+ await result.current.copy("artifacts/aex_1/plan.md");
36
+ });
37
+
38
+ expect(fn).toHaveBeenCalledTimes(1);
39
+ const req = fn.mock.calls[0][0];
40
+ expect(req.executionId).toBe("aex_1");
41
+ expect(req.storageKey).toBe("artifacts/aex_1/plan.md");
42
+ expect(writeText).toHaveBeenCalledWith("# Plan\n\nsteps");
43
+ expect(result.current.copied).toBe(true);
44
+ expect(result.current.error).toBeNull();
45
+ });
46
+
47
+ it("resets the copied flag after the feedback window", async () => {
48
+ vi.useFakeTimers();
49
+ const bytes = new TextEncoder().encode("x");
50
+ const fn = vi.fn().mockResolvedValue({ content: bytes });
51
+ const { result } = renderHook(() => useArtifactCopy("aex_1"), {
52
+ wrapper: wrapperFor(makeStigmer(fn)),
53
+ });
54
+
55
+ await act(async () => {
56
+ await result.current.copy("artifacts/aex_1/plan.md");
57
+ });
58
+ expect(result.current.copied).toBe(true);
59
+
60
+ act(() => {
61
+ vi.advanceTimersByTime(2000);
62
+ });
63
+ expect(result.current.copied).toBe(false);
64
+ vi.useRealTimers();
65
+ });
66
+
67
+ it("is a no-op when the execution id is unknown", async () => {
68
+ const fn = vi.fn();
69
+ const { result } = renderHook(() => useArtifactCopy(null), {
70
+ wrapper: wrapperFor(makeStigmer(fn)),
71
+ });
72
+ await act(async () => {
73
+ await result.current.copy("artifacts/x/plan.md");
74
+ });
75
+ expect(fn).not.toHaveBeenCalled();
76
+ expect(writeText).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("surfaces an error when the content RPC fails", async () => {
80
+ const fn = vi.fn().mockRejectedValue(new Error("boom"));
81
+ const { result } = renderHook(() => useArtifactCopy("aex_1"), {
82
+ wrapper: wrapperFor(makeStigmer(fn)),
83
+ });
84
+ await act(async () => {
85
+ await result.current.copy("artifacts/aex_1/plan.md");
86
+ });
87
+ await waitFor(() => expect(result.current.error?.message).toBe("boom"));
88
+ expect(result.current.copied).toBe(false);
89
+ });
90
+ });
@@ -38,6 +38,8 @@ describe("useArtifactDownload", () => {
38
38
  const req = fn.mock.calls[0][0];
39
39
  expect(req.executionId).toBe("aex_1");
40
40
  expect(req.storageKey).toBe("artifacts/aex_1/file.bin");
41
+ // Save-to-disk actions force an attachment disposition on the minted URL.
42
+ expect(req.asAttachment).toBe(true);
41
43
  expect(result.current.isDownloading).toBe(false);
42
44
  expect(result.current.error).toBeNull();
43
45
  });
@@ -62,6 +62,9 @@ export type {
62
62
  export { useArtifactDownload } from "./useArtifactDownload.js";
63
63
  export type { UseArtifactDownloadReturn } from "./useArtifactDownload.js";
64
64
 
65
+ export { useArtifactCopy } from "./useArtifactCopy.js";
66
+ export type { UseArtifactCopyReturn } from "./useArtifactCopy.js";
67
+
65
68
  export { useToolOutputContent } from "./useToolOutputContent.js";
66
69
  export type {
67
70
  UseToolOutputContentReturn,
@@ -230,6 +233,9 @@ export type { FileReviewCardProps } from "./FileReviewCard.js";
230
233
  export { FileReviewDock } from "./FileReviewDock.js";
231
234
  export type { FileReviewDockProps } from "./FileReviewDock.js";
232
235
 
236
+ export { FileChangeProgressBar } from "./FileChangeProgressBar.js";
237
+ export type { FileChangeProgressBarProps } from "./FileChangeProgressBar.js";
238
+
233
239
  export { ApprovalContext, useApproval } from "./ApprovalContext.js";
234
240
  export type { ApprovalContextValue, UseApprovalResult } from "./ApprovalContext.js";
235
241
 
@@ -3,7 +3,7 @@
3
3
  import { useCallback, type KeyboardEvent } from "react";
4
4
 
5
5
  /**
6
- * Card-scoped `Cmd/Ctrl+Enter` accelerator for the "Build from plan" action.
6
+ * Card-scoped `Cmd/Ctrl+Enter` accelerator for a plan card's "Build" action.
7
7
  *
8
8
  * Returns an `onKeyDown` handler for a plan card's root element. Because it is
9
9
  * attached to the card (not `window`), it fires only when the keystroke
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { create } from "@bufbuild/protobuf";
5
+ import { GetArtifactContentRequestSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
6
+ import { useStigmer } from "../hooks.js";
7
+ import { toError } from "../internal/toError.js";
8
+
9
+ /** How long the transient `copied` flag stays true after a successful copy. */
10
+ const COPIED_FEEDBACK_MS = 2000;
11
+
12
+ /** Return value of {@link useArtifactCopy}. */
13
+ export interface UseArtifactCopyReturn {
14
+ /**
15
+ * Fetch the artifact's text at click time and write it to the clipboard.
16
+ * No-op when the execution id is unknown or a copy is already in flight.
17
+ * Resolves once the copy has been attempted.
18
+ *
19
+ * @param storageKey - Storage key of the artifact to copy.
20
+ */
21
+ readonly copy: (storageKey: string) => Promise<void>;
22
+
23
+ /** `true` while the content is being fetched / written to the clipboard. */
24
+ readonly isCopying: boolean;
25
+
26
+ /**
27
+ * `true` for a brief window after a successful copy (drives "Copied"
28
+ * affordance feedback), then resets automatically.
29
+ */
30
+ readonly copied: boolean;
31
+
32
+ /** Error from the last failed copy attempt, or `null` when healthy. */
33
+ readonly error: Error | null;
34
+ }
35
+
36
+ /**
37
+ * Behavior hook that copies an execution artifact's text to the clipboard on
38
+ * demand.
39
+ *
40
+ * The imperative sibling of {@link useArtifactDownload}: it fetches content at
41
+ * click time via `getArtifactContent` (CORS-safe, through the Stigmer API)
42
+ * rather than requiring the caller to have loaded it. This suits content-free
43
+ * surfaces — a plan card that detects the artifact by convention and never
44
+ * fetches until the user acts — where a mount-time {@link useArtifactContent}
45
+ * would be wasteful.
46
+ *
47
+ * Returns an imperative `copy(storageKey)` action plus a transient `copied`
48
+ * flag for feedback, mirroring the modal's copy affordance so every surface
49
+ * shares one implementation.
50
+ *
51
+ * @param executionId - Execution that produced the artifacts, or `null` to
52
+ * disable (the action becomes a no-op).
53
+ *
54
+ * @example
55
+ * ```tsx
56
+ * const { copy, isCopying, copied } = useArtifactCopy(executionId);
57
+ * <button onClick={() => copy(artifact.storageKey)} disabled={isCopying}>
58
+ * {copied ? "Copied" : "Copy"}
59
+ * </button>
60
+ * ```
61
+ *
62
+ * @see useArtifactDownload — imperative save-to-disk counterpart
63
+ * @see useArtifactContent — declarative content reader (preview rendering)
64
+ */
65
+ export function useArtifactCopy(
66
+ executionId: string | null,
67
+ ): UseArtifactCopyReturn {
68
+ const stigmer = useStigmer();
69
+ const [isCopying, setIsCopying] = useState(false);
70
+ const [copied, setCopied] = useState(false);
71
+ const [error, setError] = useState<Error | null>(null);
72
+
73
+ // Clear the pending "copied" timer on unmount so a resolved copy never sets
74
+ // state on an unmounted component.
75
+ const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
76
+ useEffect(() => {
77
+ return () => {
78
+ if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
79
+ };
80
+ }, []);
81
+
82
+ const copy = useCallback(
83
+ async (storageKey: string) => {
84
+ if (!executionId || !storageKey || isCopying) return;
85
+ setIsCopying(true);
86
+ setError(null);
87
+ try {
88
+ const result = await stigmer.agentExecution.getArtifactContent(
89
+ create(GetArtifactContentRequestSchema, { executionId, storageKey }),
90
+ );
91
+ const text = new TextDecoder().decode(result.content);
92
+ await navigator.clipboard.writeText(text);
93
+ setCopied(true);
94
+ if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
95
+ copiedTimer.current = setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
96
+ } catch (err) {
97
+ setError(toError(err));
98
+ } finally {
99
+ setIsCopying(false);
100
+ }
101
+ },
102
+ [executionId, stigmer, isCopying],
103
+ );
104
+
105
+ return useMemo(
106
+ () => ({ copy, isCopying, copied, error }),
107
+ [copy, isCopying, copied, error],
108
+ );
109
+ }
@@ -65,7 +65,16 @@ export function useArtifactDownload(
65
65
  setError(null);
66
66
  try {
67
67
  const result = await stigmer.agentExecution.getArtifactDownloadUrl(
68
- create(GetArtifactDownloadUrlRequestSchema, { executionId, storageKey }),
68
+ create(GetArtifactDownloadUrlRequestSchema, {
69
+ executionId,
70
+ storageKey,
71
+ // This is a save-to-disk action, so mint a URL that forces a
72
+ // browser download (Content-Disposition: attachment). Browsers
73
+ // ignore the anchor's `download` attribute cross-origin, so the
74
+ // disposition must ride on the URL itself. Contrast
75
+ // useArtifactDownloadUrl, which stays inline for `<img src>`.
76
+ asAttachment: true,
77
+ }),
69
78
  );
70
79
  if (result.downloadUrl) {
71
80
  triggerBrowserDownload(result.downloadUrl, fileName);
@@ -86,10 +95,13 @@ export function useArtifactDownload(
86
95
  }
87
96
 
88
97
  /**
89
- * Trigger a browser download for a URL via a transient anchor click. The
90
- * `download` hint is best-effort: browsers ignore it for cross-origin URLs
91
- * (e.g. R2), in which case the navigation still serves the file. Falls back to
92
- * a new tab in non-DOM environments.
98
+ * Trigger a browser download for a URL via a transient anchor click.
99
+ *
100
+ * The URL minted for this flow carries `Content-Disposition: attachment`
101
+ * (requested via `asAttachment`), so the browser saves the file even
102
+ * cross-origin — where it would ignore the anchor's `download` attribute. The
103
+ * `download` hint is still set as a same-origin nicety. Falls back to a new tab
104
+ * in non-DOM environments.
93
105
  */
94
106
  function triggerBrowserDownload(url: string, fileName?: string): void {
95
107
  if (typeof document === "undefined") {
@@ -55,11 +55,13 @@ export interface CreateAgentExecutionInput {
55
55
  */
56
56
  readonly interactionMode?: "agent" | "plan";
57
57
  /**
58
- * Marks this execution as a "Build from plan" turn: the user approved a
58
+ * Marks this execution as a Build-from-plan turn: the user approved a
59
59
  * plan from a prior Plan-mode execution and asked the agent to implement
60
60
  * it. The runner injects the implement-plan directive (pointing at the
61
- * attached approved plan when present); the thread renders the turn as a
62
- * compact chip instead of the raw message text.
61
+ * attached approved plan when present); the thread hides the turn's
62
+ * machine-written message entirely the plan card above it is the
63
+ * visible cause. Surfaces without that treatment (the CLI, history)
64
+ * show the message text as-is.
63
65
  *
64
66
  * Maps to `ExecutionConfig.build_from_plan` in the proto.
65
67
  */
package/src/index.ts CHANGED
@@ -283,6 +283,7 @@ export {
283
283
  ApprovalCardBody,
284
284
  FileReviewCard,
285
285
  FileReviewDock,
286
+ FileChangeProgressBar,
286
287
  ApprovalContext,
287
288
  useApproval,
288
289
  FileReviewContext,
@@ -307,6 +308,7 @@ export {
307
308
  useArtifactContent,
308
309
  useArtifactDownloadUrl,
309
310
  useArtifactDownload,
311
+ useArtifactCopy,
310
312
  useToolOutputContent,
311
313
  useFileChangeContent,
312
314
  execIdFromStorageKey,
@@ -375,6 +377,7 @@ export type {
375
377
  ApprovalCardBodyProps,
376
378
  FileReviewCardProps,
377
379
  FileReviewDockProps,
380
+ FileChangeProgressBarProps,
378
381
  ApprovalContextValue,
379
382
  UseApprovalResult,
380
383
  FileReviewContextValue,
@@ -397,6 +400,7 @@ export type {
397
400
  UseArtifactDownloadUrlReturn,
398
401
  UseArtifactDownloadUrlOptions,
399
402
  UseArtifactDownloadReturn,
403
+ UseArtifactCopyReturn,
400
404
  UseToolOutputContentReturn,
401
405
  ToolOutputRefLike,
402
406
  UseFileChangeContentReturn,
@@ -980,10 +984,12 @@ export {
980
984
  detectSkillPackage,
981
985
  useDetectSkillPackage,
982
986
  isPlanArtifact,
987
+ isPlanArtifactName,
983
988
  findPlanArtifact,
984
989
  findLatestSessionPlan,
985
990
  findStreamingPlan,
986
991
  PLAN_ARTIFACT_NAME,
992
+ PLAN_ARTIFACT_SUFFIX,
987
993
  parseResourceYaml,
988
994
  serializeAgentYaml,
989
995
  serializeMcpServerYaml,
@@ -102,7 +102,15 @@ export function FileTreeNode({
102
102
  );
103
103
 
104
104
  return (
105
- <li role="treeitem" aria-expanded={isFolder ? expanded : undefined}>
105
+ <li
106
+ role="treeitem"
107
+ // Depth conveys tree nesting to assistive tech (indentation is visual
108
+ // only). aria-level is 1-based; `depth` is 0-based. aria-expanded stays on
109
+ // the treeitem (the <li>) — its child group is nested here, so this is the
110
+ // element a tree-aware screen reader reads expandability from.
111
+ aria-level={depth + 1}
112
+ aria-expanded={isFolder ? expanded : undefined}
113
+ >
106
114
  <button
107
115
  type="button"
108
116
  onClick={handleClick}
@@ -123,7 +131,10 @@ export function FileTreeNode({
123
131
  aria-current={isSelected ? "true" : undefined}
124
132
  >
125
133
  {isFolder && (
126
- <span className="text-[10px] text-muted-foreground-subtle">
134
+ <span
135
+ aria-hidden="true"
136
+ className="text-[10px] text-muted-foreground-subtle"
137
+ >
127
138
  {expanded ? "▼" : "▶"}
128
139
  </span>
129
140
  )}
@@ -9,6 +9,7 @@ import { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agente
9
9
  import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
10
10
  import {
11
11
  isPlanArtifact,
12
+ isPlanArtifactName,
12
13
  findPlanArtifact,
13
14
  findLatestSessionPlan,
14
15
  PLAN_ARTIFACT_NAME,
@@ -35,11 +36,31 @@ function executionWithId(id: string, ...artifacts: ReturnType<typeof artifact>[]
35
36
  });
36
37
  }
37
38
 
39
+ describe("isPlanArtifactName", () => {
40
+ it("accepts the legacy exact name and any *.plan.md", () => {
41
+ expect(isPlanArtifactName(PLAN_ARTIFACT_NAME)).toBe(true);
42
+ expect(isPlanArtifactName("feature_x.plan.md")).toBe(true);
43
+ // The runner's current shape: hyphenated slug + content-hash discriminator.
44
+ expect(isPlanArtifactName("fix-the-download-ux_a1b2c3d4.plan.md")).toBe(true);
45
+ expect(isPlanArtifactName("a1b2c3d4.plan.md")).toBe(true);
46
+ });
47
+
48
+ it("rejects non-plan filenames", () => {
49
+ expect(isPlanArtifactName("report.md")).toBe(false);
50
+ expect(isPlanArtifactName("plan.txt")).toBe(false);
51
+ expect(isPlanArtifactName("myplan.md")).toBe(false);
52
+ });
53
+ });
54
+
38
55
  describe("isPlanArtifact", () => {
39
56
  it("is true for a FILE artifact named plan.md", () => {
40
57
  expect(isPlanArtifact(artifact({ name: PLAN_ARTIFACT_NAME }))).toBe(true);
41
58
  });
42
59
 
60
+ it("is true for a FILE artifact named <slug>_<id>.plan.md", () => {
61
+ expect(isPlanArtifact(artifact({ name: "plan-card-ux-cleanup_a1b2c3d4.plan.md" }))).toBe(true);
62
+ });
63
+
43
64
  it("is false for a directory named plan.md", () => {
44
65
  expect(
45
66
  isPlanArtifact(artifact({ name: "plan.md", kind: ExecutionArtifactKind.DIRECTORY })),
@@ -3,26 +3,48 @@ import type { ExecutionArtifact } from "@stigmer/protos/ai/stigmer/agentic/agent
3
3
  import { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
4
4
 
5
5
  /**
6
- * The session's current plan: the published `plan.md` artifact of the most
7
- * recent execution that produced one, plus the execution it belongs to
8
- * (artifact content RPCs are execution-scoped).
6
+ * The session's current plan: the published plan artifact (a `*.plan.md` FILE)
7
+ * of the most recent execution that produced one, plus the execution it belongs
8
+ * to (artifact content RPCs are execution-scoped).
9
9
  */
10
10
  export interface SessionPlan {
11
11
  /** ID of the execution that published the plan. */
12
12
  readonly executionId: string;
13
- /** The published `plan.md` artifact. */
13
+ /** The published plan artifact (`*.plan.md`). */
14
14
  readonly artifact: ExecutionArtifact;
15
15
  }
16
16
 
17
17
  /**
18
- * Canonical filename a Plan-mode execution publishes its plan under. Kept in
19
- * sync with the runner's `publishPlanArtifact` (shared/plan-artifact.ts).
18
+ * Legacy plan filename. Detection-only: {@link isPlanArtifactName} still
19
+ * accepts this exact name so plans published before named artifacts existed
20
+ * keep working. The runner never freshly emits it (a titleless plan falls back
21
+ * to a bare `<id>.plan.md`). Kept in sync with the runner's
22
+ * `PLAN_ARTIFACT_NAME` (shared/plan-artifact.ts).
20
23
  */
21
24
  export const PLAN_ARTIFACT_NAME = "plan.md";
22
25
 
23
26
  /**
24
- * Returns `true` when an artifact is the published plan: a FILE artifact named
25
- * `plan.md`.
27
+ * Suffix every named plan artifact carries (`<slug>_<id>.plan.md`, or a bare
28
+ * `<id>.plan.md` when untitled). The runner derives the slug from the plan's
29
+ * title and the `<id>` from its content hash; detection keys on this suffix.
30
+ * Kept in sync with the runner's `PLAN_ARTIFACT_SUFFIX` (shared/plan-artifact.ts)
31
+ * — the runner and browser SDK have disjoint module graphs, so the constant is
32
+ * duplicated by design, mirroring {@link PLAN_ARTIFACT_NAME}.
33
+ */
34
+ export const PLAN_ARTIFACT_SUFFIX = ".plan.md";
35
+
36
+ /**
37
+ * Returns `true` when a filename is a plan's: the legacy exact `plan.md`, or
38
+ * any `*.plan.md`. Accepting the legacy name keeps plans published before named
39
+ * artifacts existed detectable.
40
+ */
41
+ export function isPlanArtifactName(name: string): boolean {
42
+ return name === PLAN_ARTIFACT_NAME || name.endsWith(PLAN_ARTIFACT_SUFFIX);
43
+ }
44
+
45
+ /**
46
+ * Returns `true` when an artifact is the published plan: a FILE artifact whose
47
+ * name satisfies {@link isPlanArtifactName}.
26
48
  *
27
49
  * Detection is by convention — the same lightweight, content-free approach used
28
50
  * for skill packages ({@link isSkillPackage}) — so the UI never needs an extra
@@ -32,7 +54,7 @@ export const PLAN_ARTIFACT_NAME = "plan.md";
32
54
  export function isPlanArtifact(artifact: ExecutionArtifact): boolean {
33
55
  return (
34
56
  artifact.kind === ExecutionArtifactKind.FILE &&
35
- artifact.name === PLAN_ARTIFACT_NAME
57
+ isPlanArtifactName(artifact.name)
36
58
  );
37
59
  }
38
60
 
@@ -56,7 +78,7 @@ export function findPlanArtifact(
56
78
 
57
79
  /**
58
80
  * Finds the session's LATEST plan across all executions — the plan the panel's
59
- * plan document tab edits and "Build from plan" implements. Executions are
81
+ * plan document tab edits and the "Build" action implements. Executions are
60
82
  * scanned newest-first (the array is chronological), mirroring the thread's
61
83
  * latest-plan-owns-the-build-action rule so every surface agrees on which
62
84
  * plan is current.
@@ -26,9 +26,11 @@ export type { SkillPackageDetection } from "./detect-skill-package.js";
26
26
 
27
27
  export {
28
28
  isPlanArtifact,
29
+ isPlanArtifactName,
29
30
  findPlanArtifact,
30
31
  findLatestSessionPlan,
31
32
  PLAN_ARTIFACT_NAME,
33
+ PLAN_ARTIFACT_SUFFIX,
32
34
  } from "./detect-plan-artifact.js";
33
35
  export type { SessionPlan } from "./detect-plan-artifact.js";
34
36
 
@@ -203,7 +203,7 @@ export function PlanEditor({
203
203
  >
204
204
  <ImplementIcon />
205
205
  <span className="truncate">
206
- {buildDisabled ? "Starting build…" : "Build from plan"}
206
+ {buildDisabled ? "Starting build…" : "Build"}
207
207
  </span>
208
208
  </button>
209
209
  )
@@ -25,13 +25,17 @@ import { useWorkspaceEditors, isVirtualEntryId } from "../internal/store/index.j
25
25
  import { ThreadSelectionContext } from "../execution/ThreadSelectionContext.js";
26
26
  import { useSelectedThreadItem } from "../execution/useThreadSelection.js";
27
27
  import { MessageThread } from "../execution/MessageThread.js";
28
+ import { FileChangeProgressBar } from "../execution/FileChangeProgressBar.js";
28
29
  import { FileReviewDock } from "../execution/FileReviewDock.js";
30
+ import {
31
+ FilePathContext,
32
+ type FilePathContextValue,
33
+ } from "../execution/FilePathContext.js";
29
34
  import { ThreadSkeleton } from "../execution/ThreadSkeleton.js";
30
35
  import { SessionComposer } from "../composer/index.js";
31
36
  import { SecretFlowErrorGuide, isSecretFlowError } from "../error/index.js";
32
37
  import { useStigmer } from "../hooks.js";
33
38
  import {
34
- PLAN_ARTIFACT_NAME,
35
39
  findLatestSessionPlan,
36
40
  findPlanArtifact,
37
41
  type SessionPlan,
@@ -57,18 +61,22 @@ import type { SessionAudience } from "./audience.js";
57
61
  * Where the approved plan mounts in the implement execution's workspace —
58
62
  * the harnesses' standard attachment inputs directory, where the runner's
59
63
  * implement-plan directive points the agent (shared/implement-plan-prompt.ts).
60
- * Attaching at this exact path is what makes the build implement the document
61
- * the user approved (including in-place edits), not a paraphrase from memory.
64
+ * Attaching under the plan's own filename (e.g. `feature-x_a1b2c3d4.plan.md`) is what
65
+ * makes the build implement the document the user approved (including in-place
66
+ * edits), not a paraphrase from memory; the runner detects it by the plan
67
+ * filename convention (`isPlanArtifactName`), so the exact name is free to vary.
62
68
  */
63
- const APPROVED_PLAN_MOUNT_PATH = `.stigmer/inputs/${PLAN_ARTIFACT_NAME}`;
69
+ function approvedPlanMountPath(planFileName: string): string {
70
+ return `.stigmer/inputs/${planFileName}`;
71
+ }
64
72
 
65
73
  /**
66
74
  * The build turn's user message — a short human-readable label, NOT the
67
75
  * implement instruction. The agent-facing instruction is runner-injected
68
- * (keyed off `executionConfig.buildFromPlan`), so this text exists for
69
- * humans: the thread renders the turn as a compact chip from the same flag,
70
- * and surfaces without the chip treatment (the CLI, older clients) show
71
- * this label as-is.
76
+ * (keyed off `executionConfig.buildFromPlan`). The thread hides the turn
77
+ * entirely from the same flag (the plan card above it is the visible cause);
78
+ * this label exists for surfaces without that treatment (the CLI, execution
79
+ * history, older clients), which show it as-is.
72
80
  */
73
81
  const BUILD_FROM_PLAN_MESSAGE = "Build from plan";
74
82
 
@@ -377,7 +385,7 @@ export function SessionViewer({
377
385
  setPlanAttachFailed(false);
378
386
 
379
387
  // `buildFromPlan` (not message text) is what tells the runner to inject
380
- // the implement directive and the thread to render the compact chip
388
+ // the implement directive and the thread to hide the turn's message
381
389
  // with or without the attachment (the runner picks the directive variant).
382
390
  const submitImplementTurn = (attachments?: AttachmentInput[]) => {
383
391
  composerRef.current?.submit(BUILD_FROM_PLAN_MESSAGE, {
@@ -398,22 +406,25 @@ export function SessionViewer({
398
406
  // execution. The published artifact itself stays immutable; the approved
399
407
  // copy is a new input on the build turn (edit-as-input provenance).
400
408
  setIsBuildingFromPlan(true);
409
+ // The approved copy keeps the published plan's filename, so the mounted
410
+ // input, the download, and the plan card all agree on one name.
411
+ const planFileName = sessionPlan.artifact.name;
401
412
  void (async () => {
402
413
  try {
403
414
  const approvedText =
404
415
  planDraft.readDraft() ?? (await fetchPlanText(stigmer, sessionPlan));
405
416
  const response = await stigmer.agentExecution.uploadAttachment(
406
417
  create(UploadAttachmentRequestSchema, {
407
- filename: PLAN_ARTIFACT_NAME,
418
+ filename: planFileName,
408
419
  content: new TextEncoder().encode(approvedText),
409
420
  contentType: "text/markdown",
410
421
  }),
411
422
  );
412
423
  submitImplementTurn([
413
424
  {
414
- filename: PLAN_ARTIFACT_NAME,
425
+ filename: planFileName,
415
426
  storageKey: response.storageKey,
416
- mountPath: APPROVED_PLAN_MOUNT_PATH,
427
+ mountPath: approvedPlanMountPath(planFileName),
417
428
  contentType: "text/markdown",
418
429
  },
419
430
  ]);
@@ -655,6 +666,20 @@ const ConversationColumn = memo(function ConversationColumn({
655
666
  [conv.stop, composerRef],
656
667
  );
657
668
 
669
+ // The composer-docked FileReviewDock sits OUTSIDE MessageThread, so the
670
+ // thread's own FilePathContext provider does not reach it. This provider
671
+ // gives the dock's file list the same path resolution and click routing
672
+ // (open in the panel's viewer; GitHub/copy fallback) as the transcript —
673
+ // one click behavior for a path everywhere in the session. Memoized so the
674
+ // dock's memoized cards are not invalidated by unrelated renders (DD-010).
675
+ const dockFilePathCtx = useMemo<FilePathContextValue>(
676
+ () => ({
677
+ workspaceEntries: conv.workspaceEntries ?? [],
678
+ onFilePathClick,
679
+ }),
680
+ [conv.workspaceEntries, onFilePathClick],
681
+ );
682
+
658
683
  return (
659
684
  <div className="flex h-full min-w-0 flex-col">
660
685
  <MessageThread
@@ -703,12 +728,19 @@ const ConversationColumn = memo(function ConversationColumn({
703
728
  decision the agent is blocked on can never scroll out of view. The
704
729
  thread renders only observational rows (badges) and read-only
705
730
  settled records; this is the one decision surface. */}
706
- <FileReviewDock
707
- changeSets={conv.fileChangeSets}
708
- onSubmit={conv.submitFileDecision}
709
- submittingDecisionKeys={conv.submittingFileDecisionKeys}
710
- decisionErrors={conv.fileDecisionErrors}
711
- />
731
+ <FilePathContext.Provider value={dockFilePathCtx}>
732
+ {/* Mid-run live capture (DD-32): the "N files changed so far" strip for
733
+ a still-running turn. Mutually exclusive with the dock below —
734
+ progress shows while CAPTURING, the dock once AWAITING_REVIEW — so it
735
+ hands off cleanly when review opens. Non-interactive. */}
736
+ <FileChangeProgressBar progress={conv.fileChangeProgress} />
737
+ <FileReviewDock
738
+ changeSets={conv.fileChangeSets}
739
+ onSubmit={conv.submitFileDecision}
740
+ submittingDecisionKeys={conv.submittingFileDecisionKeys}
741
+ decisionErrors={conv.fileDecisionErrors}
742
+ />
743
+ </FilePathContext.Provider>
712
744
  <SessionComposer
713
745
  ref={composerRef}
714
746
  onSubmit={flow.handleSubmit}