@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
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useState, useCallback, useEffect, type KeyboardEvent } from "react";
4
+ import { cn } from "@stigmer/theme";
4
5
  import type { UseWorkspaceEntriesReturn } from "./useWorkspaceEntries.js";
5
6
  import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection.js";
6
7
  import { GitHubRepoPicker } from "../github/GitHubRepoPicker.js";
@@ -136,11 +137,11 @@ export function WorkspaceEditor({
136
137
 
137
138
  if (activePanel === "github" && enableGitHub) {
138
139
  return (
139
- <div className={["space-y-2", className].filter(Boolean).join(" ")}>
140
+ <div className={cn("space-y-2", className)}>
140
141
  <button
141
142
  type="button"
142
143
  onClick={goBack}
143
- className="inline-flex items-center gap-1 text-[0.65rem] text-muted-foreground hover:text-foreground transition-colors"
144
+ className="inline-flex items-center gap-1 rounded-sm text-[0.65rem] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
144
145
  >
145
146
  <ChevronLeftIcon />
146
147
  Back
@@ -180,7 +181,7 @@ export function WorkspaceEditor({
180
181
  // ---------------------------------------------------------------------------
181
182
 
182
183
  return (
183
- <div className={["space-y-2", className].filter(Boolean).join(" ")}>
184
+ <div className={cn("space-y-2", className)}>
184
185
  {/* Entry list */}
185
186
  {workspace.entries.length > 0 && (
186
187
  <div className="relative">
@@ -202,10 +203,10 @@ export function WorkspaceEditor({
202
203
  </span>
203
204
  )}
204
205
  <span
205
- className={[
206
+ className={cn(
206
207
  "min-w-0 flex-1 truncate text-foreground",
207
- entry.type === "local" ? "[direction:rtl] text-left" : "",
208
- ].join(" ")}
208
+ entry.type === "local" && "[direction:rtl] text-left",
209
+ )}
209
210
  title={entry.name}
210
211
  >
211
212
  <bdi>{entry.name}</bdi>
@@ -214,7 +215,7 @@ export function WorkspaceEditor({
214
215
  type="button"
215
216
  onClick={() => workspace.remove(entry.id)}
216
217
  disabled={disabled}
217
- className="shrink-0 text-muted-foreground hover:text-destructive disabled:pointer-events-none"
218
+ className="shrink-0 rounded-sm text-muted-foreground transition-colors hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
218
219
  aria-label={`Remove ${entry.name}`}
219
220
  >
220
221
  <XIcon />
@@ -237,7 +238,7 @@ export function WorkspaceEditor({
237
238
  if (path) workspace.addLocalPath(path);
238
239
  }}
239
240
  disabled={disabled}
240
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-40"
241
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
241
242
  >
242
243
  <FolderIcon />
243
244
  <span className="flex-1 text-left">Browse Folder</span>
@@ -248,7 +249,7 @@ export function WorkspaceEditor({
248
249
  type="button"
249
250
  onClick={() => setActivePanel("github")}
250
251
  disabled={disabled}
251
- className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover disabled:pointer-events-none disabled:opacity-40"
252
+ className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-xs text-foreground transition-colors hover:bg-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
252
253
  >
253
254
  <GitHubIcon />
254
255
  <span className="flex-1 text-left">Connect GitHub</span>
@@ -307,14 +308,14 @@ function GitHubPanel({
307
308
  <button
308
309
  type="button"
309
310
  onClick={() => connection.connect(redirectUri, { popup: true })}
310
- className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
311
+ className="rounded-md px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
311
312
  >
312
313
  Try again
313
314
  </button>
314
315
  <button
315
316
  type="button"
316
317
  onClick={() => connection.connect(redirectUri)}
317
- className="inline-flex items-center gap-2 rounded-md bg-foreground px-3 py-1.5 text-xs text-background hover:bg-foreground-hover transition-colors"
318
+ className="inline-flex items-center gap-2 rounded-md bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
318
319
  >
319
320
  <GitHubIcon />
320
321
  <span>Continue with redirect</span>
@@ -325,7 +326,7 @@ function GitHubPanel({
325
326
  <button
326
327
  type="button"
327
328
  onClick={() => connection.connect(redirectUri, { popup: true })}
328
- className="inline-flex items-center gap-2 rounded-md bg-foreground px-3 py-1.5 text-xs text-background hover:bg-foreground-hover transition-colors"
329
+ className="inline-flex items-center gap-2 rounded-md bg-foreground px-3 py-1.5 text-xs text-background transition-colors hover:bg-foreground-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
329
330
  >
330
331
  <GitHubIcon />
331
332
  <span>Connect GitHub</span>
@@ -353,7 +354,7 @@ function GitHubPanel({
353
354
  <button
354
355
  type="button"
355
356
  onClick={connection.disconnect}
356
- className="text-[0.6rem] text-muted-foreground hover:text-destructive transition-colors"
357
+ className="rounded-sm text-[0.6rem] text-muted-foreground transition-colors hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
357
358
  >
358
359
  Disconnect
359
360
  </button>
@@ -392,6 +393,7 @@ function ManualGitPanel({
392
393
  <div className="space-y-2">
393
394
  <input
394
395
  type="url"
396
+ aria-label="Git repository URL"
395
397
  placeholder="https://github.com/org/repo.git"
396
398
  value={url}
397
399
  onChange={(e) => onUrlChange(e.target.value)}
@@ -401,6 +403,7 @@ function ManualGitPanel({
401
403
  />
402
404
  <input
403
405
  type="text"
406
+ aria-label="Branch (optional)"
404
407
  placeholder="Branch (optional)"
405
408
  value={branch}
406
409
  onChange={(e) => onBranchChange(e.target.value)}
@@ -411,7 +414,7 @@ function ManualGitPanel({
411
414
  <button
412
415
  type="button"
413
416
  onClick={onCancel}
414
- className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
417
+ className="rounded-md px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
415
418
  >
416
419
  Cancel
417
420
  </button>
@@ -419,7 +422,7 @@ function ManualGitPanel({
419
422
  type="button"
420
423
  onClick={onAdd}
421
424
  disabled={!url.trim()}
422
- className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground hover:bg-primary-hover transition-colors disabled:opacity-40"
425
+ className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
423
426
  >
424
427
  Add
425
428
  </button>
@@ -453,7 +456,7 @@ function GitHubConnectingState({ onCancel }: { onCancel: () => void }) {
453
456
  <button
454
457
  type="button"
455
458
  onClick={onCancel}
456
- className="text-[0.65rem] text-muted-foreground hover:text-foreground transition-colors"
459
+ className="rounded-sm text-[0.65rem] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
457
460
  >
458
461
  Cancel
459
462
  </button>
@@ -132,8 +132,27 @@ export function WorkspaceFileSearch({
132
132
  const trimmed = query.trim();
133
133
  const showGroupHeaders = groups.length > 1;
134
134
 
135
+ // A concise, screen-reader-only summary of the current result state. It lives
136
+ // in a persistent polite live region (below) so status changes are announced
137
+ // even as the visible states — which live in separate, unmounting branches —
138
+ // swap. Deliberately terse: never the result list itself, only its shape.
139
+ const statusMessage = isUnsupported
140
+ ? ""
141
+ : trimmed.length === 0
142
+ ? ""
143
+ : isLoading && totalMatches === 0 && groups.length === 0
144
+ ? "Searching files…"
145
+ : groups.length === 0
146
+ ? `No files matching ${trimmed}.`
147
+ : totalMatches > flatResults.length
148
+ ? `Showing the first ${flatResults.length} of ${totalMatches} matching files.`
149
+ : `${flatResults.length} matching ${flatResults.length === 1 ? "file" : "files"}.`;
150
+
135
151
  return (
136
152
  <div className={cn("flex h-full flex-col", className)}>
153
+ <div role="status" aria-live="polite" className="sr-only">
154
+ {statusMessage}
155
+ </div>
137
156
  <div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
138
157
  <SearchIcon />
139
158
  <input
@@ -264,7 +283,7 @@ function renderRows({
264
283
  if (group.truncated) {
265
284
  rows.push(
266
285
  <GroupNotice key={`t:${group.entry.id}`}>
267
- Partial listing — this repository has too many files to search in full.
286
+ Partial listing — too many files to search in full.
268
287
  </GroupNotice>,
269
288
  );
270
289
  }
@@ -299,39 +318,43 @@ function ResultRow({
299
318
  const basename = match.path.slice(lastSlash + 1);
300
319
  const dir = lastSlash >= 0 ? match.path.slice(0, lastSlash) : "";
301
320
 
321
+ // The `role="option"` IS the interactive leaf of the listbox (keyboard
322
+ // activation flows through the combobox input's Enter handler +
323
+ // aria-activedescendant). It must not nest another interactive control — a
324
+ // button inside would violate WCAG 4.1.2 (axe `nested-interactive`). So the
325
+ // click handler and row styling live on the option itself.
302
326
  return (
303
- <li id={id} role="option" aria-selected={isFocused}>
304
- <button
305
- type="button"
306
- onClick={onOpen}
307
- tabIndex={-1}
308
- aria-current={isOpen ? "true" : undefined}
309
- className={cn(
310
- "flex w-full items-baseline gap-1.5 px-3 py-1 text-left text-xs transition-colors",
311
- isFocused && "bg-muted",
312
- isOpen && "bg-muted font-medium text-foreground",
313
- !isFocused && !isOpen && "hover:bg-muted",
314
- )}
315
- >
316
- <span className="truncate text-foreground">
327
+ <li
328
+ id={id}
329
+ role="option"
330
+ aria-selected={isFocused}
331
+ aria-current={isOpen ? "true" : undefined}
332
+ onClick={onOpen}
333
+ className={cn(
334
+ "flex cursor-pointer items-baseline gap-1.5 px-3 py-1 text-xs transition-colors",
335
+ isFocused && "bg-muted",
336
+ isOpen && "bg-muted font-medium text-foreground",
337
+ !isFocused && !isOpen && "hover:bg-muted",
338
+ )}
339
+ >
340
+ <span className="truncate text-foreground">
341
+ <HighlightedPath
342
+ text={basename}
343
+ offset={lastSlash + 1}
344
+ matchStart={match.matchStart}
345
+ matchEnd={match.matchEnd}
346
+ />
347
+ </span>
348
+ {dir && (
349
+ <span className="truncate text-[0.65rem] text-muted-foreground">
317
350
  <HighlightedPath
318
- text={basename}
319
- offset={lastSlash + 1}
351
+ text={dir}
352
+ offset={0}
320
353
  matchStart={match.matchStart}
321
354
  matchEnd={match.matchEnd}
322
355
  />
323
356
  </span>
324
- {dir && (
325
- <span className="truncate text-[0.65rem] text-muted-foreground">
326
- <HighlightedPath
327
- text={dir}
328
- offset={0}
329
- matchStart={match.matchStart}
330
- matchEnd={match.matchEnd}
331
- />
332
- </span>
333
- )}
334
- </button>
357
+ )}
335
358
  </li>
336
359
  );
337
360
  }
@@ -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 () => {