@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
@@ -81,9 +81,9 @@ describe("PlanEditor", () => {
81
81
  renderEditor({ onBuildFromPlan });
82
82
 
83
83
  await waitFor(() =>
84
- expect(screen.getByText("Build from plan")).toBeTruthy(),
84
+ expect(screen.getByText("Build")).toBeTruthy(),
85
85
  );
86
- fireEvent.click(screen.getByText("Build from plan"));
86
+ fireEvent.click(screen.getByText("Build"));
87
87
  expect(onBuildFromPlan).toHaveBeenCalledTimes(1);
88
88
  });
89
89
 
@@ -99,7 +99,7 @@ describe("PlanEditor", () => {
99
99
  );
100
100
  expect(screen.getByText("Rollout Plan").className).toContain("truncate");
101
101
 
102
- const buildLabel = screen.getByText("Build from plan");
102
+ const buildLabel = screen.getByText("Build");
103
103
  expect(buildLabel.className).toContain("truncate");
104
104
  const toolbar = screen.getByRole("tablist", { name: "Plan view" })
105
105
  .parentElement!;
@@ -188,7 +188,7 @@ describe("PlanEditor — read-only (superseded plan)", () => {
188
188
  );
189
189
  expect(screen.getByText("Superseded by a newer plan")).toBeTruthy();
190
190
  expect(screen.queryByText("Edit")).toBeNull();
191
- expect(screen.queryByText("Build from plan")).toBeNull();
191
+ expect(screen.queryByText("Build")).toBeNull();
192
192
  });
193
193
 
194
194
  it("never applies the current plan's draft to a superseded plan", async () => {
@@ -44,7 +44,7 @@ describe("PlanStreamingDocument — live plan tab view", () => {
44
44
  render(<PlanStreamingDocument displayText={"# Live Plan\n\nBody"} />);
45
45
 
46
46
  expect(screen.queryByText("Edit")).toBeNull();
47
- expect(screen.queryByText("Build from plan")).toBeNull();
47
+ expect(screen.queryByText("Build")).toBeNull();
48
48
  expect(screen.queryByRole("tablist")).toBeNull();
49
49
  });
50
50
  });
@@ -3,6 +3,8 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3
3
  import { create } from "@bufbuild/protobuf";
4
4
  import {
5
5
  CapturedFileChangeSchema,
6
+ FileChangeProgressSchema,
7
+ FileChangeProgressEntrySchema,
6
8
  FileChangeSetSchema,
7
9
  } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
8
10
  import { FileContentSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
@@ -70,6 +72,31 @@ function pendingChangeSet(id: string) {
70
72
  });
71
73
  }
72
74
 
75
+ function progressSnapshot() {
76
+ return create(FileChangeProgressSchema, {
77
+ changeSetId: "cs-1",
78
+ filesChanged: 2,
79
+ linesAdded: 4,
80
+ linesRemoved: 1,
81
+ entries: [
82
+ create(FileChangeProgressEntrySchema, {
83
+ pathAfter: "a.ts",
84
+ kind: FileChangeKind.ADD,
85
+ linesAdded: 4,
86
+ linesRemoved: 0,
87
+ }),
88
+ create(FileChangeProgressEntrySchema, {
89
+ pathBefore: "b.ts",
90
+ pathAfter: "b.ts",
91
+ kind: FileChangeKind.MODIFY,
92
+ linesAdded: 0,
93
+ linesRemoved: 1,
94
+ }),
95
+ ],
96
+ capturedAt: "2026-07-05T00:00:00Z",
97
+ });
98
+ }
99
+
73
100
  const stubConv = {
74
101
  session: { spec: {} },
75
102
  isLoading: false,
@@ -92,6 +119,7 @@ const stubConv = {
92
119
  submitApproval: vi.fn(),
93
120
  submittingApprovalIds: new Set<string>(),
94
121
  fileChangeSets: [] as ReturnType<typeof pendingChangeSet>[],
122
+ fileChangeProgress: undefined as ReturnType<typeof progressSnapshot> | undefined,
95
123
  submitFileDecision: vi.fn(),
96
124
  submittingFileDecisionKeys: new Set<string>(),
97
125
  fileDecisionErrors: new Map<string, Error>(),
@@ -144,6 +172,7 @@ import { SessionViewer } from "../SessionViewer";
144
172
  beforeEach(() => {
145
173
  threadProps.length = 0;
146
174
  stubConv.fileChangeSets = [];
175
+ stubConv.fileChangeProgress = undefined;
147
176
  stubConv.submitFileDecision = vi.fn();
148
177
  });
149
178
 
@@ -189,6 +218,34 @@ describe("SessionViewer — file-review dock wiring", () => {
189
218
  );
190
219
  });
191
220
 
221
+ it("renders no progress bar when no turn is capturing", () => {
222
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
223
+ expect(
224
+ document.querySelector('[data-cursor-target="file-change-progress-bar"]'),
225
+ ).toBeNull();
226
+ });
227
+
228
+ it("shows the mid-run progress strip above the composer while a turn is capturing", () => {
229
+ // Progress is present (CAPTURING) and the dock is empty — the two are
230
+ // mutually exclusive per turn, so the strip stands in for the dock.
231
+ stubConv.fileChangeProgress = progressSnapshot();
232
+ render(<SessionViewer sessionId="ses_1" org="acme" />);
233
+
234
+ const bar = document.querySelector('[data-cursor-target="file-change-progress-bar"]');
235
+ expect(bar).toBeTruthy();
236
+ expect(screen.getByText(/2 files changing/)).toBeTruthy();
237
+ expect(
238
+ document.querySelector('[data-cursor-target="file-review-dock"]'),
239
+ ).toBeNull();
240
+
241
+ // Pinned in the fixed strip, ordered before the composer input.
242
+ const composer = screen.getByTestId("composer-probe");
243
+ expect(bar!.parentElement).toBe(composer.parentElement);
244
+ expect(
245
+ bar!.compareDocumentPosition(composer) & Node.DOCUMENT_POSITION_FOLLOWING,
246
+ ).toBeTruthy();
247
+ });
248
+
192
249
  it("gives the thread records-only file-review props (no decision callbacks)", () => {
193
250
  render(<SessionViewer sessionId="ses_1" org="acme" />);
194
251
 
@@ -312,6 +312,50 @@ describe("useSessionConversation", () => {
312
312
  });
313
313
  });
314
314
 
315
+ it("sets NO optimistic pendingUserMessage for a buildFromPlan send (the thread hides the turn)", async () => {
316
+ methods.listBySession.mockResolvedValue({ entries: [] });
317
+ const newExec = makeExecution("new-exec", ExecutionPhase.EXECUTION_PENDING);
318
+ newExec.metadata!.id = "new-exec";
319
+ methods.executionCreate.mockResolvedValue(newExec);
320
+
321
+ const { result } = renderHook(
322
+ () => useSessionConversation("session-1", "org"),
323
+ { wrapper: createWrapper(mockStigmer) },
324
+ );
325
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
326
+
327
+ await act(async () => {
328
+ await result.current.sendFollowUp("Build from plan", {
329
+ buildFromPlan: true,
330
+ });
331
+ });
332
+
333
+ expect(result.current.pendingUserMessage).toBeNull();
334
+ expect(methods.executionCreate).toHaveBeenCalledWith(
335
+ expect.objectContaining({ message: "Build from plan" }),
336
+ );
337
+ });
338
+
339
+ it("sets pendingUserMessage when a buildFromPlan send FAILS (failure must be visible)", async () => {
340
+ methods.listBySession.mockResolvedValue({ entries: [] });
341
+ methods.executionCreate.mockRejectedValue(new Error("create boom"));
342
+
343
+ const { result } = renderHook(
344
+ () => useSessionConversation("session-1", "org"),
345
+ { wrapper: createWrapper(mockStigmer) },
346
+ );
347
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
348
+
349
+ await act(async () => {
350
+ await result.current.sendFollowUp("Build from plan", {
351
+ buildFromPlan: true,
352
+ });
353
+ });
354
+
355
+ expect(result.current.sendError?.message).toBe("create boom");
356
+ expect(result.current.pendingUserMessage).toBe("Build from plan");
357
+ });
358
+
315
359
  it("preserves the message and surfaces sendError when the send fails", async () => {
316
360
  methods.listBySession.mockResolvedValue({ entries: [] });
317
361
  methods.executionCreate.mockRejectedValue(new Error("create boom"));
@@ -61,32 +61,32 @@ function openPreviewFor(name: string) {
61
61
 
62
62
  afterEach(cleanup);
63
63
 
64
- describe("ArtifactsTab — plan 'Build from plan' wiring", () => {
65
- it("shows 'Build from plan' in the preview of a plan.md artifact", () => {
64
+ describe("ArtifactsTab — plan 'Build' wiring", () => {
65
+ it("shows 'Build' in the preview of a plan.md artifact", () => {
66
66
  renderTab(vi.fn());
67
67
 
68
68
  openPreviewFor("plan.md");
69
69
 
70
70
  const dialog = document.querySelector("dialog")!;
71
- expect(within(dialog).getByText("Build from plan")).toBeTruthy();
71
+ expect(within(dialog).getByText("Build")).toBeTruthy();
72
72
  });
73
73
 
74
- it("does not show 'Build from plan' in the preview of a non-plan artifact", () => {
74
+ it("does not show 'Build' in the preview of a non-plan artifact", () => {
75
75
  renderTab(vi.fn());
76
76
 
77
77
  openPreviewFor("notes.md");
78
78
 
79
79
  const dialog = document.querySelector("dialog")!;
80
- expect(within(dialog).queryByText("Build from plan")).toBeNull();
80
+ expect(within(dialog).queryByText("Build")).toBeNull();
81
81
  });
82
82
 
83
- it("invokes onImplementPlan when 'Build from plan' is clicked for a plan", () => {
83
+ it("invokes onImplementPlan when 'Build' is clicked for a plan", () => {
84
84
  const onImplementPlan = vi.fn();
85
85
  renderTab(onImplementPlan);
86
86
 
87
87
  openPreviewFor("plan.md");
88
88
  const dialog = document.querySelector("dialog")!;
89
- fireEvent.click(within(dialog).getByText("Build from plan"));
89
+ fireEvent.click(within(dialog).getByText("Build"));
90
90
 
91
91
  expect(onImplementPlan).toHaveBeenCalledTimes(1);
92
92
  });
@@ -6,7 +6,6 @@
6
6
  // their module graph (DD-003 headless-first).
7
7
 
8
8
  import { virtualEntryId } from "../internal/store/index.js";
9
- import { PLAN_ARTIFACT_NAME } from "../library/detect-plan-artifact.js";
10
9
 
11
10
  /**
12
11
  * Virtual entry id of the plan document tab. One tab per session — the host
@@ -15,5 +14,17 @@ import { PLAN_ARTIFACT_NAME } from "../library/detect-plan-artifact.js";
15
14
  */
16
15
  export const PLAN_DOCUMENT_ENTRY_ID = virtualEntryId("plan");
17
16
 
18
- /** The plan tab's path — doubles as its tab label, matching the artifact name. */
19
- export const PLAN_DOCUMENT_PATH = PLAN_ARTIFACT_NAME;
17
+ /**
18
+ * The plan tab's path — doubles as its tab label.
19
+ *
20
+ * Deliberately plan-AGNOSTIC ("Plan"), not the artifact's filename. The
21
+ * workspace surface identifies a virtual document's tab by
22
+ * `editorKey(entryId, path)`, so `path` is part of the tab's IDENTITY — and the
23
+ * session keeps exactly ONE plan tab that swaps which plan it shows (latest
24
+ * editable, or a superseded plan read-only). Now that plans carry unique,
25
+ * title-derived filenames (`<slug>_<id>.plan.md`), binding this label to any one
26
+ * plan's name would mint a new tab identity per plan and shatter the
27
+ * single-tab design. A stable, generic label is therefore required, not a
28
+ * stylistic choice — do not "fix" it to track the filename.
29
+ */
30
+ export const PLAN_DOCUMENT_PATH = "Plan";
@@ -32,7 +32,7 @@ export interface PlanDraftController {
32
32
  /**
33
33
  * Snapshot reader for submit-time consumers: returns the current draft (or
34
34
  * `null`) without the caller depending on draft state. Referentially stable
35
- * for the lifetime of the component (DD-010) — "Build from plan" reads the
35
+ * for the lifetime of the component (DD-010) — the "Build" action reads the
36
36
  * draft through this at click time, so per-keystroke draft updates never
37
37
  * re-bind the build callback or re-render the conversation column.
38
38
  */
@@ -3,7 +3,7 @@
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
5
  import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
6
- import type { FileChangeSet } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
6
+ import type { FileChangeProgress, FileChangeSet } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/filereview_pb";
7
7
  import { ApprovalAction, ExecutionPhase, FileChangeSetStatus, FileDecisionAction } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
8
8
  import type { Session } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
9
9
  import type { McpServerUsage as ProtoMcpServerUsage } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
@@ -97,7 +97,7 @@ export interface SendFollowUpOptions {
97
97
  */
98
98
  readonly interactionMode?: "agent" | "plan";
99
99
  /**
100
- * Marks this execution as a "Build from plan" turn.
100
+ * Marks this execution as a Build-from-plan turn.
101
101
  *
102
102
  * @see {@link CreateAgentExecutionInput.buildFromPlan}
103
103
  */
@@ -222,6 +222,13 @@ export interface UseSessionConversationReturn {
222
222
 
223
223
  /** Captured change sets awaiting file review on the active execution, empty when none. */
224
224
  readonly fileChangeSets: readonly FileChangeSet[];
225
+ /**
226
+ * Mid-run live capture (DD-32): the transient, non-authoritative snapshot of the
227
+ * workspace delta accumulating during the active turn ("N files changed so far").
228
+ * Undefined when no turn is currently capturing. Never decidable — the reviewed
229
+ * diff is {@link fileChangeSets}.
230
+ */
231
+ readonly fileChangeProgress: FileChangeProgress | undefined;
225
232
  /** Submit a file-review decision for a change set. The executionId is managed internally. */
226
233
  readonly submitFileDecision: (
227
234
  changeSetId: string,
@@ -554,7 +561,14 @@ export function useSessionConversation(
554
561
  // Capture for retry and clear any prior failure before the new attempt.
555
562
  lastSendRef.current = { message, options };
556
563
  setSendError(null);
557
- setPendingUserMessage(message);
564
+ // A Build-from-plan turn shows no optimistic bubble: its message is a
565
+ // machine-written label, not user prose, and the thread hides the turn
566
+ // entirely (the plan card's "Starting build…" state covers the send
567
+ // window). It is still set on FAILURE below, so a failed build send
568
+ // renders the failed-with-retry bubble instead of vanishing.
569
+ if (!options?.buildFromPlan) {
570
+ setPendingUserMessage(message);
571
+ }
558
572
 
559
573
  try {
560
574
  const needsSessionUpdate =
@@ -596,7 +610,10 @@ export function useSessionConversation(
596
610
  } catch (err) {
597
611
  // Surface the failure and KEEP the user's message visible (do not clear
598
612
  // pendingUserMessage) so the turn renders as failed-with-retry instead
599
- // of vanishing. Covers both the update() and create() paths.
613
+ // of vanishing. Covers both the update() and create() paths. For a
614
+ // build turn this is the FIRST time the message is set — failure is
615
+ // the one case where its label must become visible.
616
+ setPendingUserMessage(message);
600
617
  setSendError(toError(err));
601
618
  if (process.env.NODE_ENV !== "production") {
602
619
  console.error("[useSessionConversation] sendFollowUp failed:", err);
@@ -641,6 +658,16 @@ export function useSessionConversation(
641
658
  [activeStreamExecution],
642
659
  );
643
660
 
661
+ // Mid-run live capture (DD-32): the transient, non-authoritative "N files
662
+ // changed so far" snapshot for the active turn. The server clears it once the
663
+ // turn's change set leaves CAPTURING, so its mere presence means a turn is
664
+ // still accumulating changes. Reference-stable per DD-010 (it rides the
665
+ // structurally-shared live status).
666
+ const fileChangeProgress = useMemo<FileChangeProgress | undefined>(
667
+ () => activeStreamExecution?.status?.fileChangeProgress,
668
+ [activeStreamExecution],
669
+ );
670
+
644
671
  const submitFileDecision = useCallback(
645
672
  async (
646
673
  changeSetId: string,
@@ -685,6 +712,7 @@ export function useSessionConversation(
685
712
  clearApprovalError,
686
713
 
687
714
  fileChangeSets,
715
+ fileChangeProgress,
688
716
  submitFileDecision,
689
717
  submittingFileDecisionKeys: submittingDecisionKeys,
690
718
  fileDecisionErrors,
@@ -4,6 +4,17 @@ import { useCallback, type KeyboardEvent, type MouseEvent } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
5
  import { editorKey, type OpenEditor } from "../internal/store/index.js";
6
6
 
7
+ /**
8
+ * Deterministic, space-free DOM id for a tab, built from an instance-scoped
9
+ * `prefix` (a `useId()` value) and the editor `key`. Shared by the tab (its
10
+ * `id`) and the editor body (its `aria-labelledby`) so the two associate; the
11
+ * key is percent-encoded because editor keys embed file paths that may contain
12
+ * characters (spaces) illegal in a space-separated IDREF.
13
+ */
14
+ export function editorTabDomId(prefix: string, key: string): string {
15
+ return `${prefix}${encodeURIComponent(key)}`;
16
+ }
17
+
7
18
  /** Props for {@link EditorTabs}. */
8
19
  export interface EditorTabsProps {
9
20
  /** Open editors in tab order. */
@@ -16,6 +27,14 @@ export interface EditorTabsProps {
16
27
  readonly onPin: (entryId: string, path: string) => void;
17
28
  /** Close an editor (close button or middle click). */
18
29
  readonly onClose: (entryId: string, path: string) => void;
30
+ /**
31
+ * DOM id of the editor body (`role="tabpanel"`). When provided together with
32
+ * {@link tabIdPrefix}, each tab gets a stable `id` and `aria-controls` linking
33
+ * it to that panel. Optional so the strip degrades gracefully without a panel.
34
+ */
35
+ readonly panelId?: string;
36
+ /** Instance-scoped prefix for per-tab ids (see {@link editorTabDomId}). */
37
+ readonly tabIdPrefix?: string;
19
38
  /** Additional CSS classes for the root element. */
20
39
  readonly className?: string;
21
40
  }
@@ -28,8 +47,10 @@ export interface EditorTabsProps {
28
47
  *
29
48
  * A `role="tablist"` of `role="tab"` items; the matching `tabpanel` is the
30
49
  * editor body rendered by the surface. Arrow keys move focus between tabs and
31
- * activate; the per-tab close button is a nested control. All visual properties
32
- * flow through `--stgm-*` tokens (DD-005).
50
+ * activate; Delete/Backspace closes the focused tab. The visual close "X" is a
51
+ * presentational (`aria-hidden`) mouse affordance — assistive tech closes via
52
+ * the keyboard, so the X is not a nested interactive control (WCAG 4.1.2). All
53
+ * visual properties flow through `--stgm-*` tokens (DD-005).
33
54
  */
34
55
  export function EditorTabs({
35
56
  editors,
@@ -37,6 +58,8 @@ export function EditorTabs({
37
58
  onActivate,
38
59
  onPin,
39
60
  onClose,
61
+ panelId,
62
+ tabIdPrefix,
40
63
  className,
41
64
  }: EditorTabsProps) {
42
65
  const move = useCallback(
@@ -94,7 +117,9 @@ export function EditorTabs({
94
117
  <div
95
118
  key={key}
96
119
  role="tab"
120
+ id={tabIdPrefix ? editorTabDomId(tabIdPrefix, key) : undefined}
97
121
  aria-selected={isActive}
122
+ aria-controls={panelId}
98
123
  tabIndex={isActive ? 0 : -1}
99
124
  title={editor.path}
100
125
  onClick={() => onActivate(editor.entryId, editor.path)}
@@ -112,22 +137,26 @@ export function EditorTabs({
112
137
  <span className={cn("truncate", editor.preview && "italic")}>
113
138
  {basename}
114
139
  </span>
115
- <button
116
- type="button"
140
+ {/* The visual close "X" is a presentational mouse affordance, NOT a
141
+ nested interactive control: a `<button>` inside `role="tab"` would
142
+ violate WCAG 4.1.2 (axe `nested-interactive`). Assistive tech and
143
+ keyboard users close the focused tab via Delete/Backspace (handled
144
+ above), so `aria-hidden` here removes the redundant nested control
145
+ without losing the close action for anyone. */}
146
+ <span
147
+ aria-hidden="true"
148
+ title={`Close ${basename}`}
117
149
  onClick={(e) => {
118
150
  e.stopPropagation();
119
151
  onClose(editor.entryId, editor.path);
120
152
  }}
121
- aria-label={`Close ${basename}`}
122
- tabIndex={-1}
123
153
  className={cn(
124
- "shrink-0 rounded p-0.5 text-muted-foreground transition-opacity hover:bg-accent-hover hover:text-foreground",
125
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
154
+ "shrink-0 cursor-pointer rounded p-0.5 text-muted-foreground transition-opacity hover:bg-accent-hover hover:text-foreground",
126
155
  isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100",
127
156
  )}
128
157
  >
129
158
  <CloseIcon />
130
- </button>
159
+ </span>
131
160
  </div>
132
161
  );
133
162
  })}
@@ -101,7 +101,10 @@ function ExplorerRoot({
101
101
  )}
102
102
  title={entry.type === "local" ? entry.localPath : entry.gitUrl}
103
103
  >
104
- <span className="text-[10px] text-muted-foreground-subtle">
104
+ <span
105
+ aria-hidden="true"
106
+ className="text-[10px] text-muted-foreground-subtle"
107
+ >
105
108
  {expanded ? "▼" : "▶"}
106
109
  </span>
107
110
  <span className="truncate">{entry.name}</span>
@@ -167,6 +170,10 @@ function ExplorerRootBody({
167
170
  if (isLoading) {
168
171
  return (
169
172
  <div
173
+ // role="status" both announces the loading state to assistive tech and
174
+ // makes aria-label permitted here (a role-less generic div cannot carry
175
+ // an accessible name — axe `aria-prohibited-attr`).
176
+ role="status"
170
177
  className="space-y-1.5 px-2 py-2"
171
178
  aria-busy="true"
172
179
  aria-label={`Loading files for ${entryName}`}
@@ -190,7 +197,7 @@ function ExplorerRootBody({
190
197
  <button
191
198
  type="button"
192
199
  onClick={onRetry}
193
- className="ml-2 underline transition-colors hover:text-destructive"
200
+ className="ml-2 rounded-sm underline transition-colors hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
194
201
  >
195
202
  Retry
196
203
  </button>
@@ -226,8 +233,7 @@ function ExplorerRootBody({
226
233
  </nav>
227
234
  {truncated && (
228
235
  <p className="px-3 py-1.5 text-[0.65rem] text-muted-foreground">
229
- Showing a partial listing — this repository has too many files to load
230
- in full.
236
+ Showing a partial listing — too many files to load in full.
231
237
  </p>
232
238
  )}
233
239
  </>
@@ -5,6 +5,7 @@ import {
5
5
  useCallback,
6
6
  useEffect,
7
7
  useImperativeHandle,
8
+ useRef,
8
9
  useState,
9
10
  type KeyboardEvent,
10
11
  } from "react";
@@ -194,7 +195,10 @@ export const FileViewer = forwardRef<FileViewerHandle, FileViewerProps>(
194
195
  <div
195
196
  role="region"
196
197
  aria-label="File viewer"
197
- className={cn("flex h-full flex-col", className)}
198
+ // Focusable (programmatically / on click) so the Escape-to-close handler
199
+ // fires even when the read-only body holds no natively focusable element.
200
+ tabIndex={-1}
201
+ className={cn("flex h-full flex-col focus:outline-none", className)}
198
202
  onKeyDown={handleKeyDown}
199
203
  >
200
204
  {showHeader && (
@@ -260,17 +264,26 @@ function ViewerModeToggle({
260
264
  { value: "file", label: "File" },
261
265
  ];
262
266
 
267
+ // Selection follows focus, matching ActivityRail / SearchModeToggle.
268
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
269
+ const selectValue = useCallback(
270
+ (next: "diff" | "file") => {
271
+ onChange(next);
272
+ buttonRefs.current[next === "diff" ? 0 : 1]?.focus();
273
+ },
274
+ [onChange],
275
+ );
263
276
  const handleKeyDown = useCallback(
264
277
  (e: KeyboardEvent<HTMLButtonElement>) => {
265
278
  if (e.key === "ArrowRight" || e.key === "ArrowDown") {
266
279
  e.preventDefault();
267
- onChange("file");
280
+ selectValue("file");
268
281
  } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
269
282
  e.preventDefault();
270
- onChange("diff");
283
+ selectValue("diff");
271
284
  }
272
285
  },
273
- [onChange],
286
+ [selectValue],
274
287
  );
275
288
 
276
289
  return (
@@ -280,11 +293,14 @@ function ViewerModeToggle({
280
293
  aria-label="File view"
281
294
  className="inline-flex rounded-md bg-muted p-0.5"
282
295
  >
283
- {options.map((option) => {
296
+ {options.map((option, index) => {
284
297
  const isSelected = value === option.value;
285
298
  return (
286
299
  <button
287
300
  key={option.value}
301
+ ref={(el) => {
302
+ buttonRefs.current[index] = el;
303
+ }}
288
304
  type="button"
289
305
  role="radio"
290
306
  aria-checked={isSelected}
@@ -340,7 +356,12 @@ function FileViewerBody({
340
356
 
341
357
  if (isLoading) {
342
358
  return (
343
- <div className="space-y-2 p-4" aria-busy="true" aria-label="Loading file">
359
+ <div
360
+ role="status"
361
+ className="space-y-2 p-4"
362
+ aria-busy="true"
363
+ aria-label="Loading file"
364
+ >
344
365
  {SKELETON_LINE_WIDTHS.map((width, i) => (
345
366
  <div
346
367
  key={i}
@@ -161,8 +161,28 @@ export function WorkspaceContentSearch({
161
161
  const belowMinLength = trimmed.length > 0 && trimmed.length < MIN_QUERY_LENGTH;
162
162
  const showEntryHeaders = groups.length > 1;
163
163
 
164
+ // Screen-reader-only summary in a persistent polite live region: status swaps
165
+ // (searching → results/none) happen across separate unmounting branches, so a
166
+ // stable region is what actually gets announced. Terse by design.
167
+ const statusMessage = isUnsupported
168
+ ? ""
169
+ : trimmed.length === 0
170
+ ? ""
171
+ : belowMinLength
172
+ ? `Type at least ${MIN_QUERY_LENGTH} characters to search.`
173
+ : isLoading
174
+ ? "Searching file contents…"
175
+ : groups.length === 0
176
+ ? `No files containing ${trimmed}.`
177
+ : totalMatches > flatResults.length
178
+ ? `Showing the first ${flatResults.length} of ${totalMatches} matches.`
179
+ : `${totalMatches} ${totalMatches === 1 ? "match" : "matches"}.`;
180
+
164
181
  return (
165
182
  <div className={cn("flex h-full flex-col", className)}>
183
+ <div role="status" aria-live="polite" className="sr-only">
184
+ {statusMessage}
185
+ </div>
166
186
  <div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
167
187
  <SearchIcon />
168
188
  <input
@@ -336,27 +356,31 @@ function LineRow({
336
356
  readonly isOpen: boolean;
337
357
  readonly onOpen: () => void;
338
358
  }) {
359
+ // The `role="option"` IS the interactive leaf of the listbox (keyboard
360
+ // activation flows through the combobox input's Enter handler +
361
+ // aria-activedescendant). It must not nest another interactive control — a
362
+ // button inside would violate WCAG 4.1.2 (axe `nested-interactive`). So the
363
+ // click handler and row styling live on the option itself.
339
364
  return (
340
- <li id={id} role="option" aria-selected={isFocused}>
341
- <button
342
- type="button"
343
- onClick={onOpen}
344
- tabIndex={-1}
345
- aria-current={isOpen ? "true" : undefined}
346
- className={cn(
347
- "flex w-full items-baseline gap-2 py-0.5 pl-6 pr-3 text-left text-xs transition-colors",
348
- isFocused && "bg-muted",
349
- isOpen && "bg-muted",
350
- !isFocused && !isOpen && "hover:bg-muted",
351
- )}
352
- >
353
- <span className="w-8 shrink-0 text-right font-mono text-[0.65rem] tabular-nums text-muted-foreground-subtle">
354
- {match.line}
355
- </span>
356
- <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">
357
- <HighlightedPreview text={match.preview} query={query} />
358
- </span>
359
- </button>
365
+ <li
366
+ id={id}
367
+ role="option"
368
+ aria-selected={isFocused}
369
+ aria-current={isOpen ? "true" : undefined}
370
+ onClick={onOpen}
371
+ className={cn(
372
+ "flex cursor-pointer items-baseline gap-2 py-0.5 pl-6 pr-3 text-xs transition-colors",
373
+ isFocused && "bg-muted",
374
+ isOpen && "bg-muted",
375
+ !isFocused && !isOpen && "hover:bg-muted",
376
+ )}
377
+ >
378
+ <span className="w-8 shrink-0 text-right font-mono text-[0.65rem] tabular-nums text-muted-foreground-subtle">
379
+ {match.line}
380
+ </span>
381
+ <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">
382
+ <HighlightedPreview text={match.preview} query={query} />
383
+ </span>
360
384
  </li>
361
385
  );
362
386
  }