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