@stigmer/react 3.0.2 → 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.
Files changed (64) hide show
  1. package/composer/SessionComposer.d.ts +23 -5
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +12 -5
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/composer/index.d.ts +1 -0
  6. package/composer/index.d.ts.map +1 -1
  7. package/composer/index.js +1 -0
  8. package/composer/index.js.map +1 -1
  9. package/composer/interaction-mode.d.ts +21 -0
  10. package/composer/interaction-mode.d.ts.map +1 -0
  11. package/composer/interaction-mode.js +37 -0
  12. package/composer/interaction-mode.js.map +1 -0
  13. package/execution/ArtifactPreviewModal.d.ts +15 -2
  14. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  15. package/execution/ArtifactPreviewModal.js +16 -6
  16. package/execution/ArtifactPreviewModal.js.map +1 -1
  17. package/execution/MessageThread.d.ts +17 -2
  18. package/execution/MessageThread.d.ts.map +1 -1
  19. package/execution/MessageThread.js +7 -7
  20. package/execution/MessageThread.js.map +1 -1
  21. package/execution/PlanArtifactCard.d.ts +13 -5
  22. package/execution/PlanArtifactCard.d.ts.map +1 -1
  23. package/execution/PlanArtifactCard.js +14 -34
  24. package/execution/PlanArtifactCard.js.map +1 -1
  25. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  26. package/execution/useCreateAgentExecution.js +2 -6
  27. package/execution/useCreateAgentExecution.js.map +1 -1
  28. package/internal/VirtualizedThread.d.ts +3 -1
  29. package/internal/VirtualizedThread.d.ts.map +1 -1
  30. package/internal/VirtualizedThread.js +4 -2
  31. package/internal/VirtualizedThread.js.map +1 -1
  32. package/package.json +4 -4
  33. package/session/SessionViewer.d.ts.map +1 -1
  34. package/session/SessionViewer.js +20 -12
  35. package/session/SessionViewer.js.map +1 -1
  36. package/session/inspector/ArtifactsTab.d.ts +7 -1
  37. package/session/inspector/ArtifactsTab.d.ts.map +1 -1
  38. package/session/inspector/ArtifactsTab.js +3 -2
  39. package/session/inspector/ArtifactsTab.js.map +1 -1
  40. package/session/inspector/SessionInspector.d.ts +5 -0
  41. package/session/inspector/SessionInspector.d.ts.map +1 -1
  42. package/session/inspector/SessionInspector.js +2 -2
  43. package/session/inspector/SessionInspector.js.map +1 -1
  44. package/session/useSessionPageFlow.d.ts +12 -1
  45. package/session/useSessionPageFlow.d.ts.map +1 -1
  46. package/session/useSessionPageFlow.js +12 -0
  47. package/session/useSessionPageFlow.js.map +1 -1
  48. package/src/composer/SessionComposer.tsx +34 -9
  49. package/src/composer/__tests__/SessionComposer-contract.test.tsx +42 -2
  50. package/src/composer/__tests__/interaction-mode.test.ts +44 -0
  51. package/src/composer/index.ts +5 -0
  52. package/src/composer/interaction-mode.ts +43 -0
  53. package/src/execution/ArtifactPreviewModal.tsx +61 -1
  54. package/src/execution/MessageThread.tsx +35 -1
  55. package/src/execution/PlanArtifactCard.tsx +45 -85
  56. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +85 -0
  57. package/src/execution/__tests__/PlanArtifactCard.test.tsx +122 -0
  58. package/src/execution/useCreateAgentExecution.ts +2 -7
  59. package/src/internal/VirtualizedThread.tsx +7 -1
  60. package/src/session/SessionViewer.tsx +25 -10
  61. package/src/session/inspector/ArtifactsTab.tsx +11 -1
  62. package/src/session/inspector/SessionInspector.tsx +7 -0
  63. package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +90 -0
  64. 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 onImplement={onBuildFromPlan} />
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, useCallback, useState } from "react";
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 { useArtifactContent } from "./useArtifactContent";
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
- * Unlike {@link PlanCompletionCard} (a bare Implement CTA), this card makes the
32
- * plan a first-class, durable object: expand to review the rendered plan, copy
33
- * it, download the `plan.md` file, or proceed to implement. The plan content is
34
- * the single source of truth (the published artifact), fetched on demand via
35
- * {@link useArtifactContent} rather than duplicated into component state.
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 [expanded, setExpanded] = useState(false);
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
- <button
115
- type="button"
116
- aria-expanded={expanded}
117
- onClick={() => setExpanded((v) => !v)}
118
- className={cn(
119
- "inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:text-primary-muted",
120
- FOCUS_RING,
121
- )}
122
- >
123
- <ChevronIcon expanded={expanded} />
124
- {expanded ? "Hide plan" : "Review plan"}
125
- </button>
126
- <button
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
- {/* Expandable plan preview */}
152
- {expanded && (
153
- <div className="max-h-96 overflow-auto border-t border-border-muted px-3 py-2">
154
- {!fetchable ? (
155
- <p className="text-xs text-muted-foreground">
156
- This plan is large — use Download to view the full file.
157
- </p>
158
- ) : isLoading ? (
159
- <div className="h-4 w-40 animate-pulse rounded bg-muted" aria-hidden="true" />
160
- ) : content ? (
161
- <ArtifactContentRenderer
162
- content={content}
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 ChevronIcon({ expanded }: { readonly expanded: boolean }) {
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={cn("shrink-0 transition-transform", expanded && "rotate-90")}
211
+ className="shrink-0"
252
212
  aria-hidden="true"
253
213
  >
254
- <path d="M4 2l4 4-4 4" />
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: INTERACTION_MODE_MAP[input.interactionMode] ?? InteractionMode.UNSPECIFIED }
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, useState, type ReactNode } from "react";
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] = useState<InteractionModeOption>("agent");
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
- // Reference the plan explicitly (it is also published as plan.md) and direct
141
- // the agent to act on it, rather than a bare "implement" that relies on the
142
- // model re-reading its own prior message.
143
- composerRef.current?.setMessage(
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"