ai-design-system 0.1.91 → 0.1.92

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.
@@ -56,6 +56,7 @@ interface AIMessage {
56
56
  isLoading?: boolean;
57
57
  blocks?: AIMessageBlock[];
58
58
  checkpointId?: string;
59
+ restoreLabel?: string;
59
60
  attachments?: UserMessageAttachment[];
60
61
  }
61
62
 
@@ -98,6 +99,17 @@ export interface AIConversationProps
98
99
  * Callback fired when user restores a conversation checkpoint
99
100
  */
100
101
  onRestoreCheckpoint?: (messageId: string, checkpointId: string) => void
102
+ /**
103
+ * Save the workspace as it stands after an orchestrator reply.
104
+ *
105
+ * Paired with `onRestoreCheckpoint`: undo sits on what the user asked for,
106
+ * save sits on what the agent produced. Omit it and no reply offers to save.
107
+ */
108
+ onSaveWorkspace?: () => void
109
+ /** True when the current version is already kept — every Save reads as saved. */
110
+ isCurrentVersionSaved?: boolean
111
+ /** A save is in flight — the control says so and refuses a second one. */
112
+ isSavingWorkspace?: boolean
101
113
  }
102
114
 
103
115
  /**
@@ -113,6 +125,9 @@ export const AIConversation = React.memo<AIConversationProps>(
113
125
  onToolAction,
114
126
  renderSystemMessage,
115
127
  onRestoreCheckpoint,
128
+ onSaveWorkspace,
129
+ isCurrentVersionSaved,
130
+ isSavingWorkspace,
116
131
  ...conversationProps
117
132
  }) => {
118
133
  const isEmpty = React.useMemo(
@@ -242,6 +257,7 @@ export const AIConversation = React.memo<AIConversationProps>(
242
257
  subAgents: msg.subAgents ? [...msg.subAgents] : undefined,
243
258
  blocks: msg.blocks ? [...msg.blocks] : undefined,
244
259
  checkpointId: msg.checkpointId,
260
+ restoreLabel: msg.restoreLabel,
245
261
  };
246
262
  result.push(currentGroup);
247
263
  }
@@ -257,6 +273,27 @@ export const AIConversation = React.memo<AIConversationProps>(
257
273
  return result;
258
274
  }, [messages]);
259
275
 
276
+ /**
277
+ * The last orchestrator reply, and the ONLY one that may offer to save.
278
+ *
279
+ * Saving anchors to the most recent request, so it captures the workspace as
280
+ * it is NOW — not as it was when some earlier reply was written. Offering the
281
+ * control on every reply implies each one saves its own moment, which is
282
+ * false and unrecoverable once acted on: the user would believe they had kept
283
+ * a point they never kept.
284
+ *
285
+ * `isCurrentVersionSaved` is likewise one fact about the workspace, not a
286
+ * property of each message. Rendered on every reply it read as "all of these
287
+ * are saved"; it belongs on the single reply the save would come from.
288
+ */
289
+ const lastReplyIndex = React.useMemo(() => {
290
+ for (let i = groupedMessages.length - 1; i >= 0; i--) {
291
+ const m = groupedMessages[i]
292
+ if (m.role === "orchestrator" || m.role === "assistant" || (!m.role && m.type === "ai")) return i
293
+ }
294
+ return -1
295
+ }, [groupedMessages])
296
+
260
297
  const renderedMessages = React.useMemo(
261
298
  () =>
262
299
  groupedMessages.map((message, index) => {
@@ -291,6 +328,7 @@ export const AIConversation = React.memo<AIConversationProps>(
291
328
  avatarName: message.avatarName,
292
329
  attachments: message.attachments,
293
330
  checkpointId: message.checkpointId,
331
+ restoreLabel: message.restoreLabel,
294
332
  }}
295
333
  showAvatar={showAvatars}
296
334
  onRestore={onRestoreCheckpoint}
@@ -335,6 +373,16 @@ export const AIConversation = React.memo<AIConversationProps>(
335
373
  isLoading: message.isLoading && !hasReasoning,
336
374
  }}
337
375
  showAvatar={showAvatars && hasDisplayContent}
376
+ // Only on a settled reply with something to read: a save
377
+ // offered beside a streaming or empty message would anchor a
378
+ // snapshot to a turn that has not produced anything yet.
379
+ onSave={
380
+ index === lastReplyIndex && hasDisplayContent && !isStreaming
381
+ ? onSaveWorkspace
382
+ : undefined
383
+ }
384
+ isSaved={index === lastReplyIndex && isCurrentVersionSaved}
385
+ isSaving={index === lastReplyIndex && isSavingWorkspace}
338
386
  >
339
387
  {/* Render reasoning-section for hidden tool results and subagents */}
340
388
  {hasReasoning && (
@@ -425,7 +473,7 @@ export const AIConversation = React.memo<AIConversationProps>(
425
473
  </React.Fragment>
426
474
  )
427
475
  }),
428
- [groupedMessages, showAvatars, onToolAction, onRestoreCheckpoint]
476
+ [groupedMessages, showAvatars, onToolAction, onRestoreCheckpoint, onSaveWorkspace, isCurrentVersionSaved, isSavingWorkspace, lastReplyIndex]
429
477
  )
430
478
 
431
479
  return (
@@ -6,8 +6,6 @@ import {
6
6
  type Connection,
7
7
  type Node,
8
8
  useReactFlow,
9
- useStoreApi,
10
- useUpdateNodeInternals,
11
9
  } from "@xyflow/react";
12
10
  import { useCallback, useEffect } from "react";
13
11
  import { Canvas } from "@/components/ai-elements/canvas";
@@ -24,22 +22,6 @@ import { AgentAnnotation } from "@/components/composites/AgentAnnotation";
24
22
  import type { WorkflowCanvasProps, WorkflowEdge } from "@/lib/workflow/interfaces";
25
23
  import "@xyflow/react/dist/style.css";
26
24
 
27
- // Bounded retry for node measurement — see the effect in WorkflowCanvasInner.
28
- //
29
- // ~30s, which sounds long and is not. The loop stops the instant every node
30
- // reports handleBounds, so in the ordinary case it ends after one pass; the cap
31
- // only bounds the pathological case where a node never appears at all.
32
- //
33
- // It was 3s, and that was too short for the case this exists to fix. The grid's
34
- // device nodes each carry an iframe, and on a cold sandbox they are not in the
35
- // DOM until Metro has served a bundle. `useUpdateNodeInternals` silently drops
36
- // every id it cannot resolve, so a window that closes before the iframes mount
37
- // spends all its attempts on nothing and gives up — leaving exactly the blank
38
- // canvas of edgeless nodes it was added to prevent. Verified live: all five
39
- // nodes resolvable, all five still `handleBounds: NULL`.
40
- const MEASURE_RETRY_MS = 250;
41
- const MEASURE_MAX_ATTEMPTS = 120;
42
-
43
25
  const edgeTypes = {
44
26
  straight: Edge.Strict,
45
27
  animated: Edge.Animated,
@@ -76,67 +58,6 @@ function WorkflowCanvasInner({
76
58
  className,
77
59
  }: WorkflowCanvasProps) {
78
60
  const { fitView } = useReactFlow();
79
- const updateNodeInternals = useUpdateNodeInternals();
80
- const store = useStoreApi();
81
-
82
- /**
83
- * Force xyflow to measure the nodes it is showing.
84
- *
85
- * xyflow will not draw an edge until BOTH endpoints have `handleBounds` —
86
- * the measured position of each handle within its node — and those come from
87
- * one place only: the per-node ResizeObserver. Publishing `initialWidth` /
88
- * `initialHeight` (which the preview nodes do) satisfies the separate
89
- * `nodeHasDimensions` check that controls VISIBILITY, and does nothing for
90
- * `handleBounds`. The two are easy to conflate and fail very differently.
91
- *
92
- * When that observer does not deliver — it fires as an unsynchronized
93
- * callback (see the fitView effect below, which works around the same race),
94
- * and reports nothing for a node that is re-created but never changes size —
95
- * the result is not a missing node. It is a canvas of perfectly normal nodes
96
- * with EVERY edge silently absent: `edgeLookup` holds them all, each with
97
- * valid endpoints and handle ids, and not one is rendered. Observed live with
98
- * all five nodes at `measured: {}` and `handleBounds: null` while their DOM
99
- * elements had real size, and xyflow logged no warning.
100
- *
101
- * Keyed on the node ids rather than the `nodes` array, which is a new
102
- * reference on nearly every render.
103
- *
104
- * RETRIED, not fired once, because the request is silently dropped when it
105
- * is early. `useUpdateNodeInternals` resolves each id through
106
- * `domNode.querySelector('.react-flow__node[data-id=...]')` and keeps only
107
- * the ones it finds — a node that has not mounted yet is skipped with no
108
- * error and no retry, and since the id list has not changed, the effect
109
- * never runs again. One shot at 60ms worked for the single-device view and
110
- * missed the grid every time, where five nodes carrying iframes mount later.
111
- * So it repeats until the store actually reports `handleBounds`, and stops
112
- * the moment it does.
113
- */
114
- const nodeIdSignature = nodes.map((n) => n.id).join("|");
115
- useEffect(() => {
116
- if (!nodeIdSignature) return;
117
- const ids = nodeIdSignature.split("|");
118
- let attempts = 0;
119
- let timer: ReturnType<typeof setTimeout>;
120
-
121
- const measured = () => {
122
- const lookup = store.getState().nodeLookup;
123
- return ids.every((id) => !!lookup.get(id)?.internals?.handleBounds);
124
- };
125
-
126
- const pass = () => {
127
- updateNodeInternals(ids);
128
- attempts += 1;
129
- // `updateNodeInternals` defers its own work to a rAF, so the result is
130
- // only readable on a later tick — hence check on the next timer rather
131
- // than immediately after the call.
132
- timer = setTimeout(() => {
133
- if (!measured() && attempts < MEASURE_MAX_ATTEMPTS) pass();
134
- }, MEASURE_RETRY_MS);
135
- };
136
-
137
- timer = setTimeout(pass, 60);
138
- return () => clearTimeout(timer);
139
- }, [nodeIdSignature, updateNodeInternals, store]);
140
61
 
141
62
  // Opt-in re-fit, e.g. after the device-preview toolbar's Route select
142
63
  // switches to "All" and the node count/extent changes underneath the
@@ -11,6 +11,7 @@ import {
11
11
  MessageTypingIndicator,
12
12
  } from "@/components/ai-elements/message"
13
13
  import { Response } from "@/components/composites/response"
14
+ import { cn } from "@/lib/utils"
14
15
 
15
16
  /**
16
17
  * OrchestratorMessage Block
@@ -36,6 +37,23 @@ export interface OrchestratorMessageProps {
36
37
  * Whether to show avatar
37
38
  */
38
39
  showAvatar?: boolean
40
+ /**
41
+ * Save the workspace as it stands after this reply.
42
+ *
43
+ * Sits here and not only in the toolbar because a snapshot is a point in the
44
+ * CONVERSATION, not a global act: the reply is what tells the user something
45
+ * is worth keeping, so the control belongs where they read that. It pairs
46
+ * with the undo control on user messages — save beside what the agent
47
+ * produced, undo beside what you asked for.
48
+ *
49
+ * Absent when there is nothing to save to, so the control simply does not
50
+ * appear rather than offering an action that cannot work.
51
+ */
52
+ onSave?: () => void
53
+ /** True once this version is kept — the control says so instead of repeating. */
54
+ isSaved?: boolean
55
+ /** A save is in flight. Refuses a second press: two presses is two snapshots. */
56
+ isSaving?: boolean
39
57
  /**
40
58
  * Child components (SpecialistMessage, ToolCallDisplay)
41
59
  */
@@ -46,14 +64,14 @@ export interface OrchestratorMessageProps {
46
64
  * OrchestratorMessage component - displays coordinator messages with nested specialists and tools
47
65
  */
48
66
  export const OrchestratorMessage = React.memo<OrchestratorMessageProps>(
49
- ({ message, showAvatar = true, children }) => {
67
+ ({ message, showAvatar = true, children, onSave, isSaved, isSaving }) => {
50
68
  const hasContent = React.useMemo(
51
69
  () => message.content && message.content.trim() !== "",
52
70
  [message.content]
53
71
  )
54
72
 
55
73
  return (
56
- <Message from="assistant">
74
+ <Message from="assistant" className="group">
57
75
  {showAvatar && (message.avatarSrc
58
76
  ? <MessageAvatar
59
77
  src={message.avatarSrc}
@@ -80,6 +98,37 @@ export const OrchestratorMessage = React.memo<OrchestratorMessageProps>(
80
98
 
81
99
  {/* Child components (specialists and tool calls) */}
82
100
  {children && <div className="mt-4 space-y-4">{children}</div>}
101
+
102
+ {/* Revealed on hover or keyboard focus, like the undo control on a
103
+ user message: keeping a version is deliberate and occasional, and
104
+ a button standing on every reply reads as something you are
105
+ expected to press. Hidden entirely while streaming — there is no
106
+ settled version to keep yet. */}
107
+ {onSave && !message.isLoading && (
108
+ <button
109
+ type="button"
110
+ aria-label={isSaved ? "This version is already saved" : "Save this version"}
111
+ title={isSaved ? "This version is already saved" : "Save this version"}
112
+ disabled={isSaved || isSaving}
113
+ onClick={onSave}
114
+ className={cn(
115
+ "mt-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs",
116
+ "text-muted-foreground transition-opacity",
117
+ "hover:bg-accent hover:text-foreground",
118
+ "focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
119
+ isSaved || isSaving ? "opacity-100" : "opacity-0 group-hover:opacity-100",
120
+ "disabled:pointer-events-none"
121
+ )}
122
+ >
123
+ <Icon
124
+ name={isSaved ? "check" : isSaving ? "loader-2" : "save"}
125
+ size="sm"
126
+ className={isSaving ? "animate-spin" : undefined}
127
+ aria-hidden
128
+ />
129
+ {isSaved ? "Saved" : isSaving ? "Saving…" : "Save"}
130
+ </button>
131
+ )}
83
132
  </div>
84
133
  </Message>
85
134
  )
@@ -273,3 +273,66 @@ export const ToggleDialog: Story = {
273
273
  },
274
274
  },
275
275
  };
276
+
277
+ /**
278
+ * With Context Window
279
+ * Displays context token consumption, progress ring, breakdown, and estimated cost
280
+ */
281
+ export const WithContextWindow: Story = {
282
+ args: {
283
+ placeholder: "Ask a question about the codebase...",
284
+ context: {
285
+ usedTokens: 42500,
286
+ maxTokens: 128000,
287
+ modelId: "gpt-4o",
288
+ usage: {
289
+ inputTokens: 38000,
290
+ outputTokens: 3500,
291
+ reasoningTokens: 1000,
292
+ cachedInputTokens: 15000,
293
+ },
294
+ },
295
+ onSubmit: (message) => {
296
+ console.log("Submitted:", message);
297
+ },
298
+ },
299
+ parameters: {
300
+ docs: {
301
+ description: {
302
+ story:
303
+ "Displays context window usage via the AI Elements Context compound component in the toolbar. Shows token usage percentage, circular progress, input/output/reasoning/cache breakdown, and estimated USD cost on hover.",
304
+ },
305
+ },
306
+ },
307
+ };
308
+
309
+ /**
310
+ * With Near-Limit Context Window
311
+ * Demonstrates high context utilization nearing the maximum context window
312
+ */
313
+ export const WithNearLimitContext: Story = {
314
+ args: {
315
+ placeholder: "Ask a question with high context utilization...",
316
+ context: {
317
+ usedTokens: 118500,
318
+ maxTokens: 128000,
319
+ modelId: "gpt-4o",
320
+ usage: {
321
+ inputTokens: 112000,
322
+ outputTokens: 6500,
323
+ },
324
+ },
325
+ onSubmit: (message) => {
326
+ console.log("Submitted:", message);
327
+ },
328
+ },
329
+ parameters: {
330
+ docs: {
331
+ description: {
332
+ story:
333
+ "Demonstrates context window indicator when the model is near its token capacity (92.6% utilized).",
334
+ },
335
+ },
336
+ },
337
+ };
338
+
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from "react";
4
4
  import type { ReactNode } from "react";
5
+ import type { LanguageModelUsage } from "ai";
5
6
  import {
6
7
  PromptInput as AIPromptInput,
7
8
  PromptInputAttachment,
@@ -18,10 +19,29 @@ import {
18
19
  usePromptInputController,
19
20
  useOptionalPromptInputController,
20
21
  } from "@/components/ai-elements/prompt-input";
22
+ import {
23
+ Context,
24
+ ContextTrigger,
25
+ ContextContent,
26
+ ContextContentHeader,
27
+ ContextContentBody,
28
+ ContextContentFooter,
29
+ ContextInputUsage,
30
+ ContextOutputUsage,
31
+ ContextReasoningUsage,
32
+ ContextCacheUsage,
33
+ } from "@/components/ai-elements/context";
21
34
  import type { FormEvent } from "react";
22
35
  import { Button } from "@/components/primitives/Button";
23
36
  import { Icon } from "@/components/primitives/Icon";
24
37
 
38
+ export interface PromptInputContextProps {
39
+ usedTokens: number;
40
+ maxTokens: number;
41
+ usage?: LanguageModelUsage;
42
+ modelId?: string;
43
+ }
44
+
25
45
  export interface PromptInputBlockProps
26
46
  extends Omit<
27
47
  AIPromptInputProps,
@@ -38,6 +58,8 @@ export interface PromptInputBlockProps
38
58
  dialog?: ReactNode;
39
59
  loading?: boolean;
40
60
  onStop?: () => void;
61
+ context?: PromptInputContextProps | ReactNode;
62
+ tools?: ReactNode;
41
63
  }
42
64
 
43
65
  export const PromptInput = React.memo<PromptInputBlockProps>(
@@ -50,6 +72,8 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
50
72
  dialog,
51
73
  loading = false,
52
74
  onStop,
75
+ context,
76
+ tools,
53
77
  accept = "image/*",
54
78
  multiple = true,
55
79
  maxFiles,
@@ -111,6 +135,13 @@ export const PromptInput = React.memo<PromptInputBlockProps>(
111
135
  <PromptInputFooter>
112
136
  <PromptInputTools>
113
137
  <AttachButton disabled={disabled || loading} />
138
+ {tools}
139
+ {context ? (
140
+ <PromptInputContextIndicator
141
+ context={context}
142
+ disabled={disabled || loading}
143
+ />
144
+ ) : null}
114
145
  </PromptInputTools>
115
146
  <PromptInputSubmit
116
147
  disabled={disabled || (loading && !onStop)}
@@ -187,4 +218,50 @@ function ExternalValueSync({ value }: { value?: string }) {
187
218
  return null;
188
219
  }
189
220
 
221
+ /**
222
+ * Renders the context window usage indicator.
223
+ * Supports either a PromptInputContextProps object (auto-rendered using Context compound elements)
224
+ * or a custom ReactNode.
225
+ */
226
+ function PromptInputContextIndicator({
227
+ context,
228
+ disabled,
229
+ }: {
230
+ context: PromptInputContextProps | ReactNode;
231
+ disabled?: boolean;
232
+ }) {
233
+ if (React.isValidElement(context)) {
234
+ return context;
235
+ }
236
+
237
+ const ctx = context as PromptInputContextProps;
238
+ if (!ctx || typeof ctx.usedTokens !== "number" || typeof ctx.maxTokens !== "number") {
239
+ return null;
240
+ }
241
+
242
+ return (
243
+ <Context
244
+ usedTokens={ctx.usedTokens}
245
+ maxTokens={ctx.maxTokens}
246
+ usage={ctx.usage}
247
+ modelId={ctx.modelId}
248
+ >
249
+ <ContextTrigger
250
+ className="h-8 px-2 text-xs font-normal gap-1.5"
251
+ disabled={disabled}
252
+ />
253
+ <ContextContent>
254
+ <ContextContentHeader />
255
+ <ContextContentBody className="space-y-1.5">
256
+ <ContextInputUsage />
257
+ <ContextOutputUsage />
258
+ <ContextReasoningUsage />
259
+ <ContextCacheUsage />
260
+ </ContextContentBody>
261
+ {ctx.modelId && <ContextContentFooter />}
262
+ </ContextContent>
263
+ </Context>
264
+ );
265
+ }
266
+
190
267
  PromptInput.displayName = "PromptInput";
@@ -1,5 +1,5 @@
1
1
  export { PromptInput } from "./PromptInput";
2
- export type { PromptInputBlockProps } from "./PromptInput";
2
+ export type { PromptInputBlockProps, PromptInputContextProps } from "./PromptInput";
3
3
 
4
4
  export {
5
5
  PromptInputProvider,
@@ -15,3 +15,29 @@ export type {
15
15
  PromptInputProviderProps,
16
16
  } from "@/components/ai-elements/prompt-input";
17
17
 
18
+ export {
19
+ Context,
20
+ ContextTrigger,
21
+ ContextContent,
22
+ ContextContentHeader,
23
+ ContextContentBody,
24
+ ContextContentFooter,
25
+ ContextInputUsage,
26
+ ContextOutputUsage,
27
+ ContextReasoningUsage,
28
+ ContextCacheUsage,
29
+ } from "@/components/ai-elements/context";
30
+
31
+ export type {
32
+ ContextProps,
33
+ ContextTriggerProps,
34
+ ContextContentProps,
35
+ ContextContentHeaderProps,
36
+ ContextContentBodyProps,
37
+ ContextContentFooterProps,
38
+ ContextInputUsageProps,
39
+ ContextOutputUsageProps,
40
+ ContextReasoningUsageProps,
41
+ ContextCacheUsageProps,
42
+ } from "@/components/ai-elements/context";
43
+
@@ -41,9 +41,10 @@ export interface UserMessageData {
41
41
  avatarName?: string
42
42
  attachments?: UserMessageAttachment[]
43
43
  /**
44
- * Set when a saved workspace snapshot sits immediately before this request.
45
- * Using the control undoes the request — it and everything it produced are
46
- * removed, and the text returns to the prompt input to be sent again.
44
+ * Set when this request's own turn was saved AND an earlier restore point
45
+ * exists to go back to. Using the control undoes the request — it and
46
+ * everything it produced are removed, and the text returns to the prompt
47
+ * input to be sent again.
47
48
  *
48
49
  * It belongs on the request, not on the answer: "take me back to here" is
49
50
  * something a person means about what they asked for, and offering it under
@@ -51,6 +52,16 @@ export interface UserMessageData {
51
52
  * of their own messages.
52
53
  */
53
54
  checkpointId?: string
55
+ /**
56
+ * What using the control will actually do, named rather than implied: which
57
+ * saved version it returns to, and how many requests that removes.
58
+ *
59
+ * A restore cuts back to the previous snapshot, so turns between this one and
60
+ * that point go too — they were never saved and have no snapshot of their own
61
+ * to stop at. That is not guessable from the control's position, so it is
62
+ * stated before the click rather than discovered after it.
63
+ */
64
+ restoreLabel?: string
54
65
  }
55
66
 
56
67
  function UserMessageAttachments({ attachments }: { attachments: UserMessageAttachment[] }) {
@@ -93,6 +104,12 @@ export interface UserMessageProps {
93
104
  /**
94
105
  * Rewind to this message's checkpoint. The control renders only when both
95
106
  * this and `message.checkpointId` are present.
107
+ *
108
+ * The checkpoint it carries is the restore point BEFORE this request, not the
109
+ * snapshot saved on this turn's own reply. That snapshot captured the
110
+ * workspace once the request had already run, so restoring it would undo
111
+ * nothing; the state to come back to is the one the request was made from.
112
+ * `message.restoreLabel` names it, because the control's position cannot.
96
113
  */
97
114
  onRestore?: (messageId: string, checkpointId: string) => void
98
115
  }
@@ -103,6 +120,10 @@ export interface UserMessageProps {
103
120
  export const UserMessage = React.memo<UserMessageProps>(
104
121
  ({ message, showAvatar = true, onRestore }) => {
105
122
  const canRestore = Boolean(message.checkpointId && onRestore)
123
+ // The generic wording is a fallback, not the intended text: without a label
124
+ // the control cannot say which version it returns to or how much it removes.
125
+ const restoreLabel =
126
+ message.restoreLabel ?? "Undo this request and go back to the last saved version"
106
127
 
107
128
  return (
108
129
  <Message from="user" className="group">
@@ -117,25 +138,31 @@ export const UserMessage = React.memo<UserMessageProps>(
117
138
  </AvatarFallback>
118
139
  </Avatar>
119
140
  )}
120
- <div className="flex flex-col items-end gap-2">
141
+ <div className="flex flex-col items-end gap-2 flex-1 min-w-0">
121
142
  {message.attachments && message.attachments.length > 0 && (
122
143
  <UserMessageAttachments attachments={message.attachments} />
123
144
  )}
124
- <div className="flex items-center gap-1">
145
+ <div className="flex items-center gap-1 max-w-[80%]">
125
146
  {/* Left of the bubble, so it sits on the inside edge of a
126
147
  right-aligned message instead of pushing into the margin.
127
- Revealed on hover or keyboard focus: rewinding is destructive
128
- and uncommon, so it should be reachable without being a
129
- standing invitation on every message. */}
148
+
149
+ Visible at rest, dimmed, and full strength on hover or keyboard
150
+ focus. It marks the requests that CAN be undone, which is a
151
+ sparse and non-obvious set — only turns that were saved and have
152
+ an earlier restore point to return to. Hiding it until hover made
153
+ that set undiscoverable: the page showed nothing on any message,
154
+ so a save appeared to have produced no control at all and the
155
+ only way to find one was to sweep the pointer down the
156
+ conversation. */}
130
157
  {canRestore && (
131
158
  <button
132
159
  type="button"
133
- aria-label="Go back to the version saved here"
134
- title="Go back to the version saved here"
160
+ aria-label={restoreLabel}
161
+ title={restoreLabel}
135
162
  onClick={() => onRestore!(message.id, message.checkpointId!)}
136
163
  className={cn(
137
- "shrink-0 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity",
138
- "hover:bg-accent hover:text-foreground",
164
+ "shrink-0 rounded-md p-1 text-muted-foreground opacity-40 transition-opacity",
165
+ "hover:bg-accent hover:text-foreground hover:opacity-100",
139
166
  "focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2",
140
167
  "focus-visible:ring-ring group-hover:opacity-100"
141
168
  )}
@@ -144,7 +171,7 @@ export const UserMessage = React.memo<UserMessageProps>(
144
171
  </button>
145
172
  )}
146
173
  {message.content && (
147
- <MessageContent variant="contained">{message.content}</MessageContent>
174
+ <MessageContent variant="contained" className="max-w-full">{message.content}</MessageContent>
148
175
  )}
149
176
  </div>
150
177
  </div>
@@ -62,6 +62,14 @@ export function WorkflowToolbarActions({
62
62
 
63
63
  if (action.switcher) {
64
64
  const { switcher } = action;
65
+ // `undefined` means the caller does not use this and keeps the old
66
+ // selection-follows-tick behaviour. `null` means the caller DOES
67
+ // use it and is saying none of the items is current — which must
68
+ // mark nothing, not fall back to the selection. Collapsing those
69
+ // two with `??` reintroduces the exact bug: a defaulted picker
70
+ // always has a `value`, so the tick would come straight back.
71
+ const tickValue =
72
+ switcher.currentValue !== undefined ? switcher.currentValue : switcher.value;
65
73
  return (
66
74
  <DropdownMenu key={action.id}>
67
75
  <DropdownMenuTrigger asChild>
@@ -80,7 +88,21 @@ export function WorkflowToolbarActions({
80
88
  onClick={() => switcher.onValueChange(item.value)}
81
89
  >
82
90
  <span>{item.label}</span>
83
- {item.value === switcher.value && (
91
+ {/* The check states a FACT, not a cursor position.
92
+ `currentValue` is the item that is actually true —
93
+ for a snapshot picker, the one matching the
94
+ workspace on screen. `undefined` means the caller
95
+ does not use this and the check follows the
96
+ selection, as it always did; `null` means the
97
+ caller uses it and NOTHING is current, so nothing
98
+ is marked.
99
+
100
+ It used to mark `switcher.value`, the highlighted
101
+ row, which for a defaulted picker is always set —
102
+ so a tick appeared beside the newest snapshot even
103
+ with unsaved work, contradicting the Save button
104
+ next to it. */}
105
+ {item.value === tickValue && (
84
106
  <Icon name="check" size="sm" className="ml-2" />
85
107
  )}
86
108
  </DropdownMenuItem>
@@ -12,10 +12,24 @@ export interface ToolbarActionSwitcherItem {
12
12
 
13
13
  export interface ToolbarActionSwitcher {
14
14
  items: ToolbarActionSwitcherItem[];
15
+ /** The highlighted row — what picking does, not what is true. */
15
16
  value?: string;
16
17
  onValueChange: (value: string) => void;
17
18
  placeholder?: string;
18
19
  disabled?: boolean;
20
+ /**
21
+ * The item that is currently TRUE, if any — as opposed to the one highlighted.
22
+ *
23
+ * Supplying it moves the check mark off the selected row and onto this one,
24
+ * so a tick states a fact about the item rather than about the cursor.
25
+ *
26
+ * Three-valued on purpose. `undefined` = this caller does not use the
27
+ * feature, so the tick follows the selection as it always did. `null` = the
28
+ * caller uses it and none of the items is current, so NOTHING is marked —
29
+ * the answer the selection-driven check could never give, and the one the
30
+ * snapshot picker needs while there is unsaved work.
31
+ */
32
+ currentValue?: string | null;
19
33
  }
20
34
 
21
35
  export interface ToolbarAction {