@stigmer/react 3.0.2-dev.20260609093630 → 3.0.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 +23 -5
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +12 -5
- package/composer/SessionComposer.js.map +1 -1
- package/composer/index.d.ts +1 -0
- package/composer/index.d.ts.map +1 -1
- package/composer/index.js +1 -0
- package/composer/index.js.map +1 -1
- package/composer/interaction-mode.d.ts +21 -0
- package/composer/interaction-mode.d.ts.map +1 -0
- package/composer/interaction-mode.js +37 -0
- package/composer/interaction-mode.js.map +1 -0
- package/execution/ArtifactPreviewModal.d.ts +15 -2
- package/execution/ArtifactPreviewModal.d.ts.map +1 -1
- package/execution/ArtifactPreviewModal.js +16 -6
- package/execution/ArtifactPreviewModal.js.map +1 -1
- package/execution/MessageThread.d.ts +17 -2
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +7 -7
- package/execution/MessageThread.js.map +1 -1
- package/execution/PlanArtifactCard.d.ts +13 -5
- package/execution/PlanArtifactCard.d.ts.map +1 -1
- package/execution/PlanArtifactCard.js +14 -34
- package/execution/PlanArtifactCard.js.map +1 -1
- package/execution/useCreateAgentExecution.d.ts.map +1 -1
- package/execution/useCreateAgentExecution.js +2 -6
- package/execution/useCreateAgentExecution.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +3 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +4 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.d.ts.map +1 -1
- package/session/SessionViewer.js +20 -12
- package/session/SessionViewer.js.map +1 -1
- package/session/inspector/ArtifactsTab.d.ts +7 -1
- package/session/inspector/ArtifactsTab.d.ts.map +1 -1
- package/session/inspector/ArtifactsTab.js +3 -2
- package/session/inspector/ArtifactsTab.js.map +1 -1
- package/session/inspector/SessionInspector.d.ts +5 -0
- package/session/inspector/SessionInspector.d.ts.map +1 -1
- package/session/inspector/SessionInspector.js +2 -2
- package/session/inspector/SessionInspector.js.map +1 -1
- package/session/useSessionPageFlow.d.ts +12 -1
- package/session/useSessionPageFlow.d.ts.map +1 -1
- package/session/useSessionPageFlow.js +12 -0
- package/session/useSessionPageFlow.js.map +1 -1
- package/src/composer/SessionComposer.tsx +34 -9
- package/src/composer/__tests__/SessionComposer-contract.test.tsx +42 -2
- package/src/composer/__tests__/interaction-mode.test.ts +44 -0
- package/src/composer/index.ts +5 -0
- package/src/composer/interaction-mode.ts +43 -0
- package/src/execution/ArtifactPreviewModal.tsx +61 -1
- package/src/execution/MessageThread.tsx +35 -1
- package/src/execution/PlanArtifactCard.tsx +45 -85
- package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +85 -0
- package/src/execution/__tests__/PlanArtifactCard.test.tsx +122 -0
- package/src/execution/useCreateAgentExecution.ts +2 -7
- package/src/internal/VirtualizedThread.tsx +7 -1
- package/src/session/SessionViewer.tsx +25 -10
- package/src/session/inspector/ArtifactsTab.tsx +11 -1
- package/src/session/inspector/SessionInspector.tsx +7 -0
- package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +90 -0
- package/src/session/useSessionPageFlow.ts +33 -1
|
@@ -141,6 +141,19 @@ export interface MessageThreadProps {
|
|
|
141
141
|
* compatible — existing consumers see no change.
|
|
142
142
|
*/
|
|
143
143
|
readonly onBuildFromPlan?: () => void;
|
|
144
|
+
/**
|
|
145
|
+
* Organization slug. Required for the plan completion card's
|
|
146
|
+
* "Review plan" action, which opens the shared artifact preview
|
|
147
|
+
* modal (the modal needs `org` for its detection/apply pipeline).
|
|
148
|
+
*/
|
|
149
|
+
readonly org?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Disables the plan completion card's actions (Implement / Review).
|
|
152
|
+
* Defense in depth for the brief windows where a follow-up cannot be
|
|
153
|
+
* sent (e.g. an execution is streaming); the composer also no-ops a
|
|
154
|
+
* disabled submit.
|
|
155
|
+
*/
|
|
156
|
+
readonly planActionsDisabled?: boolean;
|
|
144
157
|
/**
|
|
145
158
|
* Center thread content within a max-width reading column.
|
|
146
159
|
*
|
|
@@ -462,6 +475,8 @@ export function MessageThread({
|
|
|
462
475
|
summarizationEvents,
|
|
463
476
|
virtualized = false,
|
|
464
477
|
onBuildFromPlan,
|
|
478
|
+
org,
|
|
479
|
+
planActionsDisabled,
|
|
465
480
|
centerContent = false,
|
|
466
481
|
}: MessageThreadProps) {
|
|
467
482
|
useRenderTracer("MessageThread", { executions, activeStreamExecution });
|
|
@@ -499,6 +514,8 @@ export function MessageThread({
|
|
|
499
514
|
filePathCtx={filePathCtx}
|
|
500
515
|
sandboxCtx={sandboxCtx}
|
|
501
516
|
onBuildFromPlan={onBuildFromPlan}
|
|
517
|
+
org={org}
|
|
518
|
+
planActionsDisabled={planActionsDisabled}
|
|
502
519
|
centerContent={centerContent}
|
|
503
520
|
/>
|
|
504
521
|
</Suspense>
|
|
@@ -517,6 +534,8 @@ export function MessageThread({
|
|
|
517
534
|
filePathCtx={filePathCtx}
|
|
518
535
|
sandboxCtx={sandboxCtx}
|
|
519
536
|
onBuildFromPlan={onBuildFromPlan}
|
|
537
|
+
org={org}
|
|
538
|
+
planActionsDisabled={planActionsDisabled}
|
|
520
539
|
/>
|
|
521
540
|
);
|
|
522
541
|
}
|
|
@@ -539,6 +558,8 @@ interface NonVirtualizedThreadProps {
|
|
|
539
558
|
readonly filePathCtx: FilePathContextValue;
|
|
540
559
|
readonly sandboxCtx: SandboxContextValue;
|
|
541
560
|
readonly onBuildFromPlan?: () => void;
|
|
561
|
+
readonly org?: string;
|
|
562
|
+
readonly planActionsDisabled?: boolean;
|
|
542
563
|
}
|
|
543
564
|
|
|
544
565
|
function NonVirtualizedThread({
|
|
@@ -551,6 +572,8 @@ function NonVirtualizedThread({
|
|
|
551
572
|
filePathCtx,
|
|
552
573
|
sandboxCtx,
|
|
553
574
|
onBuildFromPlan,
|
|
575
|
+
org,
|
|
576
|
+
planActionsDisabled,
|
|
554
577
|
}: NonVirtualizedThreadProps) {
|
|
555
578
|
const { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest } =
|
|
556
579
|
useAutoScroll();
|
|
@@ -584,6 +607,8 @@ function NonVirtualizedThread({
|
|
|
584
607
|
onApprovalSubmit={onApprovalSubmit}
|
|
585
608
|
submittingApprovalIds={submittingApprovalIds}
|
|
586
609
|
onBuildFromPlan={onBuildFromPlan}
|
|
610
|
+
org={org}
|
|
611
|
+
planActionsDisabled={planActionsDisabled}
|
|
587
612
|
/>
|
|
588
613
|
</ThreadItemWrapper>
|
|
589
614
|
))}
|
|
@@ -618,6 +643,8 @@ export interface ThreadItemRendererProps {
|
|
|
618
643
|
) => void;
|
|
619
644
|
readonly submittingApprovalIds?: ReadonlySet<string>;
|
|
620
645
|
readonly onBuildFromPlan?: () => void;
|
|
646
|
+
readonly org?: string;
|
|
647
|
+
readonly planActionsDisabled?: boolean;
|
|
621
648
|
}
|
|
622
649
|
|
|
623
650
|
/**
|
|
@@ -637,6 +664,8 @@ export function ThreadItemRenderer({
|
|
|
637
664
|
onApprovalSubmit,
|
|
638
665
|
submittingApprovalIds,
|
|
639
666
|
onBuildFromPlan,
|
|
667
|
+
org,
|
|
668
|
+
planActionsDisabled,
|
|
640
669
|
}: ThreadItemRendererProps) {
|
|
641
670
|
switch (item.kind) {
|
|
642
671
|
case "message":
|
|
@@ -694,10 +723,15 @@ export function ThreadItemRenderer({
|
|
|
694
723
|
<PlanArtifactCard
|
|
695
724
|
executionId={item.executionId}
|
|
696
725
|
artifact={item.planArtifact}
|
|
726
|
+
org={org}
|
|
697
727
|
onImplement={onBuildFromPlan}
|
|
728
|
+
disabled={planActionsDisabled}
|
|
698
729
|
/>
|
|
699
730
|
) : (
|
|
700
|
-
<PlanCompletionCard
|
|
731
|
+
<PlanCompletionCard
|
|
732
|
+
onImplement={onBuildFromPlan}
|
|
733
|
+
disabled={planActionsDisabled}
|
|
734
|
+
/>
|
|
701
735
|
);
|
|
702
736
|
}
|
|
703
737
|
}
|
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { memo,
|
|
3
|
+
import { memo, useState } from "react";
|
|
4
4
|
import { cn } from "@stigmer/theme";
|
|
5
5
|
import type { ExecutionArtifact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
|
|
6
|
-
import {
|
|
7
|
-
import { ArtifactContentRenderer } from "./ArtifactContentRenderer";
|
|
6
|
+
import { ArtifactPreviewModal } from "./ArtifactPreviewModal";
|
|
8
7
|
import { formatArtifactSize } from "./artifact-utils";
|
|
9
8
|
|
|
10
|
-
/** Plans above this size are not inlined for preview — download instead. */
|
|
11
|
-
const MAX_PREVIEW_BYTES = 512 * 1024;
|
|
12
|
-
|
|
13
9
|
/** Props for {@link PlanArtifactCard}. */
|
|
14
10
|
export interface PlanArtifactCardProps {
|
|
15
11
|
/** Execution that produced the plan — used to fetch the plan content. */
|
|
16
12
|
readonly executionId: string;
|
|
17
13
|
/** The published `plan.md` artifact (from `findPlanArtifact`). */
|
|
18
14
|
readonly artifact: ExecutionArtifact;
|
|
15
|
+
/**
|
|
16
|
+
* Organization slug. Required for the "Review plan" modal (the shared
|
|
17
|
+
* {@link ArtifactPreviewModal} uses it for its detection/apply pipeline).
|
|
18
|
+
* When omitted, the Review action is hidden — Implement and Download remain.
|
|
19
|
+
*/
|
|
20
|
+
readonly org?: string;
|
|
19
21
|
/** Called when the user clicks "Implement". Hidden when omitted. */
|
|
20
22
|
readonly onImplement?: () => void;
|
|
21
23
|
/** Disables the Implement CTA (e.g., while an execution is active). */
|
|
@@ -28,11 +30,13 @@ export interface PlanArtifactCardProps {
|
|
|
28
30
|
* Reviewable card shown after a completed Plan-mode execution when the agent
|
|
29
31
|
* published a `plan.md` artifact.
|
|
30
32
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* the
|
|
35
|
-
*
|
|
33
|
+
* Presentational by design: it surfaces the plan as a first-class object with
|
|
34
|
+
* three actions — **Implement** (turn the plan into an Agent run), **Review
|
|
35
|
+
* plan** (open the rendered plan in the shared {@link ArtifactPreviewModal},
|
|
36
|
+
* the same popup used elsewhere for artifact previews — where Copy and an
|
|
37
|
+
* Implement CTA also live), and **Download** the `plan.md` file. The plan text
|
|
38
|
+
* is the single source of truth (the published artifact); the modal fetches it
|
|
39
|
+
* on demand, so the card itself holds no plan content.
|
|
36
40
|
*
|
|
37
41
|
* All visual properties flow through `--stgm-*` tokens; the component is
|
|
38
42
|
* self-contained and embeddable.
|
|
@@ -40,35 +44,12 @@ export interface PlanArtifactCardProps {
|
|
|
40
44
|
export const PlanArtifactCard = memo(function PlanArtifactCard({
|
|
41
45
|
executionId,
|
|
42
46
|
artifact,
|
|
47
|
+
org,
|
|
43
48
|
onImplement,
|
|
44
49
|
disabled,
|
|
45
50
|
className,
|
|
46
51
|
}: PlanArtifactCardProps) {
|
|
47
|
-
const [
|
|
48
|
-
const [copied, setCopied] = useState(false);
|
|
49
|
-
|
|
50
|
-
// Plans are small markdown; fetch eagerly (within the size cap) so both the
|
|
51
|
-
// preview and Copy have content ready. contentHash invalidates the cache when
|
|
52
|
-
// the plan is re-published in the same execution.
|
|
53
|
-
const fetchable = Number(artifact.sizeBytes) < MAX_PREVIEW_BYTES;
|
|
54
|
-
const { content, contentType, isTruncated, isLoading } = useArtifactContent(
|
|
55
|
-
fetchable ? executionId : null,
|
|
56
|
-
fetchable ? artifact.storageKey : null,
|
|
57
|
-
undefined,
|
|
58
|
-
artifact.contentHash || undefined,
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
const handleCopy = useCallback(async () => {
|
|
62
|
-
if (!content) return;
|
|
63
|
-
try {
|
|
64
|
-
await navigator.clipboard.writeText(content);
|
|
65
|
-
setCopied(true);
|
|
66
|
-
setTimeout(() => setCopied(false), 1500);
|
|
67
|
-
} catch {
|
|
68
|
-
// Clipboard can be unavailable (insecure context / denied permission).
|
|
69
|
-
// Silently no-op: Download remains available as the durable fallback.
|
|
70
|
-
}
|
|
71
|
-
}, [content]);
|
|
52
|
+
const [previewOpen, setPreviewOpen] = useState(false);
|
|
72
53
|
|
|
73
54
|
return (
|
|
74
55
|
<div
|
|
@@ -111,30 +92,19 @@ export const PlanArtifactCard = memo(function PlanArtifactCard({
|
|
|
111
92
|
|
|
112
93
|
{/* Secondary actions */}
|
|
113
94
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-border-muted px-3 py-1.5">
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
type="button"
|
|
128
|
-
onClick={handleCopy}
|
|
129
|
-
disabled={!content}
|
|
130
|
-
className={cn(
|
|
131
|
-
"text-xs font-medium text-muted-foreground transition-colors hover:text-foreground",
|
|
132
|
-
"disabled:pointer-events-none disabled:opacity-50",
|
|
133
|
-
FOCUS_RING,
|
|
134
|
-
)}
|
|
135
|
-
>
|
|
136
|
-
{copied ? "Copied" : "Copy"}
|
|
137
|
-
</button>
|
|
95
|
+
{org && (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => setPreviewOpen(true)}
|
|
99
|
+
className={cn(
|
|
100
|
+
"inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:text-primary-muted",
|
|
101
|
+
FOCUS_RING,
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
<ExpandIcon />
|
|
105
|
+
Review plan
|
|
106
|
+
</button>
|
|
107
|
+
)}
|
|
138
108
|
<a
|
|
139
109
|
href={artifact.downloadUrl}
|
|
140
110
|
download={artifact.name}
|
|
@@ -148,28 +118,18 @@ export const PlanArtifactCard = memo(function PlanArtifactCard({
|
|
|
148
118
|
</a>
|
|
149
119
|
</div>
|
|
150
120
|
|
|
151
|
-
{/*
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
fileName={artifact.name}
|
|
164
|
-
contentType={contentType}
|
|
165
|
-
isTruncated={isTruncated}
|
|
166
|
-
/>
|
|
167
|
-
) : (
|
|
168
|
-
<p className="text-xs text-muted-foreground">
|
|
169
|
-
Plan content is unavailable. Use Download to retrieve the file.
|
|
170
|
-
</p>
|
|
171
|
-
)}
|
|
172
|
-
</div>
|
|
121
|
+
{/* Review opens the shared artifact preview popup — consistent with the
|
|
122
|
+
Artifacts tab and the single place that fetches/renders plan content. */}
|
|
123
|
+
{org && previewOpen && (
|
|
124
|
+
<ArtifactPreviewModal
|
|
125
|
+
artifact={artifact}
|
|
126
|
+
executionId={executionId}
|
|
127
|
+
org={org}
|
|
128
|
+
isTerminal
|
|
129
|
+
open
|
|
130
|
+
onClose={() => setPreviewOpen(false)}
|
|
131
|
+
onImplement={onImplement}
|
|
132
|
+
/>
|
|
173
133
|
)}
|
|
174
134
|
</div>
|
|
175
135
|
);
|
|
@@ -237,7 +197,7 @@ function DownloadIcon() {
|
|
|
237
197
|
);
|
|
238
198
|
}
|
|
239
199
|
|
|
240
|
-
function
|
|
200
|
+
function ExpandIcon() {
|
|
241
201
|
return (
|
|
242
202
|
<svg
|
|
243
203
|
width="10"
|
|
@@ -248,10 +208,10 @@ function ChevronIcon({ expanded }: { readonly expanded: boolean }) {
|
|
|
248
208
|
strokeWidth="1.5"
|
|
249
209
|
strokeLinecap="round"
|
|
250
210
|
strokeLinejoin="round"
|
|
251
|
-
className=
|
|
211
|
+
className="shrink-0"
|
|
252
212
|
aria-hidden="true"
|
|
253
213
|
>
|
|
254
|
-
<path d="
|
|
214
|
+
<path d="M7 1.5h3.5V5M10.5 1.5L6.5 5.5M5 10.5H1.5V7M1.5 10.5l4-4" />
|
|
255
215
|
</svg>
|
|
256
216
|
);
|
|
257
217
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { create } from "@bufbuild/protobuf";
|
|
5
|
+
import type { Stigmer } from "@stigmer/sdk";
|
|
6
|
+
import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
|
|
7
|
+
import { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
8
|
+
import { StigmerContext } from "../../context";
|
|
9
|
+
import { ArtifactPreviewContent } from "../ArtifactPreviewModal";
|
|
10
|
+
|
|
11
|
+
// A non-text artifact: ArtifactPreviewContent skips the content fetch, so the
|
|
12
|
+
// test exercises the Implement action without any network mocking.
|
|
13
|
+
const binaryArtifact = create(ExecutionArtifactSchema, {
|
|
14
|
+
name: "report.bin",
|
|
15
|
+
kind: ExecutionArtifactKind.FILE,
|
|
16
|
+
sizeBytes: 128n,
|
|
17
|
+
storageKey: "artifacts/aex_1/report.bin",
|
|
18
|
+
downloadUrl: "https://example.test/report.bin",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
function withStigmer(children: ReactNode) {
|
|
22
|
+
const client = {} as unknown as Stigmer;
|
|
23
|
+
return (
|
|
24
|
+
<StigmerContext.Provider value={client}>{children}</StigmerContext.Provider>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(cleanup);
|
|
29
|
+
|
|
30
|
+
describe("ArtifactPreviewContent — Implement action", () => {
|
|
31
|
+
it("renders an Implement button only when onImplement is provided", () => {
|
|
32
|
+
const { rerender } = render(
|
|
33
|
+
withStigmer(
|
|
34
|
+
<ArtifactPreviewContent
|
|
35
|
+
artifact={binaryArtifact}
|
|
36
|
+
executionId="aex_1"
|
|
37
|
+
org="acme"
|
|
38
|
+
isTerminal
|
|
39
|
+
onClose={() => {}}
|
|
40
|
+
/>,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
expect(screen.queryByText("Implement")).toBeNull();
|
|
44
|
+
|
|
45
|
+
rerender(
|
|
46
|
+
withStigmer(
|
|
47
|
+
<ArtifactPreviewContent
|
|
48
|
+
artifact={binaryArtifact}
|
|
49
|
+
executionId="aex_1"
|
|
50
|
+
org="acme"
|
|
51
|
+
isTerminal
|
|
52
|
+
onClose={() => {}}
|
|
53
|
+
onImplement={() => {}}
|
|
54
|
+
/>,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
expect(screen.getByText("Implement")).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("calls onImplement then closes the modal when Implement is clicked", () => {
|
|
61
|
+
const calls: string[] = [];
|
|
62
|
+
const onImplement = vi.fn(() => calls.push("implement"));
|
|
63
|
+
const onClose = vi.fn(() => calls.push("close"));
|
|
64
|
+
|
|
65
|
+
render(
|
|
66
|
+
withStigmer(
|
|
67
|
+
<ArtifactPreviewContent
|
|
68
|
+
artifact={binaryArtifact}
|
|
69
|
+
executionId="aex_1"
|
|
70
|
+
org="acme"
|
|
71
|
+
isTerminal
|
|
72
|
+
onClose={onClose}
|
|
73
|
+
onImplement={onImplement}
|
|
74
|
+
/>,
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
fireEvent.click(screen.getByText("Implement"));
|
|
79
|
+
|
|
80
|
+
expect(onImplement).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
82
|
+
// Run the plan action before tearing down the modal.
|
|
83
|
+
expect(calls).toEqual(["implement", "close"]);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { create } from "@bufbuild/protobuf";
|
|
5
|
+
import type { Stigmer } from "@stigmer/sdk";
|
|
6
|
+
import { ExecutionArtifactSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
|
|
7
|
+
import { ExecutionArtifactKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
8
|
+
import { StigmerContext } from "../../context";
|
|
9
|
+
import { PlanArtifactCard } from "../PlanArtifactCard";
|
|
10
|
+
|
|
11
|
+
const planArtifact = create(ExecutionArtifactSchema, {
|
|
12
|
+
name: "plan.md",
|
|
13
|
+
kind: ExecutionArtifactKind.FILE,
|
|
14
|
+
sizeBytes: 4500n,
|
|
15
|
+
storageKey: "artifacts/aex_1/plan.md",
|
|
16
|
+
downloadUrl: "https://example.test/plan.md",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Modal content fetches artifact text — keep it pending so nothing rejects. */
|
|
20
|
+
function createStigmerMock(): Stigmer {
|
|
21
|
+
return {
|
|
22
|
+
agentExecution: {
|
|
23
|
+
getArtifactContent: vi.fn().mockReturnValue(new Promise(() => {})),
|
|
24
|
+
},
|
|
25
|
+
} as unknown as Stigmer;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function withStigmer(children: ReactNode) {
|
|
29
|
+
return (
|
|
30
|
+
<StigmerContext.Provider value={createStigmerMock()}>
|
|
31
|
+
{children}
|
|
32
|
+
</StigmerContext.Provider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterEach(cleanup);
|
|
37
|
+
|
|
38
|
+
describe("PlanArtifactCard", () => {
|
|
39
|
+
it("renders the review header with the artifact name and size", () => {
|
|
40
|
+
render(
|
|
41
|
+
<PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const region = screen.getByRole("region", { name: "Plan ready to review" });
|
|
45
|
+
expect(region.textContent).toContain("Plan ready to review");
|
|
46
|
+
expect(region.textContent).toContain("plan.md");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("calls onImplement when Implement is clicked", () => {
|
|
50
|
+
const onImplement = vi.fn();
|
|
51
|
+
render(
|
|
52
|
+
<PlanArtifactCard
|
|
53
|
+
executionId="aex_1"
|
|
54
|
+
artifact={planArtifact}
|
|
55
|
+
org="acme"
|
|
56
|
+
onImplement={onImplement}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
fireEvent.click(screen.getByText("Implement"));
|
|
61
|
+
expect(onImplement).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("hides Implement when onImplement is not provided", () => {
|
|
65
|
+
render(
|
|
66
|
+
<PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
|
|
67
|
+
);
|
|
68
|
+
expect(screen.queryByText("Implement")).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("disables Implement when disabled", () => {
|
|
72
|
+
const onImplement = vi.fn();
|
|
73
|
+
render(
|
|
74
|
+
<PlanArtifactCard
|
|
75
|
+
executionId="aex_1"
|
|
76
|
+
artifact={planArtifact}
|
|
77
|
+
org="acme"
|
|
78
|
+
onImplement={onImplement}
|
|
79
|
+
disabled
|
|
80
|
+
/>,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const button = screen.getByText("Implement").closest("button")!;
|
|
84
|
+
expect(button.disabled).toBe(true);
|
|
85
|
+
fireEvent.click(button);
|
|
86
|
+
expect(onImplement).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("exposes a Download link to the artifact", () => {
|
|
90
|
+
render(
|
|
91
|
+
<PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
|
|
92
|
+
);
|
|
93
|
+
const link = screen.getByText("Download").closest("a")!;
|
|
94
|
+
expect(link.getAttribute("href")).toBe("https://example.test/plan.md");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("opens the shared preview modal when Review plan is clicked", () => {
|
|
98
|
+
render(
|
|
99
|
+
withStigmer(
|
|
100
|
+
<PlanArtifactCard
|
|
101
|
+
executionId="aex_1"
|
|
102
|
+
artifact={planArtifact}
|
|
103
|
+
org="acme"
|
|
104
|
+
/>,
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// No dialog before the user reviews.
|
|
109
|
+
expect(document.querySelector("dialog")).toBeNull();
|
|
110
|
+
|
|
111
|
+
fireEvent.click(screen.getByText("Review plan"));
|
|
112
|
+
|
|
113
|
+
const dialog = document.querySelector("dialog");
|
|
114
|
+
expect(dialog).toBeTruthy();
|
|
115
|
+
expect(dialog!.getAttribute("aria-label")).toBe("Preview plan.md");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("hides Review plan when org is absent (modal needs org)", () => {
|
|
119
|
+
render(<PlanArtifactCard executionId="aex_1" artifact={planArtifact} />);
|
|
120
|
+
expect(screen.queryByText("Review plan")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -3,14 +3,9 @@
|
|
|
3
3
|
import { useCallback, useState } from "react";
|
|
4
4
|
import type { JsonObject } from "@bufbuild/protobuf";
|
|
5
5
|
import type { AttachmentInput, EnvVarInput } from "@stigmer/sdk";
|
|
6
|
-
import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
7
6
|
import { useStigmer } from "../hooks";
|
|
8
7
|
import { toError } from "../internal/toError";
|
|
9
|
-
|
|
10
|
-
const INTERACTION_MODE_MAP: Record<string, InteractionMode> = {
|
|
11
|
-
agent: InteractionMode.AGENT,
|
|
12
|
-
plan: InteractionMode.PLAN,
|
|
13
|
-
};
|
|
8
|
+
import { toProtoInteractionMode } from "../composer/interaction-mode";
|
|
14
9
|
|
|
15
10
|
/** Input for {@link UseCreateAgentExecutionReturn.create}. */
|
|
16
11
|
export interface CreateAgentExecutionInput {
|
|
@@ -168,7 +163,7 @@ export function useCreateAgentExecution(): UseCreateAgentExecutionReturn {
|
|
|
168
163
|
? {
|
|
169
164
|
...(input.modelName ? { modelName: input.modelName } : {}),
|
|
170
165
|
...(input.interactionMode
|
|
171
|
-
? { interactionMode:
|
|
166
|
+
? { interactionMode: toProtoInteractionMode(input.interactionMode) }
|
|
172
167
|
: {}),
|
|
173
168
|
...(input.structuredOutputSchema
|
|
174
169
|
? { structuredOutputSchema: input.structuredOutputSchema }
|
|
@@ -38,6 +38,8 @@ export interface VirtualizedThreadProps {
|
|
|
38
38
|
readonly filePathCtx: FilePathContextValue;
|
|
39
39
|
readonly sandboxCtx: SandboxContextValue;
|
|
40
40
|
readonly onBuildFromPlan?: () => void;
|
|
41
|
+
readonly org?: string;
|
|
42
|
+
readonly planActionsDisabled?: boolean;
|
|
41
43
|
readonly centerContent?: boolean;
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -96,6 +98,8 @@ export function VirtualizedThread({
|
|
|
96
98
|
filePathCtx,
|
|
97
99
|
sandboxCtx,
|
|
98
100
|
onBuildFromPlan,
|
|
101
|
+
org,
|
|
102
|
+
planActionsDisabled,
|
|
99
103
|
centerContent,
|
|
100
104
|
}: VirtualizedThreadProps) {
|
|
101
105
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
@@ -126,8 +130,10 @@ export function VirtualizedThread({
|
|
|
126
130
|
onApprovalSubmit,
|
|
127
131
|
submittingApprovalIds,
|
|
128
132
|
onBuildFromPlan,
|
|
133
|
+
org,
|
|
134
|
+
planActionsDisabled,
|
|
129
135
|
}),
|
|
130
|
-
[formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan],
|
|
136
|
+
[formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled],
|
|
131
137
|
);
|
|
132
138
|
|
|
133
139
|
return (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useMemo, useRef,
|
|
3
|
+
import { useCallback, useMemo, useRef, type ReactNode } from "react";
|
|
4
4
|
import { cn } from "@stigmer/theme";
|
|
5
5
|
import { getUserMessage, type ResourceRef } from "@stigmer/sdk";
|
|
6
6
|
import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection";
|
|
@@ -18,6 +18,14 @@ import { SecretFlowErrorGuide, isSecretFlowError } from "../error";
|
|
|
18
18
|
import { useSessionPageFlow } from "./useSessionPageFlow";
|
|
19
19
|
import { SessionInspector } from "./inspector/SessionInspector";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Message submitted when the user implements a plan. References the published
|
|
23
|
+
* `plan.md` explicitly so the agent acts on the durable artifact rather than
|
|
24
|
+
* relying on re-reading its own prior chat message.
|
|
25
|
+
*/
|
|
26
|
+
const IMPLEMENT_PLAN_MESSAGE =
|
|
27
|
+
"Implement the plan above (saved as plan.md). Follow it step by step and make the changes it describes.";
|
|
28
|
+
|
|
21
29
|
/** Props for {@link SessionViewer}. */
|
|
22
30
|
export interface SessionViewerProps {
|
|
23
31
|
/** Session ID to load and display. */
|
|
@@ -126,7 +134,7 @@ export function SessionViewer({
|
|
|
126
134
|
const { conv } = flow;
|
|
127
135
|
|
|
128
136
|
const [modelId, setModelId] = flow.model;
|
|
129
|
-
const [interactionMode, setInteractionMode] =
|
|
137
|
+
const [interactionMode, setInteractionMode] = flow.interactionMode;
|
|
130
138
|
const composerRef = useRef<SessionComposerHandle>(null);
|
|
131
139
|
|
|
132
140
|
const selectionStoreRef = useRef<SelectionStore | null>(null);
|
|
@@ -136,15 +144,15 @@ export function SessionViewer({
|
|
|
136
144
|
const selectionStore = selectionStoreRef.current;
|
|
137
145
|
|
|
138
146
|
const handleBuildFromPlan = useCallback(() => {
|
|
147
|
+
// Switch the picker to Agent (so subsequent turns stay in Agent) and submit
|
|
148
|
+
// the implement message immediately through the composer's full pipeline.
|
|
149
|
+
// `interactionMode: "agent"` is passed explicitly to win the same-tick race
|
|
150
|
+
// where the composer prop has not yet re-rendered from "plan".
|
|
139
151
|
setInteractionMode("agent");
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
"Implement the plan above (saved as plan.md). Follow it step by step and make the changes it describes.",
|
|
145
|
-
);
|
|
146
|
-
composerRef.current?.focus();
|
|
147
|
-
}, []);
|
|
152
|
+
composerRef.current?.submit(IMPLEMENT_PLAN_MESSAGE, {
|
|
153
|
+
interactionMode: "agent",
|
|
154
|
+
});
|
|
155
|
+
}, [setInteractionMode]);
|
|
148
156
|
|
|
149
157
|
if (conv.isLoading) {
|
|
150
158
|
return (
|
|
@@ -202,6 +210,7 @@ export function SessionViewer({
|
|
|
202
210
|
org={org}
|
|
203
211
|
selectionStore={selectionStore}
|
|
204
212
|
onApplied={onApplied}
|
|
213
|
+
onImplementPlan={handleBuildFromPlan}
|
|
205
214
|
enableGitHub={enableGitHub}
|
|
206
215
|
enableLocal={enableLocal}
|
|
207
216
|
gitHubConnection={gitHubConnection}
|
|
@@ -261,6 +270,8 @@ function ConversationColumn({
|
|
|
261
270
|
workspaceEntries={conv.workspaceEntries}
|
|
262
271
|
sandboxWorkspaceRoot={flow.sandboxWorkspaceRoot}
|
|
263
272
|
onBuildFromPlan={onBuildFromPlan}
|
|
273
|
+
org={org}
|
|
274
|
+
planActionsDisabled={!conv.canSendFollowUp}
|
|
264
275
|
centerContent
|
|
265
276
|
className="flex-1"
|
|
266
277
|
/>
|
|
@@ -319,6 +330,8 @@ interface InspectorPanelProps {
|
|
|
319
330
|
readonly org: string;
|
|
320
331
|
readonly selectionStore: SelectionStore;
|
|
321
332
|
readonly onApplied?: (result: ApplyResourceResult) => void;
|
|
333
|
+
/** Implement a plan from the Artifacts tab (same action as the thread card). */
|
|
334
|
+
readonly onImplementPlan?: () => void;
|
|
322
335
|
readonly enableGitHub: boolean;
|
|
323
336
|
readonly enableLocal: boolean;
|
|
324
337
|
readonly gitHubConnection?: UseGitHubConnectionReturn;
|
|
@@ -331,6 +344,7 @@ function InspectorPanel({
|
|
|
331
344
|
org,
|
|
332
345
|
selectionStore,
|
|
333
346
|
onApplied,
|
|
347
|
+
onImplementPlan,
|
|
334
348
|
enableGitHub,
|
|
335
349
|
enableLocal,
|
|
336
350
|
gitHubConnection,
|
|
@@ -411,6 +425,7 @@ function InspectorPanel({
|
|
|
411
425
|
org={org}
|
|
412
426
|
selectedItem={selectedItem}
|
|
413
427
|
onApplied={onApplied}
|
|
428
|
+
onImplementPlan={onImplementPlan}
|
|
414
429
|
sessionConfig={sessionConfig}
|
|
415
430
|
workspaceConfig={workspaceConfig}
|
|
416
431
|
className="min-h-0 flex-1"
|