@stigmer/react 3.1.2 → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/composer/SessionComposer.d.ts +6 -5
  2. package/composer/SessionComposer.d.ts.map +1 -1
  3. package/composer/SessionComposer.js +1 -1
  4. package/composer/SessionComposer.js.map +1 -1
  5. package/execution/ArtifactPreviewModal.d.ts +5 -5
  6. package/execution/ArtifactPreviewModal.js +4 -4
  7. package/execution/ArtifactPreviewModal.js.map +1 -1
  8. package/execution/FileChangeProgressBar.d.ts +47 -0
  9. package/execution/FileChangeProgressBar.d.ts.map +1 -0
  10. package/execution/FileChangeProgressBar.js +49 -0
  11. package/execution/FileChangeProgressBar.js.map +1 -0
  12. package/execution/FileChangesView.d.ts.map +1 -1
  13. package/execution/FileChangesView.js +19 -4
  14. package/execution/FileChangesView.js.map +1 -1
  15. package/execution/FileReviewAtoms.d.ts +27 -0
  16. package/execution/FileReviewAtoms.d.ts.map +1 -0
  17. package/execution/FileReviewAtoms.js +64 -0
  18. package/execution/FileReviewAtoms.js.map +1 -0
  19. package/execution/FileReviewCard.d.ts +2 -2
  20. package/execution/FileReviewCard.d.ts.map +1 -1
  21. package/execution/FileReviewCard.js +32 -62
  22. package/execution/FileReviewCard.js.map +1 -1
  23. package/execution/MessageEntry.d.ts +0 -9
  24. package/execution/MessageEntry.d.ts.map +1 -1
  25. package/execution/MessageEntry.js +1 -11
  26. package/execution/MessageEntry.js.map +1 -1
  27. package/execution/MessageThread.d.ts +3 -4
  28. package/execution/MessageThread.d.ts.map +1 -1
  29. package/execution/MessageThread.js +25 -11
  30. package/execution/MessageThread.js.map +1 -1
  31. package/execution/PlanArtifactCard.d.ts +16 -10
  32. package/execution/PlanArtifactCard.d.ts.map +1 -1
  33. package/execution/PlanArtifactCard.js +31 -7
  34. package/execution/PlanArtifactCard.js.map +1 -1
  35. package/execution/PlanCompletionCard.d.ts +4 -4
  36. package/execution/PlanCompletionCard.d.ts.map +1 -1
  37. package/execution/PlanCompletionCard.js +4 -4
  38. package/execution/PlanCompletionCard.js.map +1 -1
  39. package/execution/PlanStreamingCard.d.ts +7 -2
  40. package/execution/PlanStreamingCard.d.ts.map +1 -1
  41. package/execution/PlanStreamingCard.js +11 -6
  42. package/execution/PlanStreamingCard.js.map +1 -1
  43. package/execution/index.d.ts +4 -0
  44. package/execution/index.d.ts.map +1 -1
  45. package/execution/index.js +2 -0
  46. package/execution/index.js.map +1 -1
  47. package/execution/use-build-from-plan-hotkey.d.ts +1 -1
  48. package/execution/use-build-from-plan-hotkey.js +1 -1
  49. package/execution/useArtifactCopy.d.ts +51 -0
  50. package/execution/useArtifactCopy.d.ts.map +1 -0
  51. package/execution/useArtifactCopy.js +75 -0
  52. package/execution/useArtifactCopy.js.map +1 -0
  53. package/execution/useArtifactDownload.d.ts.map +1 -1
  54. package/execution/useArtifactDownload.js +17 -5
  55. package/execution/useArtifactDownload.js.map +1 -1
  56. package/execution/useCreateAgentExecution.d.ts +5 -3
  57. package/execution/useCreateAgentExecution.d.ts.map +1 -1
  58. package/execution/useCreateAgentExecution.js.map +1 -1
  59. package/index.d.ts +3 -3
  60. package/index.d.ts.map +1 -1
  61. package/index.js +2 -2
  62. package/index.js.map +1 -1
  63. package/internal/file-tree/FileTreeNode.d.ts.map +1 -1
  64. package/internal/file-tree/FileTreeNode.js +2 -2
  65. package/internal/file-tree/FileTreeNode.js.map +1 -1
  66. package/library/detect-plan-artifact.d.ts +27 -9
  67. package/library/detect-plan-artifact.d.ts.map +1 -1
  68. package/library/detect-plan-artifact.js +26 -6
  69. package/library/detect-plan-artifact.js.map +1 -1
  70. package/library/index.d.ts +1 -1
  71. package/library/index.d.ts.map +1 -1
  72. package/library/index.js +1 -1
  73. package/library/index.js.map +1 -1
  74. package/package.json +4 -4
  75. package/session/PlanEditor.js +1 -1
  76. package/session/PlanEditor.js.map +1 -1
  77. package/session/SessionViewer.d.ts.map +1 -1
  78. package/session/SessionViewer.js +32 -13
  79. package/session/SessionViewer.js.map +1 -1
  80. package/session/plan-document.d.ts +14 -2
  81. package/session/plan-document.d.ts.map +1 -1
  82. package/session/plan-document.js +14 -3
  83. package/session/plan-document.js.map +1 -1
  84. package/session/usePlanDraft.d.ts +1 -1
  85. package/session/useSessionConversation.d.ts +9 -2
  86. package/session/useSessionConversation.d.ts.map +1 -1
  87. package/session/useSessionConversation.js +19 -2
  88. package/session/useSessionConversation.js.map +1 -1
  89. package/src/composer/SessionComposer.tsx +7 -6
  90. package/src/execution/ArtifactPreviewModal.tsx +9 -9
  91. package/src/execution/FileChangeProgressBar.tsx +121 -0
  92. package/src/execution/FileChangesView.tsx +18 -4
  93. package/src/execution/FileReviewAtoms.tsx +89 -0
  94. package/src/execution/FileReviewCard.tsx +57 -90
  95. package/src/execution/MessageEntry.tsx +0 -62
  96. package/src/execution/MessageThread.tsx +29 -15
  97. package/src/execution/PlanArtifactCard.tsx +130 -52
  98. package/src/execution/PlanCompletionCard.tsx +5 -5
  99. package/src/execution/PlanStreamingCard.tsx +15 -11
  100. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
  101. package/src/execution/__tests__/FileChangeProgressBar.test.tsx +125 -0
  102. package/src/execution/__tests__/FileReviewCard.test.tsx +66 -7
  103. package/src/execution/__tests__/MessageThread.test.tsx +1 -1
  104. package/src/execution/__tests__/PlanArtifactCard.test.tsx +52 -18
  105. package/src/execution/__tests__/PlanCompletionCard.test.tsx +2 -2
  106. package/src/execution/__tests__/PlanStreamingCard.test.tsx +11 -4
  107. package/src/execution/__tests__/buildThreadItems-plan-completion.test.ts +104 -8
  108. package/src/execution/__tests__/message-entry.test.tsx +6 -37
  109. package/src/execution/__tests__/useArtifactCopy.test.tsx +90 -0
  110. package/src/execution/__tests__/useArtifactDownload.test.tsx +2 -0
  111. package/src/execution/index.ts +6 -0
  112. package/src/execution/use-build-from-plan-hotkey.ts +1 -1
  113. package/src/execution/useArtifactCopy.ts +109 -0
  114. package/src/execution/useArtifactDownload.ts +17 -5
  115. package/src/execution/useCreateAgentExecution.ts +5 -3
  116. package/src/index.ts +6 -0
  117. package/src/internal/file-tree/FileTreeNode.tsx +13 -2
  118. package/src/library/__tests__/detect-plan-artifact.test.ts +21 -0
  119. package/src/library/detect-plan-artifact.ts +32 -10
  120. package/src/library/index.ts +2 -0
  121. package/src/session/PlanEditor.tsx +1 -1
  122. package/src/session/SessionViewer.tsx +50 -18
  123. package/src/session/__tests__/PlanEditor.test.tsx +4 -4
  124. package/src/session/__tests__/PlanStreamingDocument.test.tsx +1 -1
  125. package/src/session/__tests__/SessionViewer-fileReviewDock.test.tsx +57 -0
  126. package/src/session/__tests__/useSessionConversation.test.tsx +44 -0
  127. package/src/session/facets/__tests__/ArtifactsTab.test.tsx +7 -7
  128. package/src/session/plan-document.ts +14 -3
  129. package/src/session/usePlanDraft.ts +1 -1
  130. package/src/session/useSessionConversation.ts +32 -4
  131. package/src/workspace/EditorTabs.tsx +38 -9
  132. package/src/workspace/ExplorerTree.tsx +10 -4
  133. package/src/workspace/FileViewer.tsx +27 -6
  134. package/src/workspace/WorkspaceContentSearch.tsx +44 -20
  135. package/src/workspace/WorkspaceEditor.tsx +19 -16
  136. package/src/workspace/WorkspaceFileSearch.tsx +51 -28
  137. package/src/workspace/WorkspaceSurface.tsx +106 -48
  138. package/src/workspace/__tests__/EditorTabs.test.tsx +4 -2
  139. package/src/workspace/__tests__/ExplorerTree.test.tsx +52 -0
  140. package/src/workspace/__tests__/FileViewer.test.tsx +18 -0
  141. package/src/workspace/__tests__/WorkspaceContentSearch.test.tsx +38 -5
  142. package/src/workspace/__tests__/WorkspaceEditor-initialPanel.test.tsx +19 -0
  143. package/src/workspace/__tests__/WorkspaceFileSearch.test.tsx +27 -6
  144. package/src/workspace/__tests__/WorkspaceSurface.test.tsx +70 -0
  145. package/src/workspace/__tests__/a11y/harness.tsx +140 -0
  146. package/src/workspace/__tests__/a11y/mock-capabilities.ts +138 -0
  147. package/src/workspace/__tests__/a11y/workspace-editor.a11y.test.tsx +65 -0
  148. package/src/workspace/__tests__/a11y/workspace-explorer-search.a11y.test.tsx +112 -0
  149. package/src/workspace/__tests__/a11y/workspace-viewer-tabs.a11y.test.tsx +69 -0
  150. package/styles.css +1 -1
  151. package/workspace/EditorTabs.d.ts +21 -3
  152. package/workspace/EditorTabs.d.ts.map +1 -1
  153. package/workspace/EditorTabs.js +18 -6
  154. package/workspace/EditorTabs.js.map +1 -1
  155. package/workspace/ExplorerTree.js +8 -4
  156. package/workspace/ExplorerTree.js.map +1 -1
  157. package/workspace/FileViewer.d.ts.map +1 -1
  158. package/workspace/FileViewer.js +19 -8
  159. package/workspace/FileViewer.js.map +1 -1
  160. package/workspace/WorkspaceContentSearch.d.ts.map +1 -1
  161. package/workspace/WorkspaceContentSearch.js +23 -2
  162. package/workspace/WorkspaceContentSearch.js.map +1 -1
  163. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  164. package/workspace/WorkspaceEditor.js +8 -10
  165. package/workspace/WorkspaceEditor.js.map +1 -1
  166. package/workspace/WorkspaceFileSearch.d.ts.map +1 -1
  167. package/workspace/WorkspaceFileSearch.js +23 -3
  168. package/workspace/WorkspaceFileSearch.js.map +1 -1
  169. package/workspace/WorkspaceSurface.d.ts.map +1 -1
  170. package/workspace/WorkspaceSurface.js +54 -18
  171. package/workspace/WorkspaceSurface.js.map +1 -1
@@ -48,15 +48,6 @@ export interface MessageEntryProps {
48
48
  * non-human messages.
49
49
  */
50
50
  readonly interactionMode?: InteractionMode;
51
- /**
52
- * Renders a `MESSAGE_HUMAN` entry as a compact "Build from plan" chip
53
- * instead of a prompt bubble. Stamped by the thread builder from
54
- * `execution_config.build_from_plan`: the turn's message is a short label
55
- * (the implement instruction is runner-injected), so a full-width bubble
56
- * would dignify machine plumbing as user prose. Ignored for non-human
57
- * messages.
58
- */
59
- readonly isBuildFromPlan?: boolean;
60
51
  }
61
52
 
62
53
  /**
@@ -89,7 +80,6 @@ export const MessageEntry = memo(function MessageEntry({
89
80
  onEdit,
90
81
  isPlanDocument,
91
82
  interactionMode,
92
- isBuildFromPlan,
93
83
  }: MessageEntryProps) {
94
84
  useRenderTracer("MessageEntry", {
95
85
  messageType: message.type,
@@ -99,11 +89,6 @@ export const MessageEntry = memo(function MessageEntry({
99
89
 
100
90
  switch (message.type) {
101
91
  case MessageType.MESSAGE_HUMAN:
102
- if (isBuildFromPlan) {
103
- return (
104
- <BuildFromPlanChip content={message.content} className={className} />
105
- );
106
- }
107
92
  return (
108
93
  <HumanMessage
109
94
  content={message.content}
@@ -196,53 +181,6 @@ function HumanMessage({
196
181
  * label is not user prose to rephrase; refining the plan happens in the plan
197
182
  * document, and the build is re-triggered from its card.
198
183
  */
199
- function BuildFromPlanChip({
200
- content,
201
- className,
202
- }: {
203
- content: string;
204
- className?: string;
205
- }) {
206
- return (
207
- <div
208
- role="article"
209
- aria-label="Build from plan"
210
- className={cn("flex justify-end", className)}
211
- >
212
- <span
213
- className={cn(
214
- "inline-flex items-center gap-1.5 rounded-full border border-border",
215
- "bg-muted-subtle px-3 py-1.5 text-xs font-medium text-foreground",
216
- )}
217
- >
218
- <BuildFromPlanIcon />
219
- {content}
220
- </span>
221
- </div>
222
- );
223
- }
224
-
225
- /** The plan-card glyph (document lines + implement arrow) at chip scale. */
226
- function BuildFromPlanIcon() {
227
- return (
228
- <svg
229
- width="13"
230
- height="13"
231
- viewBox="0 0 16 16"
232
- fill="none"
233
- stroke="currentColor"
234
- strokeWidth="1.5"
235
- strokeLinecap="round"
236
- strokeLinejoin="round"
237
- className="shrink-0 text-muted-foreground"
238
- aria-hidden="true"
239
- >
240
- <path d="M3 4h10M3 8h7M3 12h8" />
241
- <path d="M12.5 10.5l1.5 1.5-1.5 1.5" />
242
- </svg>
243
- );
244
- }
245
-
246
184
  function EditIcon() {
247
185
  return (
248
186
  <svg
@@ -289,7 +289,7 @@ export function threadContentColumnClass(
289
289
  * part of the public API.
290
290
  */
291
291
  export type ThreadItem =
292
- | { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean; readonly isEditable?: boolean; readonly isPlanDocument?: boolean; readonly interactionMode?: InteractionMode; readonly isBuildFromPlan?: boolean }
292
+ | { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean; readonly isEditable?: boolean; readonly isPlanDocument?: boolean; readonly interactionMode?: InteractionMode }
293
293
  | { readonly kind: "tool-group"; readonly toolCalls: readonly ToolCall[]; readonly subAgentExecutions: readonly SubAgentExecution[]; readonly key: string }
294
294
  | { readonly kind: "sub-agent"; readonly subAgentExecution: SubAgentExecution; readonly key: string }
295
295
  | { readonly kind: "phase-badge"; readonly phase: ExecutionPhase; readonly key: string }
@@ -317,9 +317,9 @@ export type ThreadItem =
317
317
  readonly planTitle?: string;
318
318
  /**
319
319
  * True for the most recent completed Plan execution in the thread — the
320
- * only plan whose card carries the primary "Build from plan" action.
321
- * Superseded plans keep their review actions (open/download) but never a
322
- * build CTA, so a stale plan can't be implemented by accident.
320
+ * only plan whose card carries the primary "Build" action. Superseded
321
+ * plans keep their review actions (open/download) but never a build
322
+ * CTA, so a stale plan can't be implemented by accident.
323
323
  */
324
324
  readonly isLatestPlan: boolean;
325
325
  }
@@ -537,9 +537,9 @@ export function buildThreadItems(
537
537
  ? allExecutions.length - 1
538
538
  : -1;
539
539
 
540
- // The most recent completed Plan execution owns the primary "Build from
541
- // plan" action; every earlier plan renders as a review-only record. Resolved
542
- // up front so each segment can stamp its own card with the right authority.
540
+ // The most recent completed Plan execution owns the primary "Build" action;
541
+ // every earlier plan renders as a review-only record. Resolved up front so
542
+ // each segment can stamp its own card with the right authority.
543
543
  let latestPlanExecutionId: string | null = null;
544
544
  for (let i = allExecutions.length - 1; i >= 0; i--) {
545
545
  if (isCompletedPlanExecution(allExecutions[i])) {
@@ -648,7 +648,13 @@ export function buildThreadItems(
648
648
  }
649
649
 
650
650
  const specMessage = exec.spec?.message;
651
- if (specMessage && specMessage !== "execute") {
651
+ // A Build-from-plan turn synthesizes NO prompt item: its message is a
652
+ // machine-written label ("Build from plan"), not user prose — the real
653
+ // instruction is runner-injected from the same flag. The plan card
654
+ // directly above the turn is the visible cause; rendering the label as a
655
+ // user bubble would attribute words to the user they never typed.
656
+ const isBuildTurn = exec.spec?.executionConfig?.buildFromPlan === true;
657
+ if (specMessage && specMessage !== "execute" && !isBuildTurn) {
652
658
  const syntheticHumanMsg = create(AgentMessageSchema);
653
659
  syntheticHumanMsg.type = MessageType.MESSAGE_HUMAN;
654
660
  syntheticHumanMsg.content = specMessage;
@@ -671,10 +677,6 @@ export function buildThreadItems(
671
677
  // The turn's mode marks the prompt bubble (a "Plan" pill on Plan
672
678
  // turns) so the transcript reads unambiguously after mode switches.
673
679
  interactionMode: exec.spec?.executionConfig?.interactionMode,
674
- // A Build-from-plan turn's prompt renders as a compact chip, not the
675
- // message text — the text is a short label; the real instruction is
676
- // runner-injected from the same flag.
677
- isBuildFromPlan: exec.spec?.executionConfig?.buildFromPlan || undefined,
678
680
  });
679
681
  }
680
682
 
@@ -896,11 +898,19 @@ export function buildThreadItems(
896
898
  const reason = lastExec?.status?.error;
897
899
  if (reason) {
898
900
  const specMessage = lastExec?.spec?.message;
901
+ // A failed build turn offers no inline Retry: resending its label as an
902
+ // ordinary message would drop the buildFromPlan flag (no runner
903
+ // directive, no plan attachment). The plan card above the error is the
904
+ // fully-wired retry — its Build button re-runs the whole pipeline.
905
+ const isBuildTurn =
906
+ lastExec?.spec?.executionConfig?.buildFromPlan === true;
899
907
  items.push({
900
908
  kind: "execution-error",
901
909
  error: reason,
902
910
  retryMessage:
903
- specMessage && specMessage !== "execute" ? specMessage : undefined,
911
+ specMessage && specMessage !== "execute" && !isBuildTurn
912
+ ? specMessage
913
+ : undefined,
904
914
  key: `execution-error-${lastExec?.metadata?.id ?? lastPhase}`,
905
915
  });
906
916
  }
@@ -936,8 +946,13 @@ export function buildThreadItems(
936
946
  // not thread items at all: they live in the composer-docked FileReviewDock.
937
947
 
938
948
  if (pendingUserMessage) {
949
+ // A FAILED send never created an execution, so an existing execution with
950
+ // identical text (e.g. two build attempts of the same plan — both
951
+ // "Build from plan") must never swallow the failed bubble. Suppression
952
+ // only applies to the succeeded-send window where the stream has
953
+ // delivered the same turn but the pending state hasn't cleared yet.
939
954
  const alreadySynthesized =
940
- lastExec?.spec?.message === pendingUserMessage;
955
+ !pendingMessageFailed && lastExec?.spec?.message === pendingUserMessage;
941
956
  if (!alreadySynthesized) {
942
957
  const syntheticPending = create(AgentMessageSchema);
943
958
  syntheticPending.type = MessageType.MESSAGE_HUMAN;
@@ -1333,7 +1348,6 @@ export function ThreadItemRenderer({
1333
1348
  className={item.isPending ? "opacity-70" : undefined}
1334
1349
  isPlanDocument={item.isPlanDocument}
1335
1350
  interactionMode={item.interactionMode}
1336
- isBuildFromPlan={item.isBuildFromPlan}
1337
1351
  onEdit={
1338
1352
  item.isEditable && onEditMessage
1339
1353
  ? () => onEditMessage(item.message.content)
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { memo, useState } from "react";
3
+ import { memo, useState, type ReactNode } 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
6
  import { ArtifactPreviewModal } from "./ArtifactPreviewModal.js";
7
7
  import { formatArtifactSize } from "./artifact-utils.js";
8
+ import { useArtifactCopy } from "./useArtifactCopy.js";
8
9
  import { useArtifactDownload } from "./useArtifactDownload.js";
9
10
  import { useBuildFromPlanHotkey } from "./use-build-from-plan-hotkey.js";
10
11
 
@@ -21,20 +22,20 @@ export interface PlanArtifactCardProps {
21
22
  */
22
23
  readonly title?: string;
23
24
  /**
24
- * Organization slug. Required for the "Open full" modal (the shared
25
+ * Organization slug. Required for the open-in-modal fallback (the shared
25
26
  * {@link ArtifactPreviewModal} uses it for its detection/apply pipeline and
26
- * to fetch the content). When omitted, "Open full" is hidden — the prominent
27
- * "Build from plan" action and "Download" remain.
27
+ * to fetch the content). When omitted, that fallback is hidden — the
28
+ * prominent "Build" action and the download icon remain.
28
29
  */
29
30
  readonly org?: string;
30
- /** Called when the user clicks "Build from plan". Hidden when omitted. */
31
+ /** Called when the user clicks "Build". Hidden when omitted. */
31
32
  readonly onImplement?: () => void;
32
33
  /**
33
34
  * Opens the plan in the session panel's plan document tab — the
34
35
  * side-by-side review/refine surface. When provided it REPLACES the
35
- * modal-based "Open full" secondary with "Open plan" (one review
36
- * affordance, not two). When omitted (hosts without a session panel) the
37
- * modal remains.
36
+ * modal-based open fallback (one review affordance, not two). When
37
+ * omitted (hosts without a session panel) the same "Open plan" icon
38
+ * opens the preview modal instead.
38
39
  */
39
40
  readonly onOpenPlan?: () => void;
40
41
  /** Disables the primary CTA (e.g., while an execution is active). */
@@ -52,9 +53,15 @@ export interface PlanArtifactCardProps {
52
53
  * the panel carries the document.
53
54
  *
54
55
  * Anatomy: a document-style identity block (plan icon, the plan's title, a
55
- * `plan.md · size` meta line) with the actions trailing — **Open plan** and
56
- * **Download** as subdued secondaries and one prominent, themeable primary,
57
- * **Build from plan** — so the next step is unmistakable.
56
+ * `<name> · size` meta line) with the actions trailing — open, copy, and
57
+ * download as icon-only secondaries (labelled via `aria-label`/`title`) and
58
+ * one prominent, themeable primary, **Build** — so the next step is
59
+ * unmistakable.
60
+ *
61
+ * Action-label rule (shared with `PlanStreamingCard`, which keeps its
62
+ * labelled "Open plan"): a lone secondary keeps its text label; a row of
63
+ * secondaries competing with a primary collapses to icons, so the primary
64
+ * carries the card's one visible verb.
58
65
  *
59
66
  * Only the LATEST plan's card carries `onImplement`; a superseded plan's card
60
67
  * is review-only (its `onOpenPlan` opens the document read-only), so a stale
@@ -76,6 +83,7 @@ export const PlanArtifactCard = memo(function PlanArtifactCard({
76
83
  }: PlanArtifactCardProps) {
77
84
  const [previewOpen, setPreviewOpen] = useState(false);
78
85
  const handleKeyDown = useBuildFromPlanHotkey(onImplement, disabled);
86
+ const { copy, isCopying, copied } = useArtifactCopy(executionId);
79
87
  const { download, isDownloading } = useArtifactDownload(executionId);
80
88
 
81
89
  return (
@@ -103,53 +111,41 @@ export const PlanArtifactCard = memo(function PlanArtifactCard({
103
111
  </div>
104
112
  </div>
105
113
 
106
- <div className="ml-auto flex flex-wrap items-center gap-x-3 gap-y-1.5">
114
+ <div className="ml-auto flex flex-wrap items-center gap-x-1.5 gap-y-1.5">
107
115
  {onOpenPlan ? (
108
- <button
109
- type="button"
116
+ <IconAction
117
+ label="Open plan"
110
118
  onClick={onOpenPlan}
111
- className={cn(
112
- "inline-flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground",
113
- FOCUS_RING,
114
- )}
115
- >
116
- <ExpandIcon />
117
- Open plan
118
- </button>
119
+ icon={<EyeIcon />}
120
+ />
119
121
  ) : (
120
122
  org && (
121
- <button
122
- type="button"
123
+ <IconAction
124
+ label="Open plan"
123
125
  onClick={() => setPreviewOpen(true)}
124
- className={cn(
125
- "inline-flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground",
126
- FOCUS_RING,
127
- )}
128
- >
129
- <ExpandIcon />
130
- Open full
131
- </button>
126
+ icon={<EyeIcon />}
127
+ />
132
128
  )
133
129
  )}
134
- <button
135
- type="button"
130
+ <IconAction
131
+ label={copied ? "Copied" : isCopying ? "Copying…" : "Copy plan"}
132
+ onClick={() => copy(artifact.storageKey)}
133
+ disabled={isCopying}
134
+ icon={copied ? <CheckIcon /> : <CopyIcon />}
135
+ />
136
+ <IconAction
137
+ label={isDownloading ? "Preparing…" : `Download ${artifact.name}`}
136
138
  onClick={() => download(artifact.storageKey, artifact.name)}
137
139
  disabled={isDownloading}
138
- className={cn(
139
- "inline-flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50",
140
- FOCUS_RING,
141
- )}
142
- >
143
- <DownloadIcon />
144
- {isDownloading ? "Preparing…" : "Download"}
145
- </button>
140
+ icon={<DownloadIcon />}
141
+ />
146
142
  {onImplement && (
147
143
  <button
148
144
  type="button"
149
145
  disabled={disabled}
150
146
  onClick={onImplement}
151
147
  className={cn(
152
- "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5",
148
+ "ml-1.5 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5",
153
149
  "text-xs font-medium transition-colors",
154
150
  "bg-primary text-primary-foreground hover:bg-primary-hover",
155
151
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
@@ -157,13 +153,18 @@ export const PlanArtifactCard = memo(function PlanArtifactCard({
157
153
  )}
158
154
  >
159
155
  <ImplementIcon />
160
- {buildPending ? "Starting build…" : "Build from plan"}
156
+ {buildPending ? "Starting build…" : "Build"}
161
157
  </button>
162
158
  )}
163
159
  </div>
164
160
 
165
- {/* "Open full" reuses the shared artifact preview popup — the single place
166
- that fetches/renders plan content (with Copy + Source/Rendered). */}
161
+ <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
162
+ {copied && "Plan copied to clipboard"}
163
+ </div>
164
+
165
+ {/* The modal open fallback reuses the shared artifact preview popup —
166
+ the single place that fetches/renders plan content (with Copy +
167
+ Source/Rendered). */}
167
168
  {org && previewOpen && (
168
169
  <ArtifactPreviewModal
169
170
  artifact={artifact}
@@ -182,6 +183,41 @@ export const PlanArtifactCard = memo(function PlanArtifactCard({
182
183
  const FOCUS_RING =
183
184
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-sm";
184
185
 
186
+ /**
187
+ * An icon-only secondary action. The accessible name doubles as the hover
188
+ * tooltip (native `title` — the codebase's tooltip pattern), and the padded
189
+ * hit area keeps the target comfortable despite the compact glyph.
190
+ */
191
+ function IconAction({
192
+ label,
193
+ onClick,
194
+ icon,
195
+ disabled,
196
+ }: {
197
+ readonly label: string;
198
+ readonly onClick: () => void;
199
+ readonly icon: ReactNode;
200
+ readonly disabled?: boolean;
201
+ }) {
202
+ return (
203
+ <button
204
+ type="button"
205
+ aria-label={label}
206
+ title={label}
207
+ onClick={onClick}
208
+ disabled={disabled}
209
+ className={cn(
210
+ "inline-flex items-center rounded-md p-1.5 text-muted-foreground",
211
+ "transition-colors hover:bg-muted hover:text-foreground",
212
+ "disabled:pointer-events-none disabled:opacity-50",
213
+ FOCUS_RING,
214
+ )}
215
+ >
216
+ {icon}
217
+ </button>
218
+ );
219
+ }
220
+
185
221
  function PlanIcon() {
186
222
  return (
187
223
  <svg
@@ -223,8 +259,8 @@ function ImplementIcon() {
223
259
  function DownloadIcon() {
224
260
  return (
225
261
  <svg
226
- width="10"
227
- height="10"
262
+ width="13"
263
+ height="13"
228
264
  viewBox="0 0 12 12"
229
265
  fill="none"
230
266
  stroke="currentColor"
@@ -241,21 +277,63 @@ function DownloadIcon() {
241
277
  );
242
278
  }
243
279
 
244
- function ExpandIcon() {
280
+ function CopyIcon() {
281
+ return (
282
+ <svg
283
+ width="13"
284
+ height="13"
285
+ viewBox="0 0 12 12"
286
+ fill="none"
287
+ stroke="currentColor"
288
+ strokeWidth="1.2"
289
+ strokeLinecap="round"
290
+ strokeLinejoin="round"
291
+ className="shrink-0"
292
+ aria-hidden="true"
293
+ >
294
+ <rect x="4" y="4" width="6.5" height="6.5" rx="1" />
295
+ <path d="M8 4V2.5C8 1.95 7.55 1.5 7 1.5H2.5C1.95 1.5 1.5 1.95 1.5 2.5V7C1.5 7.55 1.95 8 2.5 8H4" />
296
+ </svg>
297
+ );
298
+ }
299
+
300
+ /** A check glyph — transient confirmation that the copy succeeded. */
301
+ function CheckIcon() {
245
302
  return (
246
303
  <svg
247
- width="10"
248
- height="10"
304
+ width="13"
305
+ height="13"
249
306
  viewBox="0 0 12 12"
250
307
  fill="none"
251
308
  stroke="currentColor"
252
309
  strokeWidth="1.5"
253
310
  strokeLinecap="round"
254
311
  strokeLinejoin="round"
312
+ className="shrink-0 text-success"
313
+ aria-hidden="true"
314
+ >
315
+ <path d="M2 6.5L4.5 9L10 3" />
316
+ </svg>
317
+ );
318
+ }
319
+
320
+ /** An eye glyph — "view/open the plan to read it". */
321
+ function EyeIcon() {
322
+ return (
323
+ <svg
324
+ width="13"
325
+ height="13"
326
+ viewBox="0 0 16 16"
327
+ fill="none"
328
+ stroke="currentColor"
329
+ strokeWidth="1.5"
330
+ strokeLinecap="round"
331
+ strokeLinejoin="round"
255
332
  className="shrink-0"
256
333
  aria-hidden="true"
257
334
  >
258
- <path d="M7 1.5h3.5V5M10.5 1.5L6.5 5.5M5 10.5H1.5V7M1.5 10.5l4-4" />
335
+ <path d="M1.5 8S4 3.5 8 3.5s6.5 4.5 6.5 4.5-2.5 4.5-6.5 4.5S1.5 8 1.5 8Z" />
336
+ <circle cx="8" cy="8" r="1.75" />
259
337
  </svg>
260
338
  );
261
339
  }
@@ -6,7 +6,7 @@ import { useBuildFromPlanHotkey } from "./use-build-from-plan-hotkey.js";
6
6
 
7
7
  /** Props for {@link PlanCompletionCard}. */
8
8
  export interface PlanCompletionCardProps {
9
- /** Called when the user clicks the "Build from plan" button. */
9
+ /** Called when the user clicks the "Build" button. */
10
10
  readonly onImplement?: () => void;
11
11
  /** Disables the CTA button (e.g., while an execution is active). */
12
12
  readonly disabled?: boolean;
@@ -20,9 +20,9 @@ export interface PlanCompletionCardProps {
20
20
  * a plan whose upload failed). When a plan artifact exists, the richer
21
21
  * `PlanArtifactCard` is shown instead.
22
22
  *
23
- * Mirrors `PlanArtifactCard`'s hierarchy — one prominent, themeable "Build from
24
- * plan" primary action with the same card-scoped `Cmd/Ctrl+Enter` accelerator —
25
- * so the two cards feel like the same affordance. The consumer wires the action
23
+ * Mirrors `PlanArtifactCard`'s hierarchy — one prominent, themeable "Build"
24
+ * primary action with the same card-scoped `Cmd/Ctrl+Enter` accelerator — so
25
+ * the two cards feel like the same affordance. The consumer wires the action
26
26
  * to switch the interaction mode to Agent and submit the implement turn.
27
27
  *
28
28
  * Renders nothing when `onImplement` is not provided, so the card is fully
@@ -67,7 +67,7 @@ export const PlanCompletionCard = memo(function PlanCompletionCard({
67
67
  )}
68
68
  >
69
69
  <ImplementIcon />
70
- Build from plan
70
+ Build
71
71
  </button>
72
72
  </div>
73
73
  );
@@ -2,7 +2,6 @@
2
2
 
3
3
  import { memo } from "react";
4
4
  import { cn } from "@stigmer/theme";
5
- import { PLAN_ARTIFACT_NAME } from "../library/detect-plan-artifact.js";
6
5
  import { formatArtifactSize } from "./artifact-utils.js";
7
6
 
8
7
  /** Props for {@link PlanStreamingCard}. */
@@ -34,8 +33,13 @@ export interface PlanStreamingCardProps {
34
33
  * The live counterpart of {@link PlanArtifactCard} — a streaming Plan turn's
35
34
  * compact stand-in in the thread while the plan document renders live in the
36
35
  * session panel's plan tab. Same anatomy as the completed card (icon, title,
37
- * `plan.md · size` meta, trailing actions) so the completion handoff reads as
38
- * the same card settling, not a new element appearing.
36
+ * a `Writing… · size` meta, trailing actions) so the completion handoff reads
37
+ * as the same card settling, not a new element appearing.
38
+ *
39
+ * The meta line deliberately omits a filename: the artifact's name is derived
40
+ * from the plan's title, which is still streaming in, so a name shown here
41
+ * would change under the user when the plan settles. The completed card
42
+ * introduces the final `<slug>_<id>.plan.md` name once it exists.
39
43
  *
40
44
  * Deliberately action-light: "Open plan" is the only affordance. Download and
41
45
  * Build require the published artifact, which does not exist until the turn
@@ -72,8 +76,6 @@ export const PlanStreamingCard = memo(function PlanStreamingCard({
72
76
  {title ?? "Writing plan…"}
73
77
  </div>
74
78
  <div className="text-[0.65rem] text-muted-foreground-faint">
75
- {PLAN_ARTIFACT_NAME}
76
- <span aria-hidden="true"> · </span>
77
79
  <span role="status">Writing…</span>
78
80
  <span aria-hidden="true"> · </span>
79
81
  <span className="tabular-nums">{formatArtifactSize(sizeBytes)}</span>
@@ -89,7 +91,7 @@ export const PlanStreamingCard = memo(function PlanStreamingCard({
89
91
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-sm",
90
92
  )}
91
93
  >
92
- <ExpandIcon />
94
+ <EyeIcon />
93
95
  Open plan
94
96
  </button>
95
97
  )}
@@ -117,12 +119,13 @@ function PlanIcon({ className }: { readonly className?: string }) {
117
119
  );
118
120
  }
119
121
 
120
- function ExpandIcon() {
122
+ /** An eye glyph — "view/open the plan to read it" (matches PlanArtifactCard). */
123
+ function EyeIcon() {
121
124
  return (
122
125
  <svg
123
- width="10"
124
- height="10"
125
- viewBox="0 0 12 12"
126
+ width="11"
127
+ height="11"
128
+ viewBox="0 0 16 16"
126
129
  fill="none"
127
130
  stroke="currentColor"
128
131
  strokeWidth="1.5"
@@ -131,7 +134,8 @@ function ExpandIcon() {
131
134
  className="shrink-0"
132
135
  aria-hidden="true"
133
136
  >
134
- <path d="M7 1.5h3.5V5M10.5 1.5L6.5 5.5M5 10.5H1.5V7M1.5 10.5l4-4" />
137
+ <path d="M1.5 8S4 3.5 8 3.5s6.5 4.5 6.5 4.5-2.5 4.5-6.5 4.5S1.5 8 1.5 8Z" />
138
+ <circle cx="8" cy="8" r="1.75" />
135
139
  </svg>
136
140
  );
137
141
  }
@@ -26,8 +26,8 @@ function withStigmer(children: ReactNode) {
26
26
 
27
27
  afterEach(cleanup);
28
28
 
29
- describe("ArtifactPreviewContent — Build from plan action", () => {
30
- it("renders a 'Build from plan' button only when onImplement is provided", () => {
29
+ describe("ArtifactPreviewContent — Build action", () => {
30
+ it("renders a 'Build' button only when onImplement is provided", () => {
31
31
  const { rerender } = render(
32
32
  withStigmer(
33
33
  <ArtifactPreviewContent
@@ -39,7 +39,7 @@ describe("ArtifactPreviewContent — Build from plan action", () => {
39
39
  />,
40
40
  ),
41
41
  );
42
- expect(screen.queryByText("Build from plan")).toBeNull();
42
+ expect(screen.queryByText("Build")).toBeNull();
43
43
 
44
44
  rerender(
45
45
  withStigmer(
@@ -53,10 +53,10 @@ describe("ArtifactPreviewContent — Build from plan action", () => {
53
53
  />,
54
54
  ),
55
55
  );
56
- expect(screen.getByText("Build from plan")).toBeTruthy();
56
+ expect(screen.getByText("Build")).toBeTruthy();
57
57
  });
58
58
 
59
- it("calls onImplement then closes the modal when 'Build from plan' is clicked", () => {
59
+ it("calls onImplement then closes the modal when 'Build' is clicked", () => {
60
60
  const calls: string[] = [];
61
61
  const onImplement = vi.fn(() => calls.push("implement"));
62
62
  const onClose = vi.fn(() => calls.push("close"));
@@ -74,7 +74,7 @@ describe("ArtifactPreviewContent — Build from plan action", () => {
74
74
  ),
75
75
  );
76
76
 
77
- fireEvent.click(screen.getByText("Build from plan"));
77
+ fireEvent.click(screen.getByText("Build"));
78
78
 
79
79
  expect(onImplement).toHaveBeenCalledTimes(1);
80
80
  expect(onClose).toHaveBeenCalledTimes(1);