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.
- package/components/blocks/AIConversation/AIConversation.tsx +49 -1
- package/components/blocks/WorkflowCanvas/WorkflowCanvas.tsx +0 -79
- package/components/composites/OrchestratorMessage/OrchestratorMessage.tsx +51 -2
- package/components/composites/PromptInput/PromptInput.stories.tsx +63 -0
- package/components/composites/PromptInput/PromptInput.tsx +77 -0
- package/components/composites/PromptInput/index.ts +27 -1
- package/components/composites/UserMessage/UserMessage.tsx +40 -13
- package/components/composites/WorkflowToolbar/WorkflowToolbar.tsx +23 -1
- package/components/composites/WorkflowToolbar/interfaces.ts +14 -0
- package/components/composites/WorkspaceEmptyState/WorkspaceEmptyState.stories.tsx +38 -0
- package/components/composites/WorkspaceEmptyState/WorkspaceEmptyState.tsx +60 -0
- package/components/composites/WorkspaceEmptyState/index.ts +2 -0
- package/components/composites/index.ts +25 -0
- package/components/features/ChatPanel/ChatPanel.tsx +18 -1
- package/components/features/TextEditor/TextEditor.tsx +43 -2
- package/components/features/TextEditor/index.ts +1 -0
- package/components/features/index.ts +1 -1
- package/components/index.ts +21 -0
- package/dist/index.cjs +562 -81
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +3 -0
- package/dist/index.d.ts +198 -74
- package/dist/index.js +553 -82
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
45
|
-
* Using the control undoes the request — it and
|
|
46
|
-
* removed, and the text returns to the prompt
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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=
|
|
134
|
-
title=
|
|
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-
|
|
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
|
-
{
|
|
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 {
|