@stigmer/react 3.1.2 → 3.1.4

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 (285) hide show
  1. package/composer/SessionComposer.d.ts +37 -5
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +13 -5
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/execution/ArtifactApplyButton.d.ts +25 -0
  6. package/execution/ArtifactApplyButton.d.ts.map +1 -0
  7. package/execution/ArtifactApplyButton.js +21 -0
  8. package/execution/ArtifactApplyButton.js.map +1 -0
  9. package/execution/ArtifactContentBody.d.ts +36 -0
  10. package/execution/ArtifactContentBody.d.ts.map +1 -0
  11. package/execution/ArtifactContentBody.js +62 -0
  12. package/execution/ArtifactContentBody.js.map +1 -0
  13. package/execution/ArtifactDocument.d.ts +48 -0
  14. package/execution/ArtifactDocument.d.ts.map +1 -0
  15. package/execution/ArtifactDocument.js +89 -0
  16. package/execution/ArtifactDocument.js.map +1 -0
  17. package/execution/ArtifactPreviewModal.d.ts +9 -9
  18. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  19. package/execution/ArtifactPreviewModal.js +18 -145
  20. package/execution/ArtifactPreviewModal.js.map +1 -1
  21. package/execution/ArtifactRow.d.ts +49 -0
  22. package/execution/ArtifactRow.d.ts.map +1 -0
  23. package/execution/ArtifactRow.js +56 -0
  24. package/execution/ArtifactRow.js.map +1 -0
  25. package/execution/ArtifactsWidget.d.ts +6 -9
  26. package/execution/ArtifactsWidget.d.ts.map +1 -1
  27. package/execution/ArtifactsWidget.js +15 -19
  28. package/execution/ArtifactsWidget.js.map +1 -1
  29. package/execution/FileChangeProgressBar.d.ts +47 -0
  30. package/execution/FileChangeProgressBar.d.ts.map +1 -0
  31. package/execution/FileChangeProgressBar.js +49 -0
  32. package/execution/FileChangeProgressBar.js.map +1 -0
  33. package/execution/FileChangesView.d.ts.map +1 -1
  34. package/execution/FileChangesView.js +19 -4
  35. package/execution/FileChangesView.js.map +1 -1
  36. package/execution/FileReviewAtoms.d.ts +27 -0
  37. package/execution/FileReviewAtoms.d.ts.map +1 -0
  38. package/execution/FileReviewAtoms.js +64 -0
  39. package/execution/FileReviewAtoms.js.map +1 -0
  40. package/execution/FileReviewCard.d.ts +2 -2
  41. package/execution/FileReviewCard.d.ts.map +1 -1
  42. package/execution/FileReviewCard.js +32 -62
  43. package/execution/FileReviewCard.js.map +1 -1
  44. package/execution/MessageEntry.d.ts +0 -9
  45. package/execution/MessageEntry.d.ts.map +1 -1
  46. package/execution/MessageEntry.js +1 -11
  47. package/execution/MessageEntry.js.map +1 -1
  48. package/execution/MessageThread.d.ts +3 -4
  49. package/execution/MessageThread.d.ts.map +1 -1
  50. package/execution/MessageThread.js +25 -11
  51. package/execution/MessageThread.js.map +1 -1
  52. package/execution/PlanArtifactCard.d.ts +16 -10
  53. package/execution/PlanArtifactCard.d.ts.map +1 -1
  54. package/execution/PlanArtifactCard.js +31 -7
  55. package/execution/PlanArtifactCard.js.map +1 -1
  56. package/execution/PlanCompletionCard.d.ts +4 -4
  57. package/execution/PlanCompletionCard.d.ts.map +1 -1
  58. package/execution/PlanCompletionCard.js +4 -4
  59. package/execution/PlanCompletionCard.js.map +1 -1
  60. package/execution/PlanStreamingCard.d.ts +7 -2
  61. package/execution/PlanStreamingCard.d.ts.map +1 -1
  62. package/execution/PlanStreamingCard.js +11 -6
  63. package/execution/PlanStreamingCard.js.map +1 -1
  64. package/execution/index.d.ts +12 -2
  65. package/execution/index.d.ts.map +1 -1
  66. package/execution/index.js +6 -1
  67. package/execution/index.js.map +1 -1
  68. package/execution/use-build-from-plan-hotkey.d.ts +1 -1
  69. package/execution/use-build-from-plan-hotkey.js +1 -1
  70. package/execution/useArtifactContent.d.ts +6 -1
  71. package/execution/useArtifactContent.d.ts.map +1 -1
  72. package/execution/useArtifactContent.js +7 -2
  73. package/execution/useArtifactContent.js.map +1 -1
  74. package/execution/useArtifactCopy.d.ts +51 -0
  75. package/execution/useArtifactCopy.d.ts.map +1 -0
  76. package/execution/useArtifactCopy.js +75 -0
  77. package/execution/useArtifactCopy.js.map +1 -0
  78. package/execution/useArtifactDownload.d.ts.map +1 -1
  79. package/execution/useArtifactDownload.js +17 -5
  80. package/execution/useArtifactDownload.js.map +1 -1
  81. package/execution/useArtifactInspection.d.ts +85 -0
  82. package/execution/useArtifactInspection.d.ts.map +1 -0
  83. package/execution/useArtifactInspection.js +143 -0
  84. package/execution/useArtifactInspection.js.map +1 -0
  85. package/execution/useCreateAgentExecution.d.ts +16 -3
  86. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  87. package/execution/useCreateAgentExecution.js +1 -0
  88. package/execution/useCreateAgentExecution.js.map +1 -1
  89. package/index.d.ts +4 -4
  90. package/index.d.ts.map +1 -1
  91. package/index.js +3 -3
  92. package/index.js.map +1 -1
  93. package/internal/MermaidDiagram.d.ts +47 -0
  94. package/internal/MermaidDiagram.d.ts.map +1 -0
  95. package/internal/MermaidDiagram.js +118 -0
  96. package/internal/MermaidDiagram.js.map +1 -0
  97. package/internal/file-tree/FileTreeNode.d.ts.map +1 -1
  98. package/internal/file-tree/FileTreeNode.js +2 -2
  99. package/internal/file-tree/FileTreeNode.js.map +1 -1
  100. package/internal/markdown-components.d.ts.map +1 -1
  101. package/internal/markdown-components.js +34 -0
  102. package/internal/markdown-components.js.map +1 -1
  103. package/internal/mermaid-loader.d.ts +26 -0
  104. package/internal/mermaid-loader.d.ts.map +1 -0
  105. package/internal/mermaid-loader.js +34 -0
  106. package/internal/mermaid-loader.js.map +1 -0
  107. package/internal/mermaid-theme-css.d.ts +41 -0
  108. package/internal/mermaid-theme-css.d.ts.map +1 -0
  109. package/internal/mermaid-theme-css.js +179 -0
  110. package/internal/mermaid-theme-css.js.map +1 -0
  111. package/internal/slug.d.ts +9 -5
  112. package/internal/slug.d.ts.map +1 -1
  113. package/internal/slug.js +10 -6
  114. package/internal/slug.js.map +1 -1
  115. package/library/detect-plan-artifact.d.ts +27 -9
  116. package/library/detect-plan-artifact.d.ts.map +1 -1
  117. package/library/detect-plan-artifact.js +26 -6
  118. package/library/detect-plan-artifact.js.map +1 -1
  119. package/library/index.d.ts +1 -1
  120. package/library/index.d.ts.map +1 -1
  121. package/library/index.js +1 -1
  122. package/library/index.js.map +1 -1
  123. package/package.json +5 -4
  124. package/session/PlanEditor.js +1 -1
  125. package/session/PlanEditor.js.map +1 -1
  126. package/session/SessionViewer.d.ts.map +1 -1
  127. package/session/SessionViewer.js +122 -30
  128. package/session/SessionViewer.js.map +1 -1
  129. package/session/artifact-document.d.ts +13 -0
  130. package/session/artifact-document.d.ts.map +1 -0
  131. package/session/artifact-document.js +22 -0
  132. package/session/artifact-document.js.map +1 -0
  133. package/session/facets/ArtifactsTab.d.ts +30 -4
  134. package/session/facets/ArtifactsTab.d.ts.map +1 -1
  135. package/session/facets/ArtifactsTab.js +32 -10
  136. package/session/facets/ArtifactsTab.js.map +1 -1
  137. package/session/index.d.ts +2 -1
  138. package/session/index.d.ts.map +1 -1
  139. package/session/index.js +4 -1
  140. package/session/index.js.map +1 -1
  141. package/session/plan-document.d.ts +14 -2
  142. package/session/plan-document.d.ts.map +1 -1
  143. package/session/plan-document.js +14 -3
  144. package/session/plan-document.js.map +1 -1
  145. package/session/usePlanDraft.d.ts +1 -1
  146. package/session/useSessionArtifacts.d.ts +17 -0
  147. package/session/useSessionArtifacts.d.ts.map +1 -1
  148. package/session/useSessionArtifacts.js +16 -5
  149. package/session/useSessionArtifacts.js.map +1 -1
  150. package/session/useSessionConversation.d.ts +26 -3
  151. package/session/useSessionConversation.d.ts.map +1 -1
  152. package/session/useSessionConversation.js +51 -6
  153. package/session/useSessionConversation.js.map +1 -1
  154. package/session/useSessionPageFlow.d.ts.map +1 -1
  155. package/session/useSessionPageFlow.js +1 -0
  156. package/session/useSessionPageFlow.js.map +1 -1
  157. package/session/useSessionPanel.d.ts +20 -0
  158. package/session/useSessionPanel.d.ts.map +1 -1
  159. package/session/useSessionPanel.js +13 -0
  160. package/session/useSessionPanel.js.map +1 -1
  161. package/session/useSessionRailViews.d.ts +13 -1
  162. package/session/useSessionRailViews.d.ts.map +1 -1
  163. package/session/useSessionRailViews.js +5 -3
  164. package/session/useSessionRailViews.js.map +1 -1
  165. package/src/composer/SessionComposer.tsx +76 -7
  166. package/src/composer/__tests__/SessionComposer-editing.test.tsx +128 -0
  167. package/src/execution/ArtifactApplyButton.tsx +79 -0
  168. package/src/execution/ArtifactContentBody.tsx +255 -0
  169. package/src/execution/ArtifactDocument.tsx +344 -0
  170. package/src/execution/ArtifactPreviewModal.tsx +49 -415
  171. package/src/execution/ArtifactRow.tsx +165 -0
  172. package/src/execution/ArtifactsWidget.tsx +24 -33
  173. package/src/execution/FileChangeProgressBar.tsx +121 -0
  174. package/src/execution/FileChangesView.tsx +18 -4
  175. package/src/execution/FileReviewAtoms.tsx +89 -0
  176. package/src/execution/FileReviewCard.tsx +57 -90
  177. package/src/execution/MessageEntry.tsx +0 -62
  178. package/src/execution/MessageThread.tsx +29 -15
  179. package/src/execution/PlanArtifactCard.tsx +130 -52
  180. package/src/execution/PlanCompletionCard.tsx +5 -5
  181. package/src/execution/PlanStreamingCard.tsx +15 -11
  182. package/src/execution/__tests__/ArtifactContentBody.test.tsx +131 -0
  183. package/src/execution/__tests__/ArtifactDocument.test.tsx +100 -0
  184. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
  185. package/src/execution/__tests__/ArtifactRow.test.tsx +139 -0
  186. package/src/execution/__tests__/ArtifactsWidget.test.tsx +78 -0
  187. package/src/execution/__tests__/FileChangeProgressBar.test.tsx +125 -0
  188. package/src/execution/__tests__/FileReviewCard.test.tsx +66 -7
  189. package/src/execution/__tests__/MessageThread.test.tsx +1 -1
  190. package/src/execution/__tests__/PlanArtifactCard.test.tsx +52 -18
  191. package/src/execution/__tests__/PlanCompletionCard.test.tsx +2 -2
  192. package/src/execution/__tests__/PlanStreamingCard.test.tsx +11 -4
  193. package/src/execution/__tests__/buildThreadItems-plan-completion.test.ts +104 -8
  194. package/src/execution/__tests__/message-entry.test.tsx +56 -39
  195. package/src/execution/__tests__/useArtifactCopy.test.tsx +90 -0
  196. package/src/execution/__tests__/useArtifactDownload.test.tsx +2 -0
  197. package/src/execution/__tests__/useArtifactInspection.test.tsx +151 -0
  198. package/src/execution/__tests__/useCreateAgentExecution.test.tsx +35 -0
  199. package/src/execution/index.ts +20 -2
  200. package/src/execution/use-build-from-plan-hotkey.ts +1 -1
  201. package/src/execution/useArtifactContent.ts +7 -0
  202. package/src/execution/useArtifactCopy.ts +109 -0
  203. package/src/execution/useArtifactDownload.ts +17 -5
  204. package/src/execution/useArtifactInspection.ts +252 -0
  205. package/src/execution/useCreateAgentExecution.ts +17 -3
  206. package/src/index.ts +17 -2
  207. package/src/internal/MermaidDiagram.tsx +164 -0
  208. package/src/internal/__tests__/a11y/mermaid-theme.a11y.test.tsx +122 -0
  209. package/src/internal/__tests__/markdown-components.test.tsx +93 -1
  210. package/src/internal/__tests__/mermaid-diagram.test.tsx +282 -0
  211. package/src/internal/file-tree/FileTreeNode.tsx +13 -2
  212. package/src/internal/markdown-components.tsx +36 -1
  213. package/src/internal/mermaid-loader.ts +44 -0
  214. package/src/internal/mermaid-theme-css.ts +178 -0
  215. package/src/internal/slug.ts +10 -6
  216. package/src/library/__tests__/detect-plan-artifact.test.ts +21 -0
  217. package/src/library/detect-plan-artifact.ts +32 -10
  218. package/src/library/index.ts +2 -0
  219. package/src/session/PlanEditor.tsx +1 -1
  220. package/src/session/SessionViewer.tsx +227 -51
  221. package/src/session/__tests__/PlanEditor.test.tsx +4 -4
  222. package/src/session/__tests__/PlanStreamingDocument.test.tsx +1 -1
  223. package/src/session/__tests__/SessionViewer-artifactTabs.test.tsx +210 -0
  224. package/src/session/__tests__/SessionViewer-editResubmit.test.tsx +233 -0
  225. package/src/session/__tests__/SessionViewer-fileReviewDock.test.tsx +57 -0
  226. package/src/session/__tests__/useSessionArtifacts.test.ts +57 -0
  227. package/src/session/__tests__/useSessionConversation.test.tsx +216 -1
  228. package/src/session/__tests__/useSessionPanel.test.ts +70 -1
  229. package/src/session/artifact-document.ts +23 -0
  230. package/src/session/facets/ArtifactsTab.tsx +66 -18
  231. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +111 -47
  232. package/src/session/index.ts +5 -1
  233. package/src/session/plan-document.ts +14 -3
  234. package/src/session/usePlanDraft.ts +1 -1
  235. package/src/session/useSessionArtifacts.ts +16 -5
  236. package/src/session/useSessionConversation.ts +80 -10
  237. package/src/session/useSessionPageFlow.ts +1 -0
  238. package/src/session/useSessionPanel.ts +41 -0
  239. package/src/session/useSessionRailViews.tsx +21 -1
  240. package/src/workspace/EditorTabs.tsx +38 -9
  241. package/src/workspace/ExplorerTree.tsx +10 -4
  242. package/src/workspace/FileViewer.tsx +27 -6
  243. package/src/workspace/WorkspaceContentSearch.tsx +44 -20
  244. package/src/workspace/WorkspaceEditor.tsx +19 -16
  245. package/src/workspace/WorkspaceFileSearch.tsx +51 -28
  246. package/src/workspace/WorkspaceSurface.tsx +106 -48
  247. package/src/workspace/__tests__/EditorTabs.test.tsx +4 -2
  248. package/src/workspace/__tests__/ExplorerTree.test.tsx +52 -0
  249. package/src/workspace/__tests__/FileViewer.test.tsx +18 -0
  250. package/src/workspace/__tests__/WorkspaceContentSearch.test.tsx +38 -5
  251. package/src/workspace/__tests__/WorkspaceEditor-initialPanel.test.tsx +19 -0
  252. package/src/workspace/__tests__/WorkspaceFileSearch.test.tsx +27 -6
  253. package/src/workspace/__tests__/WorkspaceSurface.test.tsx +70 -0
  254. package/src/workspace/__tests__/a11y/harness.tsx +140 -0
  255. package/src/workspace/__tests__/a11y/mock-capabilities.ts +138 -0
  256. package/src/workspace/__tests__/a11y/workspace-editor.a11y.test.tsx +65 -0
  257. package/src/workspace/__tests__/a11y/workspace-explorer-search.a11y.test.tsx +112 -0
  258. package/src/workspace/__tests__/a11y/workspace-viewer-tabs.a11y.test.tsx +69 -0
  259. package/styles.css +1 -1
  260. package/workspace/EditorTabs.d.ts +21 -3
  261. package/workspace/EditorTabs.d.ts.map +1 -1
  262. package/workspace/EditorTabs.js +18 -6
  263. package/workspace/EditorTabs.js.map +1 -1
  264. package/workspace/ExplorerTree.js +8 -4
  265. package/workspace/ExplorerTree.js.map +1 -1
  266. package/workspace/FileViewer.d.ts.map +1 -1
  267. package/workspace/FileViewer.js +19 -8
  268. package/workspace/FileViewer.js.map +1 -1
  269. package/workspace/WorkspaceContentSearch.d.ts.map +1 -1
  270. package/workspace/WorkspaceContentSearch.js +23 -2
  271. package/workspace/WorkspaceContentSearch.js.map +1 -1
  272. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  273. package/workspace/WorkspaceEditor.js +8 -10
  274. package/workspace/WorkspaceEditor.js.map +1 -1
  275. package/workspace/WorkspaceFileSearch.d.ts.map +1 -1
  276. package/workspace/WorkspaceFileSearch.js +23 -3
  277. package/workspace/WorkspaceFileSearch.js.map +1 -1
  278. package/workspace/WorkspaceSurface.d.ts.map +1 -1
  279. package/workspace/WorkspaceSurface.js +54 -18
  280. package/workspace/WorkspaceSurface.js.map +1 -1
  281. package/execution/ArtifactCard.d.ts +0 -81
  282. package/execution/ArtifactCard.d.ts.map +0 -1
  283. package/execution/ArtifactCard.js +0 -130
  284. package/execution/ArtifactCard.js.map +0 -1
  285. package/src/execution/ArtifactCard.tsx +0 -320
@@ -1,6 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
3
+ import {
4
+ useCallback,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ type KeyboardEvent,
9
+ type ReactNode,
10
+ } from "react";
4
11
  import { cn } from "@stigmer/theme";
5
12
  import type { FileChange } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
6
13
  import { ResizableSplit } from "../internal/ResizableSplit.js";
@@ -18,7 +25,7 @@ import type { WorkspaceContentSearcher } from "./WorkspaceContentSearcher.js";
18
25
  import { WorkspaceFileSearch } from "./WorkspaceFileSearch.js";
19
26
  import { WorkspaceContentSearch } from "./WorkspaceContentSearch.js";
20
27
  import { FileViewer, type FileViewerHandle } from "./FileViewer.js";
21
- import { EditorTabs } from "./EditorTabs.js";
28
+ import { EditorTabs, editorTabDomId } from "./EditorTabs.js";
22
29
  import { ExplorerTree } from "./ExplorerTree.js";
23
30
 
24
31
  /** The built-in rail views every surface has. */
@@ -302,8 +309,14 @@ function ActivityRail({
302
309
  ...(extraViews ?? []),
303
310
  ];
304
311
 
305
- // Roving selection: arrows move relative to the *focused* item's position,
306
- // so the rail scales to any number of injected views.
312
+ // One ref per rail button so selection can move DOM focus with it (below).
313
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
314
+
315
+ // Roving radiogroup: selection follows focus. `keydown` fires on the focused
316
+ // button, so its `index` is the current position; we select the neighbour AND
317
+ // move focus to it. Moving focus is essential — without it focus stays pinned
318
+ // on the entered button (whose index never changes) and every later view
319
+ // (Config/Changes/…) is unreachable by keyboard.
307
320
  const handleKeyDown = useCallback(
308
321
  (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
309
322
  let nextIndex: number | null = null;
@@ -311,10 +324,15 @@ function ActivityRail({
311
324
  nextIndex = Math.min(index + 1, items.length - 1);
312
325
  } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
313
326
  nextIndex = Math.max(index - 1, 0);
327
+ } else if (e.key === "Home") {
328
+ nextIndex = 0;
329
+ } else if (e.key === "End") {
330
+ nextIndex = items.length - 1;
314
331
  }
315
332
  if (nextIndex === null || nextIndex === index) return;
316
333
  e.preventDefault();
317
334
  onViewChange(items[nextIndex].id);
335
+ buttonRefs.current[nextIndex]?.focus();
318
336
  },
319
337
  [items, onViewChange],
320
338
  );
@@ -332,6 +350,9 @@ function ActivityRail({
332
350
  return (
333
351
  <button
334
352
  key={item.id}
353
+ ref={(el) => {
354
+ buttonRefs.current[index] = el;
355
+ }}
335
356
  type="button"
336
357
  role="radio"
337
358
  aria-checked={isSelected}
@@ -487,14 +508,26 @@ function SearchModeToggle({
487
508
  { value: "text", label: "Text" },
488
509
  ];
489
510
 
511
+ // Selection follows focus, matching ActivityRail / ViewerModeToggle.
512
+ const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
513
+ const selectMode = useCallback(
514
+ (next: SearchMode) => {
515
+ onChange(next);
516
+ buttonRefs.current[next === "name" ? 0 : 1]?.focus();
517
+ },
518
+ [onChange],
519
+ );
490
520
  const handleKeyDown = useCallback(
491
521
  (e: KeyboardEvent<HTMLButtonElement>) => {
492
- if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
522
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
493
523
  e.preventDefault();
494
- onChange(value === "name" ? "text" : "name");
524
+ selectMode("text");
525
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
526
+ e.preventDefault();
527
+ selectMode("name");
495
528
  }
496
529
  },
497
- [value, onChange],
530
+ [selectMode],
498
531
  );
499
532
 
500
533
  return (
@@ -504,11 +537,14 @@ function SearchModeToggle({
504
537
  aria-label="Search mode"
505
538
  className="inline-flex rounded-md bg-muted p-0.5"
506
539
  >
507
- {options.map((option) => {
540
+ {options.map((option, index) => {
508
541
  const isSelected = value === option.value;
509
542
  return (
510
543
  <button
511
544
  key={option.value}
545
+ ref={(el) => {
546
+ buttonRefs.current[index] = el;
547
+ }}
512
548
  type="button"
513
549
  role="radio"
514
550
  aria-checked={isSelected}
@@ -579,6 +615,10 @@ function EditorArea({
579
615
  readonly onCollapse: () => void;
580
616
  }) {
581
617
  const viewerRef = useRef<FileViewerHandle>(null);
618
+ // Instance-scoped ids tie each tab to the single editor body (tabpanel).
619
+ const idBase = useId();
620
+ const panelId = `${idBase}editor-panel`;
621
+ const tabIdPrefix = `${idBase}editor-tab-`;
582
622
  const activeKey = selectedFile
583
623
  ? editorKey(selectedFile.entryId, selectedFile.path)
584
624
  : null;
@@ -616,51 +656,69 @@ function EditorArea({
616
656
  onActivate={onActivateEditor}
617
657
  onPin={onPinEditor}
618
658
  onClose={onCloseEditor}
659
+ panelId={panelId}
660
+ tabIdPrefix={tabIdPrefix}
619
661
  className="min-w-0 flex-1 border-b-0"
620
662
  />
621
663
  )}
622
664
  </div>
623
- {activeVirtualDocument ? (
624
- // A virtual document owns its body wholesale: no breadcrumbs (there is
625
- // no filesystem location) and no FileViewer (there is no reader-backed
626
- // file). Keyed like files so switching documents resets cleanly.
627
- // Vertical-only scrolling: a lone overflow-y-auto would compute
628
- // overflow-x to auto too, giving the pane a horizontal scrollbar the
629
- // moment any child refuses to shrink — documents must reflow instead.
630
- <div
631
- key={activeKey ?? undefined}
632
- className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
633
- >
634
- {activeVirtualDocument.content}
635
- </div>
636
- ) : selectedFile ? (
637
- <>
638
- <Breadcrumbs
639
- entryName={activeEntryName}
640
- path={selectedFile.path}
641
- onRefresh={reader ? () => viewerRef.current?.refresh() : undefined}
642
- />
643
- {/* Per-file remount (key) resets scroll and view-mode state cleanly.
644
- The tab strip + breadcrumbs own the file identity, close, and
645
- refresh, so the viewer is chrome-less; Escape still collapses. */}
646
- <FileViewer
647
- ref={viewerRef}
665
+ {/* The single editor body — the one tabpanel the tab strip controls (the
666
+ content swaps; the panel is stable). role/labelledby apply only with an
667
+ active tab (the empty state has no tab to label). Carries the same
668
+ flex/min-w-0 chain as the branches so the DD-20 reflow is preserved:
669
+ the wrapper is just a labelled passthrough, not a new layout context. */}
670
+ <div
671
+ className="flex min-h-0 min-w-0 flex-1 flex-col"
672
+ {...(activeKey
673
+ ? {
674
+ role: "tabpanel",
675
+ id: panelId,
676
+ "aria-labelledby": editorTabDomId(tabIdPrefix, activeKey),
677
+ }
678
+ : {})}
679
+ >
680
+ {activeVirtualDocument ? (
681
+ // A virtual document owns its body wholesale: no breadcrumbs (there is
682
+ // no filesystem location) and no FileViewer (there is no reader-backed
683
+ // file). Keyed like files so switching documents resets cleanly.
684
+ // Vertical-only scrolling: a lone overflow-y-auto would compute
685
+ // overflow-x to auto too, giving the pane a horizontal scrollbar the
686
+ // moment any child refuses to shrink documents must reflow instead.
687
+ <div
648
688
  key={activeKey ?? undefined}
649
- selectedFile={selectedFile}
650
- entries={entries}
651
- reader={reader}
652
- change={change}
653
- reveal={reveal}
654
- onClose={onCollapse}
655
- showHeader={false}
656
- className="min-h-0 flex-1"
657
- />
658
- </>
659
- ) : (
660
- <div className="flex flex-1 items-center justify-center p-8 text-center text-xs text-muted-foreground">
661
- Select a file to view its contents.
662
- </div>
663
- )}
689
+ className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden"
690
+ >
691
+ {activeVirtualDocument.content}
692
+ </div>
693
+ ) : selectedFile ? (
694
+ <>
695
+ <Breadcrumbs
696
+ entryName={activeEntryName}
697
+ path={selectedFile.path}
698
+ onRefresh={reader ? () => viewerRef.current?.refresh() : undefined}
699
+ />
700
+ {/* Per-file remount (key) resets scroll and view-mode state cleanly.
701
+ The tab strip + breadcrumbs own the file identity, close, and
702
+ refresh, so the viewer is chrome-less; Escape still collapses. */}
703
+ <FileViewer
704
+ ref={viewerRef}
705
+ key={activeKey ?? undefined}
706
+ selectedFile={selectedFile}
707
+ entries={entries}
708
+ reader={reader}
709
+ change={change}
710
+ reveal={reveal}
711
+ onClose={onCollapse}
712
+ showHeader={false}
713
+ className="min-h-0 flex-1"
714
+ />
715
+ </>
716
+ ) : (
717
+ <div className="flex flex-1 items-center justify-center p-8 text-center text-xs text-muted-foreground">
718
+ Select a file to view its contents.
719
+ </div>
720
+ )}
721
+ </div>
664
722
  </div>
665
723
  );
666
724
  }
@@ -67,9 +67,11 @@ describe("EditorTabs", () => {
67
67
  expect(onPin).toHaveBeenCalledWith("e1", "src/b.ts");
68
68
  });
69
69
 
70
- it("closes a tab via its close button", () => {
70
+ it("closes a tab via its close affordance (presentational, aria-hidden)", () => {
71
71
  const { onClose } = renderTabs();
72
- fireEvent.click(screen.getByRole("button", { name: "Close a.ts" }));
72
+ // The close "X" is a presentational span (not a nested button) to satisfy
73
+ // WCAG 4.1.2; it is addressable by its title.
74
+ fireEvent.click(screen.getByTitle("Close a.ts"));
73
75
  expect(onClose).toHaveBeenCalledWith("e1", "src/a.ts");
74
76
  });
75
77
 
@@ -149,4 +149,56 @@ describe("ExplorerTree", () => {
149
149
  screen.queryByRole("button", { name: /Remove .* from workspace/ }),
150
150
  ).toBeNull();
151
151
  });
152
+
153
+ it("exposes tree depth via aria-level and keeps aria-expanded on the treeitem", async () => {
154
+ render(
155
+ <ExplorerTree
156
+ entries={[entry("x9")]}
157
+ lister={lister(["src/nested.ts"])}
158
+ selectedFile={null}
159
+ onOpenFile={vi.fn()}
160
+ onActivateFile={vi.fn()}
161
+ />,
162
+ );
163
+ const folder = await screen.findByText("src");
164
+ const folderItem = folder.closest('[role="treeitem"]')!;
165
+ // Top-level treeitem is level 1; expandability is announced on the treeitem.
166
+ expect(folderItem.getAttribute("aria-level")).toBe("1");
167
+ expect(folderItem.getAttribute("aria-expanded")).toBe("false");
168
+
169
+ fireEvent.click(folder);
170
+ const nested = await screen.findByText("nested.ts");
171
+ expect(nested.closest('[role="treeitem"]')!.getAttribute("aria-level")).toBe(
172
+ "2",
173
+ );
174
+ });
175
+
176
+ it("shows a substrate-agnostic truncation banner from a notice entry (DD-11)", async () => {
177
+ // A lister that signals an incomplete listing via a `notice` entry (as the
178
+ // desktop and GitHub listers do). The advisory is stripped from the tree and
179
+ // surfaced as a banner instead.
180
+ const truncatedLister: WorkspaceFileLister = vi.fn(async () => [
181
+ { path: "a.ts", isDirectory: false },
182
+ {
183
+ path: "... (listing truncated)",
184
+ isDirectory: false,
185
+ notice: true as const,
186
+ },
187
+ ]);
188
+ render(
189
+ <ExplorerTree
190
+ entries={[entry("x8")]}
191
+ lister={truncatedLister}
192
+ selectedFile={null}
193
+ onOpenFile={vi.fn()}
194
+ onActivateFile={vi.fn()}
195
+ />,
196
+ );
197
+ const banner = await screen.findByText(/too many files to load in full/i);
198
+ expect(banner).toBeTruthy();
199
+ // Wording must read correctly for a local folder, not just a git repo.
200
+ expect(banner.textContent).not.toMatch(/repository/i);
201
+ // The notice advisory is never an openable tree leaf.
202
+ expect(screen.queryByText("... (listing truncated)")).toBeNull();
203
+ });
152
204
  });
@@ -326,6 +326,24 @@ describe("FileViewer — diff mode", () => {
326
326
  expect(screen.getByRole("radio", { name: "Diff" }).getAttribute("aria-checked")).toBe("true"),
327
327
  );
328
328
  });
329
+
330
+ it("moves focus with selection when roving Diff|File", async () => {
331
+ render(
332
+ <FileViewer
333
+ selectedFile={{ entryId: "e1", path: "src/a.ts" }}
334
+ entries={ENTRIES}
335
+ reader={readerFor(text("live content"))}
336
+ change={wholeFileChange("src/a.ts")}
337
+ />,
338
+ );
339
+
340
+ const diffRadio = screen.getByRole("radio", { name: "Diff" });
341
+ diffRadio.focus();
342
+ fireEvent.keyDown(diffRadio, { key: "ArrowRight" });
343
+ await waitFor(() =>
344
+ expect(document.activeElement).toBe(screen.getByRole("radio", { name: "File" })),
345
+ );
346
+ });
329
347
  });
330
348
 
331
349
  // ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest";
2
- import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
2
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3
3
  import { WorkspaceContentSearch } from "../WorkspaceContentSearch";
4
4
  import type { WorkspaceEntry } from "../useWorkspaceEntries";
5
5
  import type {
@@ -69,7 +69,10 @@ describe("WorkspaceContentSearch", () => {
69
69
  <WorkspaceContentSearch entries={[makeEntry()]} searcher={searcher} onOpenFile={vi.fn()} />,
70
70
  );
71
71
  typeQuery("a");
72
- expect(screen.getByText(/type at least 2 characters/i)).toBeTruthy();
72
+ // Assert the visible message, not the parallel sr-only live-region copy.
73
+ expect(
74
+ screen.getByText(/type at least 2 characters/i, { ignore: ".sr-only" }),
75
+ ).toBeTruthy();
73
76
  expect(searcher).not.toHaveBeenCalled();
74
77
  });
75
78
 
@@ -96,7 +99,7 @@ describe("WorkspaceContentSearch", () => {
96
99
  // Clicking a line hit opens via the shared onOpenFile seam (the same path a
97
100
  // tree/filename click uses — this is what makes a changed file honor the
98
101
  // FileViewer diff-default; see FileViewer diff-mode suite).
99
- fireEvent.click(within(options[0]).getByRole("button"));
102
+ fireEvent.click(options[0]);
100
103
  // The hit's line rides along so the viewer jumps to it (DR-1/DR-2).
101
104
  expect(onOpenFile).toHaveBeenCalledWith(entry.id, "src/app.ts", { line: 3 });
102
105
  });
@@ -138,7 +141,9 @@ describe("WorkspaceContentSearch", () => {
138
141
  />,
139
142
  );
140
143
  typeQuery("zzz");
141
- expect(await screen.findByText(/no files containing/i)).toBeTruthy();
144
+ expect(
145
+ await screen.findByText(/no files containing/i, { ignore: ".sr-only" }),
146
+ ).toBeTruthy();
142
147
  });
143
148
 
144
149
  it("moves virtual focus with ArrowDown and opens on Enter", async () => {
@@ -178,7 +183,35 @@ describe("WorkspaceContentSearch", () => {
178
183
  typeQuery("foo");
179
184
  await screen.findAllByRole("option");
180
185
  expect(screen.getAllByRole("option")).toHaveLength(200);
181
- expect(screen.getByText(/showing the first 200 of 250 matches/i)).toBeTruthy();
186
+ expect(
187
+ screen.getByText(/showing the first 200 of 250 matches/i, {
188
+ ignore: ".sr-only",
189
+ }),
190
+ ).toBeTruthy();
191
+ });
192
+
193
+ it("announces result status in a polite live region", async () => {
194
+ // Content matching is backend-delegated, so the searcher must vary by query.
195
+ const searcher: WorkspaceContentSearcher = vi.fn(async (_e, query) =>
196
+ query === "foo" ? ok([match("a.ts", 1, "foo")]) : ok([]),
197
+ );
198
+ render(
199
+ <WorkspaceContentSearch
200
+ entries={[makeEntry()]}
201
+ searcher={searcher}
202
+ onOpenFile={vi.fn()}
203
+ />,
204
+ );
205
+ const status = screen.getByRole("status");
206
+ expect(status.getAttribute("aria-live")).toBe("polite");
207
+
208
+ typeQuery("foo");
209
+ await screen.findByRole("option");
210
+ expect(status.textContent).toMatch(/match/i);
211
+
212
+ typeQuery("zzz");
213
+ await screen.findByText(/no files containing/i, { ignore: ".sr-only" });
214
+ expect(status.textContent).toMatch(/no files containing/i);
182
215
  });
183
216
 
184
217
  it("renders a truncation notice when a search was capped", async () => {
@@ -146,6 +146,25 @@ describe("WorkspaceEditor initialPanel", () => {
146
146
  expect(screen.getByText("new-repo")).toBeTruthy();
147
147
  });
148
148
 
149
+ it("labels the manual git inputs for assistive tech (no OAuth connection)", () => {
150
+ // With no gitHubConnection, the panel falls back to manual URL/branch entry.
151
+ render(
152
+ <WorkspaceEditor
153
+ workspace={createMockWorkspace()}
154
+ enableGitHub
155
+ enableLocal={false}
156
+ initialPanel="github"
157
+ />,
158
+ );
159
+ // Accessible names come from aria-label, not the disappearing placeholder.
160
+ expect(
161
+ screen.getByRole("textbox", { name: "Git repository URL" }),
162
+ ).toBeTruthy();
163
+ expect(
164
+ screen.getByRole("textbox", { name: "Branch (optional)" }),
165
+ ).toBeTruthy();
166
+ });
167
+
149
168
  it("shows loading state when GitHub is checking connection with initialPanel='github'", () => {
150
169
  const connection = createMockGitHubConnection({ isLoading: true });
151
170
 
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
2
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3
3
  import { WorkspaceFileSearch } from "../WorkspaceFileSearch";
4
4
  import { __clearWorkspaceListingCache } from "../workspaceListingCache";
5
5
  import type { WorkspaceEntry } from "../useWorkspaceEntries";
@@ -74,7 +74,7 @@ describe("WorkspaceFileSearch", () => {
74
74
 
75
75
  typeQuery("button");
76
76
  const option = await screen.findByRole("option");
77
- fireEvent.click(within(option).getByRole("button"));
77
+ fireEvent.click(option);
78
78
 
79
79
  expect(onOpenFile).toHaveBeenCalledWith(entry.id, "src/button.tsx");
80
80
  // Single entry → no group header.
@@ -126,9 +126,7 @@ describe("WorkspaceFileSearch", () => {
126
126
  );
127
127
  typeQuery("button");
128
128
  const option = await screen.findByRole("option");
129
- expect(within(option).getByRole("button").getAttribute("aria-current")).toBe(
130
- "true",
131
- );
129
+ expect(option.getAttribute("aria-current")).toBe("true");
132
130
  });
133
131
 
134
132
  it("shows a no-match state", async () => {
@@ -140,7 +138,10 @@ describe("WorkspaceFileSearch", () => {
140
138
  />,
141
139
  );
142
140
  typeQuery("zzzzz");
143
- expect(await screen.findByText(/no files matching/i)).toBeTruthy();
141
+ // Assert the visible message, not the parallel sr-only live-region copy.
142
+ expect(
143
+ await screen.findByText(/no files matching/i, { ignore: ".sr-only" }),
144
+ ).toBeTruthy();
144
145
  });
145
146
 
146
147
  it("renders a truncation notice from a notice entry", async () => {
@@ -184,6 +185,26 @@ describe("WorkspaceFileSearch", () => {
184
185
  expect(onOpenFile.mock.calls[0][0]).toBe(entry.id);
185
186
  });
186
187
 
188
+ it("announces result status in a polite live region", async () => {
189
+ render(
190
+ <WorkspaceFileSearch
191
+ entries={[makeEntry()]}
192
+ lister={vi.fn(async () => FILES)}
193
+ onOpenFile={vi.fn()}
194
+ />,
195
+ );
196
+ const status = screen.getByRole("status");
197
+ expect(status.getAttribute("aria-live")).toBe("polite");
198
+
199
+ typeQuery("button");
200
+ await screen.findByRole("option");
201
+ expect(status.textContent).toMatch(/matching file/i);
202
+
203
+ typeQuery("zzzzz");
204
+ await screen.findByText(/no files matching/i, { ignore: ".sr-only" });
205
+ expect(status.textContent).toMatch(/no files matching/i);
206
+ });
207
+
187
208
  it("caps rendered results and reports the total", async () => {
188
209
  const entry = makeEntry();
189
210
  const many: WorkspaceFileEntry[] = Array.from({ length: 250 }, (_, i) => ({
@@ -152,6 +152,23 @@ describe("WorkspaceSurface", () => {
152
152
  expect(tab.getAttribute("aria-selected")).toBe("true");
153
153
  });
154
154
 
155
+ it("associates the active tab with the single editor tabpanel", () => {
156
+ renderSurface();
157
+ const tab = screen.getByRole("tab", { name: /main\.go/ });
158
+ const panel = screen.getByRole("tabpanel");
159
+ const panelId = panel.getAttribute("id");
160
+ expect(panelId).toBeTruthy();
161
+ expect(tab.getAttribute("id")).toBeTruthy();
162
+ // Tab -> panel and panel -> active tab, both directions wired.
163
+ expect(tab.getAttribute("aria-controls")).toBe(panelId);
164
+ expect(panel.getAttribute("aria-labelledby")).toBe(tab.getAttribute("id"));
165
+ });
166
+
167
+ it("renders no tabpanel when no editor is open (nothing to label)", () => {
168
+ renderSurface({ selectedFile: null, editors: [] });
169
+ expect(screen.queryByRole("tabpanel")).toBeNull();
170
+ });
171
+
155
172
  it("collapses back to chat when the toolbar control is clicked", () => {
156
173
  const { onCollapse } = renderSurface();
157
174
  fireEvent.click(screen.getByRole("button", { name: "Back to chat" }));
@@ -242,6 +259,59 @@ describe("WorkspaceSurface extraViews", () => {
242
259
  fireEvent.keyDown(search, { key: "ArrowDown" });
243
260
  expect(screen.getByTestId("config-probe")).toBeTruthy();
244
261
  });
262
+
263
+ it("reaches an injected view beyond the second by keyboard (focus follows selection)", () => {
264
+ // Regression guard: the old handler moved selection but never DOM focus, so
265
+ // focus stayed pinned on the entered button and every view past the second
266
+ // (Config, Changes, …) was keyboard-unreachable. Each arrow must advance
267
+ // focus so the next arrow computes from the new position.
268
+ renderSurface({ extraViews: [configView, changesView] });
269
+ const explorer = screen.getByRole("radio", { name: "Explorer" });
270
+ explorer.focus();
271
+ expect(document.activeElement).toBe(explorer);
272
+
273
+ // Step 1: Explorer -> Search, focus moves with the selection.
274
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
275
+ const search = screen.getByRole("radio", { name: "Search" });
276
+ expect(document.activeElement).toBe(search);
277
+ expect(search.getAttribute("aria-checked")).toBe("true");
278
+
279
+ // Step 2: Search -> Config. The step the old code could not make.
280
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
281
+ const config = screen.getByRole("radio", { name: "Config" });
282
+ expect(document.activeElement).toBe(config);
283
+ expect(config.getAttribute("aria-checked")).toBe("true");
284
+ expect(screen.getByTestId("config-probe")).toBeTruthy();
285
+ });
286
+
287
+ it("jumps to the last and first rail views with End and Home", () => {
288
+ renderSurface({ extraViews: [configView, changesView] });
289
+ const explorer = screen.getByRole("radio", { name: "Explorer" });
290
+ explorer.focus();
291
+
292
+ fireEvent.keyDown(document.activeElement!, { key: "End" });
293
+ const changes = screen.getByRole("radio", { name: "Changes (3)" });
294
+ expect(document.activeElement).toBe(changes);
295
+ expect(changes.getAttribute("aria-checked")).toBe("true");
296
+
297
+ fireEvent.keyDown(document.activeElement!, { key: "Home" });
298
+ const explorerAgain = screen.getByRole("radio", { name: "Explorer" });
299
+ expect(document.activeElement).toBe(explorerAgain);
300
+ expect(explorerAgain.getAttribute("aria-checked")).toBe("true");
301
+ });
302
+
303
+ it("moves focus with selection in the Name|Text search-mode toggle", () => {
304
+ renderSurface({ searcher });
305
+ fireEvent.click(screen.getByRole("radio", { name: "Search" }));
306
+ const toggle = screen.getByRole("radiogroup", { name: "Search mode" });
307
+ const name = within(toggle).getByRole("radio", { name: "Name" });
308
+ name.focus();
309
+
310
+ fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
311
+ const text = within(toggle).getByRole("radio", { name: "Text" });
312
+ expect(document.activeElement).toBe(text);
313
+ expect(text.getAttribute("aria-checked")).toBe("true");
314
+ });
245
315
  });
246
316
 
247
317
  // ---------------------------------------------------------------------------