@stigmer/react 3.1.7 → 3.1.8

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 (195) hide show
  1. package/agent/AgentDetailView.d.ts +9 -1
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +15 -4
  4. package/agent/AgentDetailView.js.map +1 -1
  5. package/environment/EnvironmentListPanel.d.ts.map +1 -1
  6. package/environment/EnvironmentListPanel.js +11 -3
  7. package/environment/EnvironmentListPanel.js.map +1 -1
  8. package/environment/EnvironmentVariableEditor.d.ts.map +1 -1
  9. package/environment/EnvironmentVariableEditor.js +9 -3
  10. package/environment/EnvironmentVariableEditor.js.map +1 -1
  11. package/environment/index.d.ts +1 -0
  12. package/environment/index.d.ts.map +1 -1
  13. package/environment/index.js +1 -0
  14. package/environment/index.js.map +1 -1
  15. package/environment/shareRestriction.d.ts +13 -0
  16. package/environment/shareRestriction.d.ts.map +1 -0
  17. package/environment/shareRestriction.js +15 -0
  18. package/environment/shareRestriction.js.map +1 -0
  19. package/execution/ToolCallItem.d.ts.map +1 -1
  20. package/execution/ToolCallItem.js +12 -0
  21. package/execution/ToolCallItem.js.map +1 -1
  22. package/execution/ToolRunGroup.js +4 -0
  23. package/execution/ToolRunGroup.js.map +1 -1
  24. package/execution/WriteBackCard.d.ts +14 -5
  25. package/execution/WriteBackCard.d.ts.map +1 -1
  26. package/execution/WriteBackCard.js +103 -32
  27. package/execution/WriteBackCard.js.map +1 -1
  28. package/execution/WriteBacksWidget.d.ts +1 -1
  29. package/execution/WriteBacksWidget.js +2 -2
  30. package/execution/WriteBacksWidget.js.map +1 -1
  31. package/execution/index.d.ts +2 -0
  32. package/execution/index.d.ts.map +1 -1
  33. package/execution/index.js +1 -0
  34. package/execution/index.js.map +1 -1
  35. package/execution/write-back-utils.d.ts +57 -0
  36. package/execution/write-back-utils.d.ts.map +1 -0
  37. package/execution/write-back-utils.js +81 -0
  38. package/execution/write-back-utils.js.map +1 -0
  39. package/index.d.ts +6 -2
  40. package/index.d.ts.map +1 -1
  41. package/index.js +6 -1
  42. package/index.js.map +1 -1
  43. package/internal/dev/dom-counter.d.ts.map +1 -1
  44. package/internal/dev/dom-counter.js +2 -2
  45. package/internal/dev/dom-counter.js.map +1 -1
  46. package/internal/dev/enabled.d.ts +7 -0
  47. package/internal/dev/enabled.d.ts.map +1 -0
  48. package/internal/dev/enabled.js +38 -0
  49. package/internal/dev/enabled.js.map +1 -0
  50. package/internal/dev/profiler-wrapper.d.ts +3 -3
  51. package/internal/dev/profiler-wrapper.d.ts.map +1 -1
  52. package/internal/dev/profiler-wrapper.js +5 -5
  53. package/internal/dev/profiler-wrapper.js.map +1 -1
  54. package/internal/dev/use-key-stability.d.ts.map +1 -1
  55. package/internal/dev/use-key-stability.js +2 -2
  56. package/internal/dev/use-key-stability.js.map +1 -1
  57. package/internal/dev/use-render-tracer.d.ts +2 -2
  58. package/internal/dev/use-render-tracer.d.ts.map +1 -1
  59. package/internal/dev/use-render-tracer.js +4 -4
  60. package/internal/dev/use-render-tracer.js.map +1 -1
  61. package/internal/dev/use-stream-rate.d.ts +2 -1
  62. package/internal/dev/use-stream-rate.d.ts.map +1 -1
  63. package/internal/dev/use-stream-rate.js +4 -3
  64. package/internal/dev/use-stream-rate.js.map +1 -1
  65. package/library/ResourceVisibilityControl.d.ts +3 -0
  66. package/library/ResourceVisibilityControl.d.ts.map +1 -1
  67. package/library/ResourceVisibilityControl.js +22 -8
  68. package/library/ResourceVisibilityControl.js.map +1 -1
  69. package/library/useUpdateVisibility.d.ts +5 -3
  70. package/library/useUpdateVisibility.d.ts.map +1 -1
  71. package/library/useUpdateVisibility.js +7 -2
  72. package/library/useUpdateVisibility.js.map +1 -1
  73. package/library/visibilityLevels.d.ts +17 -0
  74. package/library/visibilityLevels.d.ts.map +1 -1
  75. package/library/visibilityLevels.js +32 -0
  76. package/library/visibilityLevels.js.map +1 -1
  77. package/package.json +4 -4
  78. package/session/NewSessionViewer.d.ts +11 -1
  79. package/session/NewSessionViewer.d.ts.map +1 -1
  80. package/session/NewSessionViewer.js +28 -8
  81. package/session/NewSessionViewer.js.map +1 -1
  82. package/session/SessionViewer.d.ts +9 -2
  83. package/session/SessionViewer.d.ts.map +1 -1
  84. package/session/SessionViewer.js +26 -12
  85. package/session/SessionViewer.js.map +1 -1
  86. package/session/audience.d.ts +12 -4
  87. package/session/audience.d.ts.map +1 -1
  88. package/session/facets/ChangesTab.d.ts.map +1 -1
  89. package/session/facets/ChangesTab.js +3 -1
  90. package/session/facets/ChangesTab.js.map +1 -1
  91. package/session/useNewSessionFlow.d.ts +12 -0
  92. package/session/useNewSessionFlow.d.ts.map +1 -1
  93. package/session/useNewSessionFlow.js +13 -1
  94. package/session/useNewSessionFlow.js.map +1 -1
  95. package/session/useSessionPageFlow.d.ts +13 -0
  96. package/session/useSessionPageFlow.d.ts.map +1 -1
  97. package/session/useSessionPageFlow.js +7 -2
  98. package/session/useSessionPageFlow.js.map +1 -1
  99. package/sharing/ShareAgentDialog.d.ts +55 -0
  100. package/sharing/ShareAgentDialog.d.ts.map +1 -0
  101. package/sharing/ShareAgentDialog.js +393 -0
  102. package/sharing/ShareAgentDialog.js.map +1 -0
  103. package/sharing/SharedAgentChat.d.ts +84 -0
  104. package/sharing/SharedAgentChat.d.ts.map +1 -0
  105. package/sharing/SharedAgentChat.js +80 -0
  106. package/sharing/SharedAgentChat.js.map +1 -0
  107. package/sharing/index.d.ts +16 -0
  108. package/sharing/index.d.ts.map +1 -0
  109. package/sharing/index.js +11 -0
  110. package/sharing/index.js.map +1 -0
  111. package/sharing/useRotateShareLink.d.ts +42 -0
  112. package/sharing/useRotateShareLink.d.ts.map +1 -0
  113. package/sharing/useRotateShareLink.js +55 -0
  114. package/sharing/useRotateShareLink.js.map +1 -0
  115. package/sharing/useShareAgent.d.ts +68 -0
  116. package/sharing/useShareAgent.d.ts.map +1 -0
  117. package/sharing/useShareAgent.js +41 -0
  118. package/sharing/useShareAgent.js.map +1 -0
  119. package/sharing/useShareToolReadiness.d.ts +48 -0
  120. package/sharing/useShareToolReadiness.d.ts.map +1 -0
  121. package/sharing/useShareToolReadiness.js +93 -0
  122. package/sharing/useShareToolReadiness.js.map +1 -0
  123. package/sharing/useSharedAgentProfile.d.ts +84 -0
  124. package/sharing/useSharedAgentProfile.d.ts.map +1 -0
  125. package/sharing/useSharedAgentProfile.js +72 -0
  126. package/sharing/useSharedAgentProfile.js.map +1 -0
  127. package/sharing/useUpdateAgentSharing.d.ts +86 -0
  128. package/sharing/useUpdateAgentSharing.d.ts.map +1 -0
  129. package/sharing/useUpdateAgentSharing.js +81 -0
  130. package/sharing/useUpdateAgentSharing.js.map +1 -0
  131. package/src/agent/AgentDetailView.tsx +28 -3
  132. package/src/environment/EnvironmentListPanel.tsx +47 -18
  133. package/src/environment/EnvironmentVariableEditor.tsx +27 -17
  134. package/src/environment/__tests__/shareRestriction.test.ts +35 -0
  135. package/src/environment/index.ts +5 -0
  136. package/src/environment/shareRestriction.ts +22 -0
  137. package/src/execution/ToolCallItem.tsx +19 -1
  138. package/src/execution/ToolRunGroup.tsx +4 -0
  139. package/src/execution/WriteBackCard.tsx +285 -88
  140. package/src/execution/WriteBacksWidget.tsx +5 -5
  141. package/src/execution/__tests__/ToolCallItem.test.tsx +51 -0
  142. package/src/execution/__tests__/ToolRunGroup.test.tsx +22 -0
  143. package/src/execution/__tests__/WriteBackCard.test.tsx +64 -10
  144. package/src/execution/__tests__/write-back-utils.test.ts +135 -0
  145. package/src/execution/index.ts +7 -0
  146. package/src/execution/write-back-utils.ts +107 -0
  147. package/src/index.ts +34 -0
  148. package/src/internal/dev/__tests__/use-key-stability.test.ts +4 -0
  149. package/src/internal/dev/__tests__/use-render-tracer.test.ts +4 -0
  150. package/src/internal/dev/dom-counter.ts +2 -3
  151. package/src/internal/dev/enabled.ts +39 -0
  152. package/src/internal/dev/profiler-wrapper.tsx +5 -6
  153. package/src/internal/dev/use-key-stability.ts +2 -3
  154. package/src/internal/dev/use-render-tracer.ts +4 -5
  155. package/src/internal/dev/use-stream-rate.ts +4 -4
  156. package/src/library/ResourceVisibilityControl.tsx +23 -7
  157. package/src/library/__tests__/visibilityLevels.test.ts +33 -0
  158. package/src/library/useUpdateVisibility.ts +9 -3
  159. package/src/library/visibilityLevels.ts +36 -0
  160. package/src/session/NewSessionViewer.tsx +67 -31
  161. package/src/session/SessionViewer.tsx +67 -36
  162. package/src/session/__tests__/audienceWiring.test.tsx +99 -0
  163. package/src/session/__tests__/useNewSessionFlow.test.tsx +75 -1
  164. package/src/session/__tests__/useSessionPageFlow.guest.test.tsx +112 -0
  165. package/src/session/audience.ts +12 -4
  166. package/src/session/facets/ChangesTab.tsx +6 -4
  167. package/src/session/useNewSessionFlow.ts +26 -1
  168. package/src/session/useSessionPageFlow.ts +24 -2
  169. package/src/sharing/ShareAgentDialog.tsx +1255 -0
  170. package/src/sharing/SharedAgentChat.tsx +283 -0
  171. package/src/sharing/__tests__/ShareAgentDialog.test.tsx +745 -0
  172. package/src/sharing/__tests__/SharedAgentChat.test.tsx +222 -0
  173. package/src/sharing/__tests__/useRotateShareLink.test.tsx +96 -0
  174. package/src/sharing/__tests__/useShareAgent.test.tsx +127 -0
  175. package/src/sharing/__tests__/useShareToolReadiness.test.tsx +182 -0
  176. package/src/sharing/__tests__/useSharedAgentProfile.test.tsx +201 -0
  177. package/src/sharing/__tests__/useUpdateAgentSharing.test.tsx +200 -0
  178. package/src/sharing/index.ts +30 -0
  179. package/src/sharing/useRotateShareLink.ts +76 -0
  180. package/src/sharing/useShareAgent.tsx +106 -0
  181. package/src/sharing/useShareToolReadiness.ts +129 -0
  182. package/src/sharing/useSharedAgentProfile.ts +131 -0
  183. package/src/sharing/useUpdateAgentSharing.ts +151 -0
  184. package/src/switch/Switch.tsx +92 -0
  185. package/src/switch/__tests__/Switch.test.tsx +88 -0
  186. package/src/switch/index.ts +2 -0
  187. package/styles.css +1 -1
  188. package/switch/Switch.d.ts +47 -0
  189. package/switch/Switch.d.ts.map +1 -0
  190. package/switch/Switch.js +36 -0
  191. package/switch/Switch.js.map +1 -0
  192. package/switch/index.d.ts +3 -0
  193. package/switch/index.d.ts.map +1 -0
  194. package/switch/index.js +2 -0
  195. package/switch/index.js.map +1 -0
@@ -1,8 +1,15 @@
1
1
  "use client";
2
2
 
3
+ import { useCallback, useEffect, useRef, useState } from "react";
3
4
  import type { WorkspaceWriteBack } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
4
5
  import { WorkspaceWriteBackPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/writeback_pb";
5
6
  import { cn } from "@stigmer/theme";
7
+ import { DiffSummary } from "../version-history/DiffSummary.js";
8
+ import {
9
+ parseDiffStatSummary,
10
+ trailingDiffStatLine,
11
+ writeBackDisplayName,
12
+ } from "./write-back-utils.js";
6
13
 
7
14
  /** Props for {@link WriteBackCard}. */
8
15
  export interface WriteBackCardProps {
@@ -13,11 +20,15 @@ export interface WriteBackCardProps {
13
20
  }
14
21
 
15
22
  /**
16
- * Renders a single workspace write-back outcome as a compact card.
23
+ * Renders a single workspace write-back outcome as a dense, VS Code
24
+ * Source-Control-style row group: a header (workspace name + quiet phase
25
+ * caption), a pull-request row that opens the PR on the remote, a branch row
26
+ * with a hover-revealed copy control, and an aggregate change-stat line.
17
27
  *
18
- * Shows the workspace entry name, branch, diff summary, phase
19
- * indicator, and a "View PR" link when the pull request was
20
- * successfully created.
28
+ * Row geometry, hover, and focus treatment match {@link ArtifactRow}, so the
29
+ * session panel's Changes and Artifacts facets read as one surface. The PR
30
+ * link and the copy control are SIBLING interactive elements, never nested
31
+ * (axe `nested-interactive`, WCAG 4.1.2).
21
32
  *
22
33
  * Error rendering follows the record's phase honestly (DD-006):
23
34
  * a FAILED record's error is destructive (the work did not reach the
@@ -25,8 +36,12 @@ export interface WriteBackCardProps {
25
36
  * degraded notice — the branch is live and its info stays usable; only
26
37
  * the PR step failed.
27
38
  *
39
+ * The stat line is parsed from the record's `git diff --stat` summary and
40
+ * rendered through the shared {@link DiffSummary} component; unparseable
41
+ * text degrades to the raw trailing line rather than showing wrong numbers.
42
+ *
28
43
  * Themed via standard semantic tokens — no hardcoded colors, no
29
- * Console dependencies. Embedders can theme this card through
44
+ * Console dependencies. Embedders can theme this component through
30
45
  * their Tailwind/CSS configuration.
31
46
  *
32
47
  * @example
@@ -42,53 +57,49 @@ export interface WriteBackCardProps {
42
57
  * ```
43
58
  *
44
59
  * @see useWorkspaceWriteBacks — extracts write-back data from an execution
60
+ * @see writeBackDisplayName — the header's entry-name fallback chain
45
61
  */
46
62
  export function WriteBackCard({ writeBack, className }: WriteBackCardProps) {
47
63
  const isFailed =
48
64
  writeBack.phase === WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED;
49
65
  const hasPR = !!writeBack.pullRequestUrl;
66
+ const displayName = writeBackDisplayName(writeBack);
50
67
 
51
68
  return (
52
69
  <div
53
70
  role="article"
54
- aria-label={`Write-back: ${writeBack.workspaceEntryName}`}
55
- className={cn(
56
- "rounded-md border p-3",
57
- isFailed ? "border-destructive/40" : "border-border",
58
- className,
59
- )}
71
+ aria-label={`Write-back: ${displayName}`}
72
+ className={cn("flex flex-col", className)}
60
73
  >
61
- {/* Header: icon + workspace name + phase badge */}
62
- <div className="flex items-start gap-2">
63
- <span className="mt-0.5 shrink-0 text-muted-foreground">
64
- <GitBranchIcon />
74
+ {/* Header: workspace name + quiet phase caption */}
75
+ <div className="flex items-center gap-2 px-2 py-1">
76
+ <span
77
+ className="min-w-0 flex-1 truncate text-xs font-medium text-foreground"
78
+ title={displayName}
79
+ >
80
+ {displayName}
65
81
  </span>
66
- <div className="min-w-0 flex-1">
67
- <div className="flex items-center gap-2">
68
- <span className="truncate text-sm font-medium text-foreground">
69
- {writeBack.workspaceEntryName}
70
- </span>
71
- <PhaseBadge phase={writeBack.phase} />
72
- </div>
73
- {writeBack.branchName && (
74
- <div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
75
- {writeBack.branchName}
76
- {writeBack.baseBranch && (
77
- <span className="text-muted-foreground-subtle">
78
- {" \u2190 "}
79
- {writeBack.baseBranch}
80
- </span>
81
- )}
82
- </div>
83
- )}
84
- </div>
82
+ <PhaseCaption
83
+ phase={writeBack.phase}
84
+ pullRequestNumber={writeBack.pullRequestNumber}
85
+ />
85
86
  </div>
86
87
 
87
- {/* Diff summary */}
88
- {writeBack.diffSummary && (
89
- <div className="mt-1.5 whitespace-pre-wrap font-mono text-xs text-muted-foreground">
90
- {writeBack.diffSummary}
91
- </div>
88
+ {/* Pull-request row — the primary action when the PR exists */}
89
+ {hasPR && (
90
+ <PullRequestRow
91
+ url={writeBack.pullRequestUrl}
92
+ number={writeBack.pullRequestNumber}
93
+ />
94
+ )}
95
+
96
+ {/* Branch row — primary when no PR was created; the branch is still
97
+ the user's handle on the pushed work */}
98
+ {writeBack.branchName && (
99
+ <BranchRow
100
+ branchName={writeBack.branchName}
101
+ baseBranch={writeBack.baseBranch}
102
+ />
92
103
  )}
93
104
 
94
105
  {/* Error message: destructive when the write-back itself failed,
@@ -96,7 +107,7 @@ export function WriteBackCard({ writeBack, className }: WriteBackCardProps) {
96
107
  {writeBack.error && (
97
108
  <div
98
109
  className={cn(
99
- "mt-1.5 rounded px-2 py-1 text-xs",
110
+ "mx-2 my-1 rounded px-2 py-1 text-xs",
100
111
  isFailed
101
112
  ? "bg-destructive-subtle text-destructive"
102
113
  : "bg-status-degraded-subtle text-status-degraded",
@@ -106,78 +117,202 @@ export function WriteBackCard({ writeBack, className }: WriteBackCardProps) {
106
117
  </div>
107
118
  )}
108
119
 
109
- {/* PR link */}
110
- {hasPR && (
111
- <div className="mt-2">
112
- <a
113
- href={writeBack.pullRequestUrl}
114
- target="_blank"
115
- rel="noopener noreferrer"
116
- className={cn(
117
- "inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:text-primary-muted",
118
- FOCUS_RING_CLASSES,
119
- )}
120
- >
121
- <ExternalLinkIcon />
122
- View PR #{writeBack.pullRequestNumber}
123
- </a>
124
- </div>
125
- )}
120
+ {/* Aggregate change stats */}
121
+ {writeBack.diffSummary && <StatLine diffSummary={writeBack.diffSummary} />}
126
122
  </div>
127
123
  );
128
124
  }
129
125
 
130
126
  // ---------------------------------------------------------------------------
131
- // Phase badge
127
+ // Phase caption — quiet header text, replacing the retired pill badge
132
128
  // ---------------------------------------------------------------------------
133
129
 
134
- const PHASE_CONFIG: Record<
135
- number,
136
- { label: string; className: string }
137
- > = {
138
- [WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_COMMITTED]: {
139
- label: "Committed",
140
- className: "bg-muted text-muted-foreground",
141
- },
142
- [WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED]: {
143
- label: "Pushed",
144
- className: "bg-muted text-muted-foreground",
145
- },
146
- [WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED]: {
147
- label: "PR Created",
148
- className: "bg-primary-subtle text-primary",
149
- },
150
- [WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED]: {
151
- label: "Failed",
152
- className: "bg-destructive-subtle text-destructive",
153
- },
154
- };
155
-
156
- function PhaseBadge({ phase }: { phase: number }) {
157
- const config = PHASE_CONFIG[phase];
158
- if (!config) return null;
130
+ function PhaseCaption({
131
+ phase,
132
+ pullRequestNumber,
133
+ }: {
134
+ phase: WorkspaceWriteBackPhase;
135
+ pullRequestNumber: number;
136
+ }) {
137
+ let label: string;
138
+ switch (phase) {
139
+ case WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_COMMITTED:
140
+ label = "Committed";
141
+ break;
142
+ case WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PUSHED:
143
+ label = "Pushed";
144
+ break;
145
+ case WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED:
146
+ label = pullRequestNumber > 0 ? `PR #${pullRequestNumber}` : "PR created";
147
+ break;
148
+ case WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED:
149
+ label = "Failed";
150
+ break;
151
+ default:
152
+ return null;
153
+ }
159
154
 
160
155
  return (
161
156
  <span
162
157
  className={cn(
163
- "inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
164
- config.className,
158
+ "shrink-0 text-[10px] font-medium tabular-nums",
159
+ phase === WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_FAILED
160
+ ? "text-destructive"
161
+ : "text-muted-foreground",
165
162
  )}
166
163
  >
167
- {config.label}
164
+ {label}
168
165
  </span>
169
166
  );
170
167
  }
171
168
 
169
+ // ---------------------------------------------------------------------------
170
+ // Pull-request row
171
+ // ---------------------------------------------------------------------------
172
+
173
+ function PullRequestRow({ url, number }: { url: string; number: number }) {
174
+ return (
175
+ <a
176
+ href={url}
177
+ target="_blank"
178
+ rel="noopener noreferrer"
179
+ className={cn(
180
+ "group flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground transition-colors",
181
+ "hover:bg-muted hover:text-foreground",
182
+ FOCUS_RING_CLASSES,
183
+ )}
184
+ >
185
+ <span className="shrink-0">
186
+ <PullRequestIcon />
187
+ </span>
188
+ <span className="min-w-0 flex-1 truncate text-foreground">
189
+ {number > 0 ? `Pull Request #${number}` : "Pull Request"}
190
+ </span>
191
+ <span
192
+ className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
193
+ aria-hidden="true"
194
+ >
195
+ <ExternalLinkIcon />
196
+ </span>
197
+ </a>
198
+ );
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Branch row — info + sibling hover-revealed copy control
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** How long the copy control shows its "copied" confirmation. */
206
+ const COPIED_FEEDBACK_MS = 2000;
207
+
208
+ function BranchRow({
209
+ branchName,
210
+ baseBranch,
211
+ }: {
212
+ branchName: string;
213
+ baseBranch: string;
214
+ }) {
215
+ const [copied, setCopied] = useState(false);
216
+
217
+ // Clear the pending feedback timer on unmount so a resolved copy never
218
+ // sets state on an unmounted component (same guard as useArtifactCopy).
219
+ const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
220
+ useEffect(() => {
221
+ return () => {
222
+ if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
223
+ };
224
+ }, []);
225
+
226
+ const handleCopy = useCallback(() => {
227
+ navigator.clipboard.writeText(branchName).then(() => {
228
+ setCopied(true);
229
+ if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
230
+ copiedTimer.current = setTimeout(
231
+ () => setCopied(false),
232
+ COPIED_FEEDBACK_MS,
233
+ );
234
+ });
235
+ }, [branchName]);
236
+
237
+ return (
238
+ <div className="group flex items-stretch">
239
+ <div
240
+ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1 text-xs"
241
+ title={baseBranch ? `${branchName} \u2190 ${baseBranch}` : branchName}
242
+ >
243
+ <span className="shrink-0 text-muted-foreground">
244
+ <GitBranchIcon />
245
+ </span>
246
+ <span className="min-w-0 truncate font-mono text-foreground">
247
+ {branchName}
248
+ </span>
249
+ {baseBranch && (
250
+ <span className="shrink-0 font-mono text-muted-foreground-faint">
251
+ {"\u2190 "}
252
+ {baseBranch}
253
+ </span>
254
+ )}
255
+ </div>
256
+ <button
257
+ type="button"
258
+ onClick={handleCopy}
259
+ aria-label={copied ? "Branch name copied" : `Copy branch name ${branchName}`}
260
+ title={copied ? "Copied" : "Copy branch name"}
261
+ className={cn(
262
+ "flex shrink-0 items-center px-2 text-muted-foreground transition-opacity",
263
+ copied
264
+ ? "opacity-100"
265
+ : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
266
+ "hover:text-foreground",
267
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
268
+ )}
269
+ >
270
+ {copied ? <CheckIcon /> : <CopyIcon />}
271
+ </button>
272
+ </div>
273
+ );
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Stat line — parsed --stat summary through the shared DiffSummary
278
+ // ---------------------------------------------------------------------------
279
+
280
+ function StatLine({ diffSummary }: { diffSummary: string }) {
281
+ const stats = parseDiffStatSummary(diffSummary);
282
+
283
+ if (stats) {
284
+ return (
285
+ <DiffSummary
286
+ fileCount={stats.filesChanged}
287
+ additions={stats.insertions}
288
+ deletions={stats.deletions}
289
+ className="px-2 py-1"
290
+ />
291
+ );
292
+ }
293
+
294
+ // Unparseable text (format drift): the raw trailing line is still honest;
295
+ // structured-looking wrong numbers would not be.
296
+ const raw = trailingDiffStatLine(diffSummary);
297
+ if (!raw) return null;
298
+
299
+ return (
300
+ <div className="px-2 py-1 font-mono text-xs text-muted-foreground">
301
+ {raw}
302
+ </div>
303
+ );
304
+ }
305
+
172
306
  // ---------------------------------------------------------------------------
173
307
  // Shared style constants
174
308
  // ---------------------------------------------------------------------------
175
309
 
176
310
  const FOCUS_RING_CLASSES =
177
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:rounded-sm";
311
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring";
178
312
 
179
313
  // ---------------------------------------------------------------------------
180
- // Inline SVG icons
314
+ // Inline SVG icons — monochrome, `currentColor`-tinted (DD-005; SDK
315
+ // independence — no icon-library dependency)
181
316
  // ---------------------------------------------------------------------------
182
317
 
183
318
  function GitBranchIcon() {
@@ -203,6 +338,29 @@ function GitBranchIcon() {
203
338
  );
204
339
  }
205
340
 
341
+ /** Git pull-request glyph: base branch line joined to the merge commit. */
342
+ function PullRequestIcon() {
343
+ return (
344
+ <svg
345
+ width="14"
346
+ height="14"
347
+ viewBox="0 0 14 14"
348
+ fill="none"
349
+ stroke="currentColor"
350
+ strokeWidth="1.5"
351
+ strokeLinecap="round"
352
+ strokeLinejoin="round"
353
+ aria-hidden="true"
354
+ >
355
+ <circle cx="3.5" cy="3.5" r="1.5" />
356
+ <circle cx="3.5" cy="10.5" r="1.5" />
357
+ <circle cx="10.5" cy="10.5" r="1.5" />
358
+ <path d="M3.5 5V9" />
359
+ <path d="M7 3.5H8.5C9.6 3.5 10.5 4.4 10.5 5.5V9" />
360
+ </svg>
361
+ );
362
+ }
363
+
206
364
  function ExternalLinkIcon() {
207
365
  return (
208
366
  <svg
@@ -223,3 +381,42 @@ function ExternalLinkIcon() {
223
381
  </svg>
224
382
  );
225
383
  }
384
+
385
+ function CopyIcon() {
386
+ return (
387
+ <svg
388
+ width="12"
389
+ height="12"
390
+ viewBox="0 0 12 12"
391
+ fill="none"
392
+ stroke="currentColor"
393
+ strokeWidth="1.5"
394
+ strokeLinecap="round"
395
+ strokeLinejoin="round"
396
+ className="shrink-0"
397
+ aria-hidden="true"
398
+ >
399
+ <rect x="4" y="4" width="6.5" height="6.5" rx="1" />
400
+ <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" />
401
+ </svg>
402
+ );
403
+ }
404
+
405
+ function CheckIcon() {
406
+ return (
407
+ <svg
408
+ width="12"
409
+ height="12"
410
+ viewBox="0 0 12 12"
411
+ fill="none"
412
+ stroke="currentColor"
413
+ strokeWidth="1.5"
414
+ strokeLinecap="round"
415
+ strokeLinejoin="round"
416
+ className="shrink-0"
417
+ aria-hidden="true"
418
+ >
419
+ <path d="M2.5 6.5L5 9L9.5 3.5" />
420
+ </svg>
421
+ );
422
+ }
@@ -49,7 +49,7 @@ export interface WriteBacksWidgetProps {
49
49
  * />
50
50
  * ```
51
51
  *
52
- * @see {@link WriteBackCard} — compact card per write-back
52
+ * @see {@link WriteBackCard} — dense row group per write-back
53
53
  * @see {@link useSessionWriteBacks} — headless session-level write-back aggregation hook
54
54
  * @see {@link useWorkspaceWriteBacks} — headless single-execution write-back extraction hook
55
55
  */
@@ -71,13 +71,13 @@ export function WriteBacksWidget({
71
71
  </span>
72
72
  </div>
73
73
 
74
- <div role="list" className="space-y-2">
74
+ <ul role="list" className="flex flex-col gap-3">
75
75
  {writeBacks.map((entry) => (
76
- <div key={entry.writeBack.workspaceEntryName} role="listitem">
76
+ <li key={entry.writeBack.workspaceEntryName}>
77
77
  <WriteBackCard writeBack={entry.writeBack} />
78
- </div>
78
+ </li>
79
79
  ))}
80
- </div>
80
+ </ul>
81
81
  </section>
82
82
  );
83
83
  }
@@ -117,6 +117,57 @@ describe("ToolCallItem disclosure", () => {
117
117
  expect(isExpanded(container)).toBe(false);
118
118
  });
119
119
 
120
+ // --- Interrupted rows (issue #207): settled, neutral, never live -----------
121
+
122
+ it("renders an interrupted row settled: collapsed, no spinner, neutral slash icon", () => {
123
+ // A tool call the platform settled to INTERRUPTED when its execution
124
+ // terminalized mid-call. Before #207 a zombie RUNNING row spun forever and
125
+ // force-opened; the settled row must read as finished-by-interruption.
126
+ const { container } = render(
127
+ <ToolCallItem
128
+ toolCall={makeToolCall({
129
+ id: "tc-int",
130
+ name: "delete_file",
131
+ args: { path: "/x.ts" },
132
+ status: ToolCallStatus.TOOL_CALL_INTERRUPTED,
133
+ })}
134
+ />,
135
+ );
136
+ expect(container.querySelector(".animate-spin")).toBeNull();
137
+ expect(hasHeaderChevron(container)).toBe(true);
138
+ expect(isExpanded(container)).toBe(false);
139
+ });
140
+
141
+ it("collapses a force-opened running row the moment it settles to interrupted", () => {
142
+ // Mirrors the running→completed settle: the server-authored interruption
143
+ // arrives via a status update and the row must release its auto-open.
144
+ const { container, rerender } = render(
145
+ <ToolCallItem
146
+ toolCall={makeToolCall({
147
+ id: "tc-int",
148
+ name: "delete_file",
149
+ args: { path: "/x.ts" },
150
+ status: ToolCallStatus.TOOL_CALL_RUNNING,
151
+ })}
152
+ />,
153
+ );
154
+ expect(isExpanded(container)).toBe(true);
155
+ expect(container.querySelector(".animate-spin")).not.toBeNull();
156
+
157
+ rerender(
158
+ <ToolCallItem
159
+ toolCall={makeToolCall({
160
+ id: "tc-int",
161
+ name: "delete_file",
162
+ args: { path: "/x.ts" },
163
+ status: ToolCallStatus.TOOL_CALL_INTERRUPTED,
164
+ })}
165
+ />,
166
+ );
167
+ expect(container.querySelector(".animate-spin")).toBeNull();
168
+ expect(isExpanded(container)).toBe(false);
169
+ });
170
+
120
171
  // --- Preview categories: no chevron, always-visible body ------------------
121
172
 
122
173
  it("renders a settled edit as an always-visible diff with no header chevron", () => {
@@ -74,6 +74,28 @@ describe("ToolRunGroup", () => {
74
74
  expect(chipExpanded(container)).toBe(false);
75
75
  });
76
76
 
77
+ it("treats an interrupted call as terminal: the chip settles instead of pinning to pending", () => {
78
+ // A platform-settled INTERRUPTED call (issue #207) inside a folded run.
79
+ // Before the fix an unknown status kept allTerminal=false forever, pinning
80
+ // the whole chip to a live-looking pending state with a spinner-adjacent
81
+ // dot; a settled run must render settled.
82
+ const { container } = render(
83
+ <ToolRunGroup
84
+ category="read"
85
+ toolCalls={[
86
+ makeRead("r1", "/a.ts", ToolCallStatus.TOOL_CALL_COMPLETED),
87
+ makeRead("r2", "/b.ts", ToolCallStatus.TOOL_CALL_INTERRUPTED),
88
+ ]}
89
+ />,
90
+ );
91
+ expect(container.querySelector(".animate-spin")).toBeNull();
92
+ expect(chipExpanded(container)).toBe(false);
93
+ // The aggregate resolves to the terminal branch (completed chip in
94
+ // text-success), not the non-terminal pending fallback (muted dot).
95
+ expect(container.querySelector(".text-success")).not.toBeNull();
96
+ expect(screen.getByText("Read 2 files")).toBeTruthy();
97
+ });
98
+
77
99
  it("auto-opens when a folded call is awaiting approval, so the gate is reachable", () => {
78
100
  const gated = makeRead("r2", "/b.ts", ToolCallStatus.TOOL_CALL_WAITING_APPROVAL);
79
101
  const approval: PendingApproval = create(PendingApprovalSchema, {
@@ -1,11 +1,13 @@
1
1
  /**
2
- * WriteBackCard — phase-honest rendering of a workspace write-back record.
2
+ * WriteBackCard — phase-honest rendering of a workspace write-back record
3
+ * as a dense VS Code-style row group.
4
+ *
3
5
  * The high-stakes matrix is the error treatment: FAILED errors are
4
6
  * destructive, while a PUSHED record carrying a PR error renders it as a
5
7
  * degraded notice with the branch info intact (the branch IS live).
6
8
  */
7
- import { describe, it, expect, afterEach } from "vitest";
8
- import { render, screen, cleanup } from "@testing-library/react";
9
+ import { describe, it, expect, vi, afterEach } from "vitest";
10
+ import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
9
11
  import { create } from "@bufbuild/protobuf";
10
12
  import {
11
13
  WorkspaceWriteBackSchema,
@@ -19,7 +21,7 @@ function makeWriteBack(overrides: Record<string, unknown> = {}) {
19
21
  branchName: "stigmer/ses-01test",
20
22
  baseBranch: "main",
21
23
  commitSha: "abc123",
22
- diffSummary: " 2 files changed",
24
+ diffSummary: " 2 files changed, 10 insertions(+), 3 deletions(-)",
23
25
  phase: WorkspaceWriteBackPhase.WORKSPACE_WRITE_BACK_PR_CREATED,
24
26
  pullRequestUrl: "https://github.com/acme/api/pull/12",
25
27
  pullRequestNumber: 12,
@@ -30,13 +32,64 @@ function makeWriteBack(overrides: Record<string, unknown> = {}) {
30
32
  afterEach(() => cleanup());
31
33
 
32
34
  describe("WriteBackCard", () => {
33
- it("renders branch, diff summary, and the PR link for a PR_CREATED record", () => {
35
+ it("renders the header name, PR row, branch row, and parsed stats for a PR_CREATED record", () => {
34
36
  render(<WriteBackCard writeBack={makeWriteBack()} />);
35
- expect(screen.getByText("stigmer/ses-01test")).toBeTruthy();
36
- expect(screen.getByText(/2 files changed/)).toBeTruthy();
37
- const link = screen.getByRole("link", { name: /view pr #12/i });
37
+
38
+ expect(screen.getByText("acme/api")).toBeTruthy();
39
+ // Quiet phase caption in the header.
40
+ expect(screen.getByText("PR #12")).toBeTruthy();
41
+
42
+ const link = screen.getByRole("link", { name: /pull request #12/i });
38
43
  expect(link.getAttribute("href")).toBe("https://github.com/acme/api/pull/12");
39
- expect(screen.getByText("PR Created")).toBeTruthy();
44
+ expect(link.getAttribute("target")).toBe("_blank");
45
+ expect(link.getAttribute("rel")).toContain("noopener");
46
+
47
+ expect(screen.getByText("stigmer/ses-01test")).toBeTruthy();
48
+ expect(screen.getByText(/main/)).toBeTruthy();
49
+
50
+ // The --stat summary line renders structured, not as raw mono text.
51
+ expect(screen.getByText("2 files changed")).toBeTruthy();
52
+ expect(screen.getByText("+10 additions")).toBeTruthy();
53
+ expect(screen.getByText("-3 deletions")).toBeTruthy();
54
+ });
55
+
56
+ it("falls back to the raw trailing --stat line when the summary is unparseable", () => {
57
+ render(
58
+ <WriteBackCard
59
+ writeBack={makeWriteBack({ diffSummary: " something unexpected" })}
60
+ />,
61
+ );
62
+ expect(screen.getByText("something unexpected")).toBeTruthy();
63
+ });
64
+
65
+ // Single-entry sessions can write back under an empty entry name (the
66
+ // runner's resolveEntry convention) — the header must never be blank.
67
+ it("derives the header from the PR URL when the entry name is empty", () => {
68
+ render(
69
+ <WriteBackCard writeBack={makeWriteBack({ workspaceEntryName: "" })} />,
70
+ );
71
+ expect(screen.getByText("acme/api")).toBeTruthy();
72
+ expect(
73
+ screen.getByRole("article", { name: "Write-back: acme/api" }),
74
+ ).toBeTruthy();
75
+ });
76
+
77
+ it("copies the branch name with transient feedback", async () => {
78
+ const writeText = vi.fn().mockResolvedValue(undefined);
79
+ Object.defineProperty(navigator, "clipboard", {
80
+ value: { writeText },
81
+ configurable: true,
82
+ });
83
+
84
+ render(<WriteBackCard writeBack={makeWriteBack()} />);
85
+ fireEvent.click(
86
+ screen.getByRole("button", { name: /copy branch name/i }),
87
+ );
88
+
89
+ expect(writeText).toHaveBeenCalledWith("stigmer/ses-01test");
90
+ await waitFor(() =>
91
+ expect(screen.getByRole("button", { name: /copied/i })).toBeTruthy(),
92
+ );
40
93
  });
41
94
 
42
95
  it("renders a FAILED record's error destructively", () => {
@@ -52,7 +105,8 @@ describe("WriteBackCard", () => {
52
105
  );
53
106
  const error = screen.getByText(/git push failed/);
54
107
  expect(error.className).toContain("text-destructive");
55
- expect(screen.getByText("Failed")).toBeTruthy();
108
+ const caption = screen.getByText("Failed");
109
+ expect(caption.className).toContain("text-destructive");
56
110
  });
57
111
 
58
112
  it("renders a PUSHED record's PR error as a degraded notice, branch info intact", () => {