@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.
- package/composer/SessionComposer.d.ts +6 -5
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +1 -1
- package/composer/SessionComposer.js.map +1 -1
- package/execution/ArtifactPreviewModal.d.ts +5 -5
- package/execution/ArtifactPreviewModal.js +4 -4
- package/execution/ArtifactPreviewModal.js.map +1 -1
- package/execution/FileChangeProgressBar.d.ts +47 -0
- package/execution/FileChangeProgressBar.d.ts.map +1 -0
- package/execution/FileChangeProgressBar.js +49 -0
- package/execution/FileChangeProgressBar.js.map +1 -0
- package/execution/FileChangesView.d.ts.map +1 -1
- package/execution/FileChangesView.js +19 -4
- package/execution/FileChangesView.js.map +1 -1
- package/execution/FileReviewAtoms.d.ts +27 -0
- package/execution/FileReviewAtoms.d.ts.map +1 -0
- package/execution/FileReviewAtoms.js +64 -0
- package/execution/FileReviewAtoms.js.map +1 -0
- package/execution/FileReviewCard.d.ts +2 -2
- package/execution/FileReviewCard.d.ts.map +1 -1
- package/execution/FileReviewCard.js +32 -62
- package/execution/FileReviewCard.js.map +1 -1
- package/execution/MessageEntry.d.ts +0 -9
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +1 -11
- package/execution/MessageEntry.js.map +1 -1
- package/execution/MessageThread.d.ts +3 -4
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +25 -11
- package/execution/MessageThread.js.map +1 -1
- package/execution/PlanArtifactCard.d.ts +16 -10
- package/execution/PlanArtifactCard.d.ts.map +1 -1
- package/execution/PlanArtifactCard.js +31 -7
- package/execution/PlanArtifactCard.js.map +1 -1
- package/execution/PlanCompletionCard.d.ts +4 -4
- package/execution/PlanCompletionCard.d.ts.map +1 -1
- package/execution/PlanCompletionCard.js +4 -4
- package/execution/PlanCompletionCard.js.map +1 -1
- package/execution/PlanStreamingCard.d.ts +7 -2
- package/execution/PlanStreamingCard.d.ts.map +1 -1
- package/execution/PlanStreamingCard.js +11 -6
- package/execution/PlanStreamingCard.js.map +1 -1
- package/execution/index.d.ts +4 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +2 -0
- package/execution/index.js.map +1 -1
- package/execution/use-build-from-plan-hotkey.d.ts +1 -1
- package/execution/use-build-from-plan-hotkey.js +1 -1
- package/execution/useArtifactCopy.d.ts +51 -0
- package/execution/useArtifactCopy.d.ts.map +1 -0
- package/execution/useArtifactCopy.js +75 -0
- package/execution/useArtifactCopy.js.map +1 -0
- package/execution/useArtifactDownload.d.ts.map +1 -1
- package/execution/useArtifactDownload.js +17 -5
- package/execution/useArtifactDownload.js.map +1 -1
- package/execution/useCreateAgentExecution.d.ts +5 -3
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js.map +1 -1
- package/index.d.ts +3 -3
- package/index.d.ts.map +1 -1
- package/index.js +2 -2
- package/index.js.map +1 -1
- package/internal/file-tree/FileTreeNode.d.ts.map +1 -1
- package/internal/file-tree/FileTreeNode.js +2 -2
- package/internal/file-tree/FileTreeNode.js.map +1 -1
- package/library/detect-plan-artifact.d.ts +27 -9
- package/library/detect-plan-artifact.d.ts.map +1 -1
- package/library/detect-plan-artifact.js +26 -6
- package/library/detect-plan-artifact.js.map +1 -1
- package/library/index.d.ts +1 -1
- package/library/index.d.ts.map +1 -1
- package/library/index.js +1 -1
- package/library/index.js.map +1 -1
- package/package.json +4 -4
- package/session/PlanEditor.js +1 -1
- package/session/PlanEditor.js.map +1 -1
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +32 -13
- package/session/SessionViewer.js.map +1 -1
- package/session/plan-document.d.ts +14 -2
- package/session/plan-document.d.ts.map +1 -1
- package/session/plan-document.js +14 -3
- package/session/plan-document.js.map +1 -1
- package/session/usePlanDraft.d.ts +1 -1
- package/session/useSessionConversation.d.ts +9 -2
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +19 -2
- package/session/useSessionConversation.js.map +1 -1
- package/src/composer/SessionComposer.tsx +7 -6
- package/src/execution/ArtifactPreviewModal.tsx +9 -9
- package/src/execution/FileChangeProgressBar.tsx +121 -0
- package/src/execution/FileChangesView.tsx +18 -4
- package/src/execution/FileReviewAtoms.tsx +89 -0
- package/src/execution/FileReviewCard.tsx +57 -90
- package/src/execution/MessageEntry.tsx +0 -62
- package/src/execution/MessageThread.tsx +29 -15
- package/src/execution/PlanArtifactCard.tsx +130 -52
- package/src/execution/PlanCompletionCard.tsx +5 -5
- package/src/execution/PlanStreamingCard.tsx +15 -11
- package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
- package/src/execution/__tests__/FileChangeProgressBar.test.tsx +125 -0
- package/src/execution/__tests__/FileReviewCard.test.tsx +66 -7
- package/src/execution/__tests__/MessageThread.test.tsx +1 -1
- package/src/execution/__tests__/PlanArtifactCard.test.tsx +52 -18
- package/src/execution/__tests__/PlanCompletionCard.test.tsx +2 -2
- package/src/execution/__tests__/PlanStreamingCard.test.tsx +11 -4
- package/src/execution/__tests__/buildThreadItems-plan-completion.test.ts +104 -8
- package/src/execution/__tests__/message-entry.test.tsx +6 -37
- package/src/execution/__tests__/useArtifactCopy.test.tsx +90 -0
- package/src/execution/__tests__/useArtifactDownload.test.tsx +2 -0
- package/src/execution/index.ts +6 -0
- package/src/execution/use-build-from-plan-hotkey.ts +1 -1
- package/src/execution/useArtifactCopy.ts +109 -0
- package/src/execution/useArtifactDownload.ts +17 -5
- package/src/execution/useCreateAgentExecution.ts +5 -3
- package/src/index.ts +6 -0
- package/src/internal/file-tree/FileTreeNode.tsx +13 -2
- package/src/library/__tests__/detect-plan-artifact.test.ts +21 -0
- package/src/library/detect-plan-artifact.ts +32 -10
- package/src/library/index.ts +2 -0
- package/src/session/PlanEditor.tsx +1 -1
- package/src/session/SessionViewer.tsx +50 -18
- package/src/session/__tests__/PlanEditor.test.tsx +4 -4
- package/src/session/__tests__/PlanStreamingDocument.test.tsx +1 -1
- package/src/session/__tests__/SessionViewer-fileReviewDock.test.tsx +57 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +44 -0
- package/src/session/facets/__tests__/ArtifactsTab.test.tsx +7 -7
- package/src/session/plan-document.ts +14 -3
- package/src/session/usePlanDraft.ts +1 -1
- package/src/session/useSessionConversation.ts +32 -4
- package/src/workspace/EditorTabs.tsx +38 -9
- package/src/workspace/ExplorerTree.tsx +10 -4
- package/src/workspace/FileViewer.tsx +27 -6
- package/src/workspace/WorkspaceContentSearch.tsx +44 -20
- package/src/workspace/WorkspaceEditor.tsx +19 -16
- package/src/workspace/WorkspaceFileSearch.tsx +51 -28
- package/src/workspace/WorkspaceSurface.tsx +106 -48
- package/src/workspace/__tests__/EditorTabs.test.tsx +4 -2
- package/src/workspace/__tests__/ExplorerTree.test.tsx +52 -0
- package/src/workspace/__tests__/FileViewer.test.tsx +18 -0
- package/src/workspace/__tests__/WorkspaceContentSearch.test.tsx +38 -5
- package/src/workspace/__tests__/WorkspaceEditor-initialPanel.test.tsx +19 -0
- package/src/workspace/__tests__/WorkspaceFileSearch.test.tsx +27 -6
- package/src/workspace/__tests__/WorkspaceSurface.test.tsx +70 -0
- package/src/workspace/__tests__/a11y/harness.tsx +140 -0
- package/src/workspace/__tests__/a11y/mock-capabilities.ts +138 -0
- package/src/workspace/__tests__/a11y/workspace-editor.a11y.test.tsx +65 -0
- package/src/workspace/__tests__/a11y/workspace-explorer-search.a11y.test.tsx +112 -0
- package/src/workspace/__tests__/a11y/workspace-viewer-tabs.a11y.test.tsx +69 -0
- package/styles.css +1 -1
- package/workspace/EditorTabs.d.ts +21 -3
- package/workspace/EditorTabs.d.ts.map +1 -1
- package/workspace/EditorTabs.js +18 -6
- package/workspace/EditorTabs.js.map +1 -1
- package/workspace/ExplorerTree.js +8 -4
- package/workspace/ExplorerTree.js.map +1 -1
- package/workspace/FileViewer.d.ts.map +1 -1
- package/workspace/FileViewer.js +19 -8
- package/workspace/FileViewer.js.map +1 -1
- package/workspace/WorkspaceContentSearch.d.ts.map +1 -1
- package/workspace/WorkspaceContentSearch.js +23 -2
- package/workspace/WorkspaceContentSearch.js.map +1 -1
- package/workspace/WorkspaceEditor.d.ts.map +1 -1
- package/workspace/WorkspaceEditor.js +8 -10
- package/workspace/WorkspaceEditor.js.map +1 -1
- package/workspace/WorkspaceFileSearch.d.ts.map +1 -1
- package/workspace/WorkspaceFileSearch.js +23 -3
- package/workspace/WorkspaceFileSearch.js.map +1 -1
- package/workspace/WorkspaceSurface.d.ts.map +1 -1
- package/workspace/WorkspaceSurface.js +54 -18
- 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={
|
|
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
|
|
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={
|
|
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"
|
|
208
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 —
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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={
|
|
319
|
-
offset={
|
|
351
|
+
text={dir}
|
|
352
|
+
offset={0}
|
|
320
353
|
matchStart={match.matchStart}
|
|
321
354
|
matchEnd={match.matchEnd}
|
|
322
355
|
/>
|
|
323
356
|
</span>
|
|
324
|
-
|
|
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 {
|
|
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
|
-
//
|
|
306
|
-
|
|
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 === "
|
|
522
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
493
523
|
e.preventDefault();
|
|
494
|
-
|
|
524
|
+
selectMode("text");
|
|
525
|
+
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
selectMode("name");
|
|
495
528
|
}
|
|
496
529
|
},
|
|
497
|
-
[
|
|
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
|
-
{
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
|
70
|
+
it("closes a tab via its close affordance (presentational, aria-hidden)", () => {
|
|
71
71
|
const { onClose } = renderTabs();
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 () => {
|