@tangle-network/ui 1.0.0
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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/active-sessions-store-CeOmXgv5.d.ts +85 -0
- package/dist/artifact-pane-DvJyPWV4.d.ts +24 -0
- package/dist/auth.d.ts +74 -0
- package/dist/auth.js +15 -0
- package/dist/button-CMQuQEW_.d.ts +17 -0
- package/dist/chat.d.ts +232 -0
- package/dist/chat.js +30 -0
- package/dist/chunk-2NFQRQOD.js +1009 -0
- package/dist/chunk-2VH6PUXD.js +186 -0
- package/dist/chunk-34A66VBG.js +214 -0
- package/dist/chunk-3OI2QKFD.js +0 -0
- package/dist/chunk-4CLN43XT.js +45 -0
- package/dist/chunk-54SQQMMM.js +156 -0
- package/dist/chunk-5Z5ZYMOJ.js +0 -0
- package/dist/chunk-66BNMOVT.js +167 -0
- package/dist/chunk-6BGQA4BQ.js +0 -0
- package/dist/chunk-7UO2ZMRQ.js +133 -0
- package/dist/chunk-BX6AQMUS.js +183 -0
- package/dist/chunk-CD53GZOM.js +59 -0
- package/dist/chunk-CSAIKY36.js +54 -0
- package/dist/chunk-EEE55AVS.js +1201 -0
- package/dist/chunk-GYPQXTJU.js +230 -0
- package/dist/chunk-HFL6R6IF.js +37 -0
- package/dist/chunk-HJKCSXCH.js +737 -0
- package/dist/chunk-LISXUB4D.js +1222 -0
- package/dist/chunk-LQS34IGP.js +0 -0
- package/dist/chunk-MKTSMWVD.js +109 -0
- package/dist/chunk-NKDZ7GZE.js +192 -0
- package/dist/chunk-OEX7NZE3.js +321 -0
- package/dist/chunk-Q56BYXQF.js +61 -0
- package/dist/chunk-Q7EIIWTC.js +0 -0
- package/dist/chunk-REJESC5U.js +117 -0
- package/dist/chunk-RQGKSCEZ.js +0 -0
- package/dist/chunk-RQHJBTEU.js +10 -0
- package/dist/chunk-TMFOPHHN.js +299 -0
- package/dist/chunk-XGKULLYE.js +40 -0
- package/dist/chunk-XIHMJ7ZQ.js +614 -0
- package/dist/chunk-YJ2G3XO5.js +1048 -0
- package/dist/chunk-YNN4O57I.js +754 -0
- package/dist/code-block-DjXf8eOG.d.ts +19 -0
- package/dist/document-editor-pane-A5LT5H4N.js +12 -0
- package/dist/document-editor-pane-DyDEX_Zm.d.ts +124 -0
- package/dist/editor.d.ts +120 -0
- package/dist/editor.js +34 -0
- package/dist/files.d.ts +175 -0
- package/dist/files.js +20 -0
- package/dist/hooks.d.ts +56 -0
- package/dist/hooks.js +41 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +446 -0
- package/dist/markdown.d.ts +15 -0
- package/dist/markdown.js +14 -0
- package/dist/message-BHWbxBtT.d.ts +15 -0
- package/dist/openui.d.ts +115 -0
- package/dist/openui.js +12 -0
- package/dist/parts-dj7AcUg0.d.ts +36 -0
- package/dist/primitives.d.ts +332 -0
- package/dist/primitives.js +191 -0
- package/dist/run-PfLmDAox.d.ts +41 -0
- package/dist/run.d.ts +69 -0
- package/dist/run.js +36 -0
- package/dist/sdk-hooks.d.ts +285 -0
- package/dist/sdk-hooks.js +31 -0
- package/dist/stores.d.ts +17 -0
- package/dist/stores.js +76 -0
- package/dist/tool-call-feed-Bs3MyQMT.d.ts +68 -0
- package/dist/tool-display-z4JcDmMQ.d.ts +32 -0
- package/dist/tool-previews.d.ts +48 -0
- package/dist/tool-previews.js +21 -0
- package/dist/types.d.ts +19 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +45 -0
- package/dist/utils.js +32 -0
- package/package.json +193 -0
- package/src/auth/auth.tsx +228 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/login-layout.tsx +46 -0
- package/src/chat/agent-timeline.stories.tsx +429 -0
- package/src/chat/agent-timeline.tsx +360 -0
- package/src/chat/chat-container.tsx +486 -0
- package/src/chat/chat-input.stories.tsx +142 -0
- package/src/chat/chat-input.tsx +389 -0
- package/src/chat/chat-message.stories.tsx +237 -0
- package/src/chat/chat-message.tsx +129 -0
- package/src/chat/index.ts +18 -0
- package/src/chat/message-list.stories.tsx +336 -0
- package/src/chat/message-list.tsx +79 -0
- package/src/chat/thinking-indicator.stories.tsx +56 -0
- package/src/chat/thinking-indicator.tsx +30 -0
- package/src/chat/user-message.stories.tsx +92 -0
- package/src/chat/user-message.tsx +43 -0
- package/src/editor/document-editor-pane.tsx +351 -0
- package/src/editor/editor-provider.tsx +428 -0
- package/src/editor/editor-toolbar.tsx +130 -0
- package/src/editor/index.ts +31 -0
- package/src/editor/markdown-conversion.ts +21 -0
- package/src/editor/markdown-document-editor.tsx +137 -0
- package/src/editor/tiptap-editor.tsx +331 -0
- package/src/editor/use-editor.ts +221 -0
- package/src/files/file-artifact-pane.tsx +183 -0
- package/src/files/file-preview.tsx +342 -0
- package/src/files/file-tabs.tsx +71 -0
- package/src/files/file-tree.tsx +258 -0
- package/src/files/index.ts +17 -0
- package/src/files/rich-file-tree.stories.tsx +104 -0
- package/src/files/rich-file-tree.test.tsx +42 -0
- package/src/files/rich-file-tree.tsx +232 -0
- package/src/hooks/index.ts +10 -0
- package/src/hooks/use-auth.ts +153 -0
- package/src/hooks/use-auto-scroll.ts +59 -0
- package/src/hooks/use-dropdown-menu.ts +40 -0
- package/src/hooks/use-live-time.test.tsx +40 -0
- package/src/hooks/use-live-time.ts +27 -0
- package/src/hooks/use-realtime-session.ts +319 -0
- package/src/hooks/use-run-collapse-state.ts +25 -0
- package/src/hooks/use-run-groups.ts +111 -0
- package/src/hooks/use-sdk-session.ts +575 -0
- package/src/hooks/use-sse-stream.ts +475 -0
- package/src/hooks/use-tool-call-stream.ts +96 -0
- package/src/index.ts +14 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown/code-block.tsx +198 -0
- package/src/markdown/index.ts +2 -0
- package/src/markdown/markdown.stories.tsx +190 -0
- package/src/markdown/markdown.tsx +62 -0
- package/src/openui/index.ts +20 -0
- package/src/openui/openui-artifact-renderer.tsx +542 -0
- package/src/primitives/artifact-pane.tsx +91 -0
- package/src/primitives/avatar.stories.tsx +95 -0
- package/src/primitives/avatar.tsx +47 -0
- package/src/primitives/badge.stories.tsx +57 -0
- package/src/primitives/badge.tsx +97 -0
- package/src/primitives/button.stories.tsx +48 -0
- package/src/primitives/button.tsx +115 -0
- package/src/primitives/card.stories.tsx +53 -0
- package/src/primitives/card.tsx +98 -0
- package/src/primitives/code-block.stories.tsx +115 -0
- package/src/primitives/code-block.tsx +22 -0
- package/src/primitives/design-tokens.stories.tsx +162 -0
- package/src/primitives/dialog.stories.tsx +176 -0
- package/src/primitives/dialog.tsx +137 -0
- package/src/primitives/drop-zone.stories.tsx +123 -0
- package/src/primitives/drop-zone.tsx +131 -0
- package/src/primitives/dropdown-menu.stories.tsx +122 -0
- package/src/primitives/dropdown-menu.tsx +214 -0
- package/src/primitives/empty-state.stories.tsx +81 -0
- package/src/primitives/empty-state.tsx +40 -0
- package/src/primitives/index.ts +118 -0
- package/src/primitives/input.stories.tsx +113 -0
- package/src/primitives/input.tsx +136 -0
- package/src/primitives/label.stories.tsx +84 -0
- package/src/primitives/label.tsx +24 -0
- package/src/primitives/progress.stories.tsx +93 -0
- package/src/primitives/progress.tsx +50 -0
- package/src/primitives/segmented-control.test.tsx +328 -0
- package/src/primitives/segmented-control.tsx +154 -0
- package/src/primitives/select.stories.tsx +164 -0
- package/src/primitives/select.tsx +158 -0
- package/src/primitives/sidebar-drop-zone.stories.tsx +100 -0
- package/src/primitives/sidebar-drop-zone.tsx +149 -0
- package/src/primitives/skeleton.stories.tsx +79 -0
- package/src/primitives/skeleton.tsx +55 -0
- package/src/primitives/stat-card.stories.tsx +137 -0
- package/src/primitives/stat-card.tsx +97 -0
- package/src/primitives/switch.stories.tsx +85 -0
- package/src/primitives/switch.tsx +28 -0
- package/src/primitives/table.stories.tsx +170 -0
- package/src/primitives/table.tsx +116 -0
- package/src/primitives/tabs.stories.tsx +180 -0
- package/src/primitives/tabs.tsx +71 -0
- package/src/primitives/terminal-display.stories.tsx +191 -0
- package/src/primitives/terminal-display.tsx +189 -0
- package/src/primitives/theme-toggle.stories.tsx +32 -0
- package/src/primitives/theme-toggle.tsx +96 -0
- package/src/primitives/toast.stories.tsx +155 -0
- package/src/primitives/toast.tsx +190 -0
- package/src/primitives/upload-progress.stories.tsx +120 -0
- package/src/primitives/upload-progress.tsx +110 -0
- package/src/run/expanded-tool-detail.stories.tsx +182 -0
- package/src/run/expanded-tool-detail.tsx +186 -0
- package/src/run/index.ts +13 -0
- package/src/run/inline-thinking-item.stories.tsx +136 -0
- package/src/run/inline-thinking-item.tsx +120 -0
- package/src/run/inline-tool-item.stories.tsx +222 -0
- package/src/run/inline-tool-item.tsx +190 -0
- package/src/run/run-group.stories.tsx +322 -0
- package/src/run/run-group.tsx +569 -0
- package/src/run/run-item-primitives.tsx +17 -0
- package/src/run/tool-call-feed.stories.tsx +294 -0
- package/src/run/tool-call-feed.tsx +192 -0
- package/src/run/tool-call-step.stories.tsx +198 -0
- package/src/run/tool-call-step.tsx +240 -0
- package/src/sdk-hooks.ts +38 -0
- package/src/stores/active-sessions-store.ts +455 -0
- package/src/stores/chat-store.ts +43 -0
- package/src/stores/index.ts +2 -0
- package/src/tool-previews/command-preview.tsx +116 -0
- package/src/tool-previews/diff-preview.tsx +85 -0
- package/src/tool-previews/glob-results-preview.tsx +98 -0
- package/src/tool-previews/grep-results-preview.tsx +157 -0
- package/src/tool-previews/index.ts +22 -0
- package/src/tool-previews/preview-primitives.tsx +84 -0
- package/src/tool-previews/question-preview.tsx +101 -0
- package/src/tool-previews/web-search-preview.tsx +117 -0
- package/src/tool-previews/write-file-preview.tsx +80 -0
- package/src/types/branding.ts +11 -0
- package/src/types/index.ts +5 -0
- package/src/types/message.ts +13 -0
- package/src/types/parts.ts +51 -0
- package/src/types/run.ts +56 -0
- package/src/types/tool-display.ts +41 -0
- package/src/utils/copy-text.ts +30 -0
- package/src/utils/format.test.ts +43 -0
- package/src/utils/format.ts +56 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/time-ago.ts +9 -0
- package/src/utils/tool-display.ts +238 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatInput — message input bar with file attach, drag-and-drop, send/cancel.
|
|
3
|
+
*
|
|
4
|
+
* - Auto-resizing textarea (up to max height)
|
|
5
|
+
* - Enter to send, Shift+Enter for newline
|
|
6
|
+
* - Drag-and-drop files onto the input with styled overlay
|
|
7
|
+
* - File attachment button (files) + folder attachment button
|
|
8
|
+
* - Pending file/folder chips
|
|
9
|
+
* - Cancel button when streaming
|
|
10
|
+
* - Optional model selector pill
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type DragEvent } from "react";
|
|
14
|
+
import { Send, Square, Paperclip, FolderUp, X, Upload } from "lucide-react";
|
|
15
|
+
import { cn } from "../lib/utils";
|
|
16
|
+
|
|
17
|
+
export interface PendingFile {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
size: number;
|
|
21
|
+
type: "file" | "folder";
|
|
22
|
+
/** Number of files inside (for folders) */
|
|
23
|
+
fileCount?: number;
|
|
24
|
+
status: "pending" | "uploading" | "ready" | "error";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChatInputProps {
|
|
28
|
+
onSend: (message: string, files?: File[]) => void;
|
|
29
|
+
onCancel?: () => void;
|
|
30
|
+
isStreaming?: boolean;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
/** Currently selected model label */
|
|
34
|
+
modelLabel?: string;
|
|
35
|
+
onModelClick?: () => void;
|
|
36
|
+
/** Pending uploaded files */
|
|
37
|
+
pendingFiles?: PendingFile[];
|
|
38
|
+
onRemoveFile?: (id: string) => void;
|
|
39
|
+
/** Called when files are attached (via button or drag-and-drop) */
|
|
40
|
+
onAttach?: (files: FileList) => void;
|
|
41
|
+
/** Called when a folder is selected via the folder button */
|
|
42
|
+
onAttachFolder?: (files: FileList) => void;
|
|
43
|
+
/** Accepted file types for the file input (e.g. ".pdf,.csv") */
|
|
44
|
+
accept?: string;
|
|
45
|
+
/** Drop zone overlay title */
|
|
46
|
+
dropTitle?: string;
|
|
47
|
+
/** Drop zone overlay description */
|
|
48
|
+
dropDescription?: string;
|
|
49
|
+
className?: string;
|
|
50
|
+
/** Label above the input. Set to null to hide. Default: "Agent Command Deck" */
|
|
51
|
+
inputLabel?: string | null;
|
|
52
|
+
/** Status text shown when idle. Set to null to hide. Default: "Ready for next instruction" */
|
|
53
|
+
idleStatus?: string | null;
|
|
54
|
+
/** Status text shown when streaming. Set to null to hide. Default: "Streaming response" */
|
|
55
|
+
streamingStatus?: string | null;
|
|
56
|
+
/** Hide the Cmd+L focus shortcut hint */
|
|
57
|
+
hideShortcutHint?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function ChatInput({
|
|
61
|
+
onSend,
|
|
62
|
+
onCancel,
|
|
63
|
+
isStreaming,
|
|
64
|
+
disabled,
|
|
65
|
+
placeholder = "Ask the agent to inspect files, run commands, or explain results…",
|
|
66
|
+
modelLabel,
|
|
67
|
+
onModelClick,
|
|
68
|
+
pendingFiles = [],
|
|
69
|
+
onRemoveFile,
|
|
70
|
+
onAttach,
|
|
71
|
+
onAttachFolder,
|
|
72
|
+
accept,
|
|
73
|
+
dropTitle = "Drop files to add context",
|
|
74
|
+
dropDescription = "Files will be attached to your next message.",
|
|
75
|
+
className,
|
|
76
|
+
inputLabel = "Agent Command Deck",
|
|
77
|
+
idleStatus = "Ready for next instruction",
|
|
78
|
+
streamingStatus = "Streaming response",
|
|
79
|
+
hideShortcutHint,
|
|
80
|
+
}: ChatInputProps) {
|
|
81
|
+
const [value, setValue] = useState("");
|
|
82
|
+
const [dragOver, setDragOver] = useState(false);
|
|
83
|
+
const dragCounter = useRef(0);
|
|
84
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
85
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
86
|
+
const folderInputRef = useRef<HTMLInputElement>(null);
|
|
87
|
+
|
|
88
|
+
const handleSend = useCallback(() => {
|
|
89
|
+
const trimmed = value.trim();
|
|
90
|
+
if (!trimmed || isStreaming || disabled) return;
|
|
91
|
+
onSend(trimmed);
|
|
92
|
+
setValue("");
|
|
93
|
+
if (textareaRef.current) {
|
|
94
|
+
textareaRef.current.style.height = "auto";
|
|
95
|
+
}
|
|
96
|
+
}, [value, isStreaming, disabled, onSend]);
|
|
97
|
+
|
|
98
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
99
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
handleSend();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
106
|
+
setValue(e.target.value);
|
|
107
|
+
const el = e.target;
|
|
108
|
+
el.style.height = "auto";
|
|
109
|
+
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const handleAttachClick = () => {
|
|
113
|
+
fileInputRef.current?.click();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleFolderClick = () => {
|
|
117
|
+
folderInputRef.current?.click();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
121
|
+
if (e.target.files?.length) {
|
|
122
|
+
onAttach?.(e.target.files);
|
|
123
|
+
e.target.value = "";
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleFolderChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
128
|
+
if (e.target.files?.length) {
|
|
129
|
+
(onAttachFolder ?? onAttach)?.(e.target.files);
|
|
130
|
+
e.target.value = "";
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Drag-and-drop handlers
|
|
135
|
+
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
e.stopPropagation();
|
|
138
|
+
dragCounter.current++;
|
|
139
|
+
if (e.dataTransfer?.types.includes("Files")) {
|
|
140
|
+
setDragOver(true);
|
|
141
|
+
}
|
|
142
|
+
}, []);
|
|
143
|
+
|
|
144
|
+
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
e.stopPropagation();
|
|
147
|
+
dragCounter.current--;
|
|
148
|
+
if (dragCounter.current === 0) {
|
|
149
|
+
setDragOver(false);
|
|
150
|
+
}
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const handleDragOver = useCallback((e: DragEvent) => {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
e.stopPropagation();
|
|
156
|
+
e.dataTransfer.dropEffect = "copy";
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
const handleDrop = useCallback((e: DragEvent) => {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
e.stopPropagation();
|
|
162
|
+
dragCounter.current = 0;
|
|
163
|
+
setDragOver(false);
|
|
164
|
+
|
|
165
|
+
const files = e.dataTransfer?.files;
|
|
166
|
+
if (files?.length && onAttach) {
|
|
167
|
+
onAttach(files);
|
|
168
|
+
}
|
|
169
|
+
}, [onAttach]);
|
|
170
|
+
|
|
171
|
+
const fileChips = pendingFiles.filter((f) => f.type === "file" || !f.type);
|
|
172
|
+
const folderChips = pendingFiles.filter((f) => f.type === "folder");
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div
|
|
176
|
+
className={cn("relative", className)}
|
|
177
|
+
onDragEnter={onAttach ? handleDragEnter : undefined}
|
|
178
|
+
onDragLeave={onAttach ? handleDragLeave : undefined}
|
|
179
|
+
onDragOver={onAttach ? handleDragOver : undefined}
|
|
180
|
+
onDrop={onAttach ? handleDrop : undefined}
|
|
181
|
+
>
|
|
182
|
+
{/* Drop zone overlay */}
|
|
183
|
+
{dragOver && (
|
|
184
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-xl)] border-2 border-dashed border-border bg-card pointer-events-none">
|
|
185
|
+
<div className="text-center">
|
|
186
|
+
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-surface-soft)]">
|
|
187
|
+
<Upload className="h-6 w-6 text-primary" />
|
|
188
|
+
</div>
|
|
189
|
+
<p className="text-sm font-semibold text-foreground">{dropTitle}</p>
|
|
190
|
+
<p className="mt-1 text-xs text-muted-foreground">{dropDescription}</p>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{/* Pending file chips */}
|
|
196
|
+
{pendingFiles.length > 0 && (
|
|
197
|
+
<div className="mb-3 flex flex-wrap gap-2">
|
|
198
|
+
{folderChips.map((f) => (
|
|
199
|
+
<span
|
|
200
|
+
key={f.id}
|
|
201
|
+
className={cn(
|
|
202
|
+
"inline-flex items-center gap-1.5 rounded-[var(--radius-full)] border px-3 py-1.5 text-xs",
|
|
203
|
+
"border-border bg-muted/50",
|
|
204
|
+
f.status === "error" && "border-[var(--code-error)]/30 text-[var(--code-error)]",
|
|
205
|
+
f.status !== "error" && "text-foreground",
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
<FolderUp className="h-3 w-3 shrink-0" />
|
|
209
|
+
<span className="truncate max-w-[150px]">{f.name}</span>
|
|
210
|
+
{f.fileCount !== undefined && (
|
|
211
|
+
<span className="text-muted-foreground">({f.fileCount})</span>
|
|
212
|
+
)}
|
|
213
|
+
{f.status === "uploading" && (
|
|
214
|
+
<span className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
215
|
+
)}
|
|
216
|
+
{onRemoveFile && (
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
aria-label={`Remove ${f.name}`}
|
|
220
|
+
onClick={() => onRemoveFile(f.id)}
|
|
221
|
+
className="rounded p-0.5 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
|
222
|
+
>
|
|
223
|
+
<X className="h-3 w-3" />
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
</span>
|
|
227
|
+
))}
|
|
228
|
+
{fileChips.map((f) => (
|
|
229
|
+
<span
|
|
230
|
+
key={f.id}
|
|
231
|
+
className={cn(
|
|
232
|
+
"inline-flex items-center gap-1.5 rounded-[var(--radius-full)] border px-3 py-1.5 text-xs",
|
|
233
|
+
"border-border bg-muted/50",
|
|
234
|
+
f.status === "error" && "border-[var(--code-error)]/30 text-[var(--code-error)]",
|
|
235
|
+
f.status !== "error" && "text-foreground",
|
|
236
|
+
)}
|
|
237
|
+
>
|
|
238
|
+
<Paperclip className="h-3 w-3 shrink-0" />
|
|
239
|
+
<span className="truncate max-w-[150px]">{f.name}</span>
|
|
240
|
+
{f.status === "uploading" && (
|
|
241
|
+
<span className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
|
242
|
+
)}
|
|
243
|
+
{onRemoveFile && (
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
aria-label={`Remove ${f.name}`}
|
|
247
|
+
onClick={() => onRemoveFile(f.id)}
|
|
248
|
+
className="rounded p-0.5 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
|
249
|
+
>
|
|
250
|
+
<X className="h-3 w-3" />
|
|
251
|
+
</button>
|
|
252
|
+
)}
|
|
253
|
+
</span>
|
|
254
|
+
))}
|
|
255
|
+
</div>
|
|
256
|
+
)}
|
|
257
|
+
|
|
258
|
+
{/* Input row */}
|
|
259
|
+
<div className="rounded-[24px] border border-[var(--chat-input-border,var(--border-default))] [background:var(--chat-input-bg,var(--bg-card))] shadow-[var(--chat-input-shadow,0_1px_2px_rgba(15,23,42,0.05))] transition-all focus-within:border-[var(--chat-input-focus-border,var(--border-accent))] focus-within:shadow-[var(--chat-input-focus-shadow,0_10px_30px_rgba(15,23,42,0.08))]">
|
|
260
|
+
<div className="rounded-[24px] px-4 py-[var(--chat-input-py)]">
|
|
261
|
+
{(inputLabel !== null || idleStatus !== null || streamingStatus !== null) && (
|
|
262
|
+
<div className="mb-1.5 flex items-center justify-between gap-3 px-1">
|
|
263
|
+
{inputLabel !== null && (
|
|
264
|
+
<div className="text-[var(--chat-label-size,11px)] font-[var(--chat-label-weight,600)] uppercase tracking-[var(--chat-label-tracking,0.16em)] text-[var(--text-muted)]">
|
|
265
|
+
{inputLabel}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
{(idleStatus !== null || streamingStatus !== null) && (
|
|
269
|
+
<div className="text-[var(--chat-label-size,11px)] text-[var(--text-muted)]">
|
|
270
|
+
{isStreaming ? (streamingStatus ?? "") : (idleStatus ?? "")}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
<div className="flex items-end gap-2.5">
|
|
276
|
+
{/* Attach buttons */}
|
|
277
|
+
{onAttach && (
|
|
278
|
+
<>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
onClick={handleAttachClick}
|
|
282
|
+
disabled={isStreaming}
|
|
283
|
+
aria-label="Attach files"
|
|
284
|
+
title="Attach files"
|
|
285
|
+
className="mb-0.5 shrink-0 rounded-[var(--radius-md)] border border-transparent p-2 text-muted-foreground transition-colors hover:border-border hover:bg-accent hover:text-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
|
286
|
+
>
|
|
287
|
+
<Paperclip className="h-4 w-4" />
|
|
288
|
+
</button>
|
|
289
|
+
<input
|
|
290
|
+
ref={fileInputRef}
|
|
291
|
+
type="file"
|
|
292
|
+
multiple
|
|
293
|
+
className="hidden"
|
|
294
|
+
onChange={handleFileChange}
|
|
295
|
+
accept={accept ?? ".pdf,.csv,.xlsx,.xls,.jpg,.jpeg,.png,.gif,.txt,.json,.yaml,.yml"}
|
|
296
|
+
/>
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
{(onAttachFolder ?? onAttach) && (
|
|
300
|
+
<>
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
onClick={handleFolderClick}
|
|
304
|
+
disabled={isStreaming}
|
|
305
|
+
aria-label="Attach folder"
|
|
306
|
+
title="Attach folder"
|
|
307
|
+
className="mb-0.5 shrink-0 rounded-[var(--radius-md)] border border-transparent p-2 text-muted-foreground transition-colors hover:border-border hover:bg-accent hover:text-foreground disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
|
308
|
+
>
|
|
309
|
+
<FolderUp className="h-4 w-4" />
|
|
310
|
+
</button>
|
|
311
|
+
<input
|
|
312
|
+
ref={folderInputRef}
|
|
313
|
+
type="file"
|
|
314
|
+
multiple
|
|
315
|
+
className="hidden"
|
|
316
|
+
onChange={handleFolderChange}
|
|
317
|
+
// @ts-ignore webkitdirectory is non-standard but widely supported
|
|
318
|
+
webkitdirectory=""
|
|
319
|
+
/>
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
|
|
323
|
+
{/* Textarea */}
|
|
324
|
+
<textarea
|
|
325
|
+
ref={textareaRef}
|
|
326
|
+
value={value}
|
|
327
|
+
onChange={handleChange}
|
|
328
|
+
onKeyDown={handleKeyDown}
|
|
329
|
+
placeholder={placeholder}
|
|
330
|
+
disabled={isStreaming || disabled}
|
|
331
|
+
rows={1}
|
|
332
|
+
aria-label="Message input"
|
|
333
|
+
className="min-h-[42px] max-h-[160px] flex-1 resize-none bg-transparent py-2 text-[15px] leading-6 text-foreground placeholder:text-muted-foreground disabled:opacity-50 focus-visible:outline-none"
|
|
334
|
+
/>
|
|
335
|
+
|
|
336
|
+
{/* Send / Cancel */}
|
|
337
|
+
{isStreaming ? (
|
|
338
|
+
<button
|
|
339
|
+
type="button"
|
|
340
|
+
onClick={onCancel}
|
|
341
|
+
aria-label="Stop response"
|
|
342
|
+
className="mb-0.5 shrink-0 rounded-[var(--radius-lg)] border border-[var(--code-error)]/20 bg-[var(--code-error)]/14 p-2.5 text-[var(--code-error)] transition-colors hover:bg-[var(--code-error)]/24 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--code-error)]/50"
|
|
343
|
+
>
|
|
344
|
+
<Square className="h-4 w-4" />
|
|
345
|
+
</button>
|
|
346
|
+
) : (
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
onClick={handleSend}
|
|
350
|
+
disabled={!value.trim() || disabled}
|
|
351
|
+
aria-label="Send message"
|
|
352
|
+
className="mb-0.5 inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[var(--chat-send-border,var(--border-accent))] [background:var(--chat-send-bg,var(--brand-primary))] px-3.5 py-2.5 text-sm font-medium text-[var(--chat-send-color,white)] shadow-[var(--chat-send-shadow,0_6px_16px_rgba(15,23,42,0.12))] transition-all hover:translate-y-[-1px] hover:[background:var(--chat-send-hover-bg,var(--brand-strong))] disabled:opacity-30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chat-send-ring,var(--border-accent))]"
|
|
353
|
+
>
|
|
354
|
+
<Send className="h-4 w-4" />
|
|
355
|
+
<span>Send</span>
|
|
356
|
+
</button>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{/* Footer: model selector + shortcuts */}
|
|
363
|
+
{(modelLabel || !hideShortcutHint) && (
|
|
364
|
+
<div className="mt-2 flex items-center justify-between px-1">
|
|
365
|
+
<div className="flex items-center gap-2">
|
|
366
|
+
{modelLabel && (
|
|
367
|
+
<button
|
|
368
|
+
type="button"
|
|
369
|
+
onClick={onModelClick}
|
|
370
|
+
aria-label={`Select model, current model ${modelLabel}`}
|
|
371
|
+
className="inline-flex items-center gap-1.5 rounded-[var(--radius-full)] border border-border bg-[linear-gradient(180deg,rgba(255,255,255,0.04),transparent)] px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:border-primary/20 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
|
|
372
|
+
>
|
|
373
|
+
<span className="w-1.5 h-1.5 rounded-full bg-[var(--code-success)]" />
|
|
374
|
+
{modelLabel}
|
|
375
|
+
</button>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
{!hideShortcutHint && (
|
|
379
|
+
<span className="text-xs text-muted-foreground">
|
|
380
|
+
<kbd className="px-1 py-0.5 bg-background rounded border border-border text-[10px]">Cmd</kbd>
|
|
381
|
+
<kbd className="px-1 py-0.5 bg-background rounded border border-border text-[10px] ml-0.5">L</kbd>
|
|
382
|
+
<span className="ml-1">to focus</span>
|
|
383
|
+
</span>
|
|
384
|
+
)}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { ChatMessage } from './chat-message'
|
|
3
|
+
import { ToolCallStep } from '../run/tool-call-step'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof ChatMessage> = {
|
|
6
|
+
title: 'Chat/ChatMessage',
|
|
7
|
+
component: ChatMessage,
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'fullscreen',
|
|
10
|
+
backgrounds: { default: 'dark' },
|
|
11
|
+
},
|
|
12
|
+
decorators: [
|
|
13
|
+
(Story) => (
|
|
14
|
+
<div className="min-h-screen bg-[var(--bg-root)] p-8">
|
|
15
|
+
<div className="mx-auto max-w-3xl space-y-4">
|
|
16
|
+
<Story />
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
),
|
|
20
|
+
],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default meta
|
|
24
|
+
type Story = StoryObj<typeof ChatMessage>
|
|
25
|
+
|
|
26
|
+
const ts = (offsetMinutes = 0) =>
|
|
27
|
+
new Date(Date.now() - offsetMinutes * 60 * 1000)
|
|
28
|
+
|
|
29
|
+
export const UserMessage: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
role: 'user',
|
|
32
|
+
content: 'How do I implement a binary search tree in TypeScript?',
|
|
33
|
+
timestamp: ts(3),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const AssistantSimple: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
role: 'assistant',
|
|
40
|
+
content:
|
|
41
|
+
'A binary search tree (BST) is a node-based data structure where each node has at most two children, and all left descendants are less than the node, all right descendants are greater.',
|
|
42
|
+
timestamp: ts(2),
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const AssistantWithCode: Story = {
|
|
47
|
+
args: {
|
|
48
|
+
role: 'assistant',
|
|
49
|
+
content: `Here's a clean BST implementation in TypeScript:
|
|
50
|
+
|
|
51
|
+
\`\`\`typescript
|
|
52
|
+
interface TreeNode<T> {
|
|
53
|
+
value: T
|
|
54
|
+
left: TreeNode<T> | null
|
|
55
|
+
right: TreeNode<T> | null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class BinarySearchTree<T> {
|
|
59
|
+
private root: TreeNode<T> | null = null
|
|
60
|
+
|
|
61
|
+
insert(value: T): void {
|
|
62
|
+
this.root = this.insertNode(this.root, value)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private insertNode(node: TreeNode<T> | null, value: T): TreeNode<T> {
|
|
66
|
+
if (!node) return { value, left: null, right: null }
|
|
67
|
+
if (value < node.value) node.left = this.insertNode(node.left, value)
|
|
68
|
+
else if (value > node.value) node.right = this.insertNode(node.right, value)
|
|
69
|
+
return node
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
contains(value: T): boolean {
|
|
73
|
+
let current = this.root
|
|
74
|
+
while (current) {
|
|
75
|
+
if (value === current.value) return true
|
|
76
|
+
current = value < current.value ? current.left : current.right
|
|
77
|
+
}
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
inOrder(): T[] {
|
|
82
|
+
const result: T[] = []
|
|
83
|
+
const traverse = (node: TreeNode<T> | null) => {
|
|
84
|
+
if (!node) return
|
|
85
|
+
traverse(node.left)
|
|
86
|
+
result.push(node.value)
|
|
87
|
+
traverse(node.right)
|
|
88
|
+
}
|
|
89
|
+
traverse(this.root)
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
\`\`\`
|
|
94
|
+
|
|
95
|
+
**Key properties:**
|
|
96
|
+
- \`insert\` — O(log n) average, O(n) worst case
|
|
97
|
+
- \`contains\` — O(log n) average, O(n) worst case
|
|
98
|
+
- \`inOrder\` — O(n) always, yields sorted output
|
|
99
|
+
|
|
100
|
+
For a balanced variant, consider AVL or Red-Black trees.`,
|
|
101
|
+
timestamp: ts(2),
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const AssistantStreaming: Story = {
|
|
106
|
+
args: {
|
|
107
|
+
role: 'assistant',
|
|
108
|
+
content: `I'll analyze the performance bottleneck in your \`fetchData\` hook. The issue is that \`useState(true)\` initializes loading as true before React even checks the cache`,
|
|
109
|
+
isStreaming: true,
|
|
110
|
+
timestamp: ts(0),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const AssistantWithToolCalls: Story = {
|
|
115
|
+
args: {
|
|
116
|
+
role: 'assistant',
|
|
117
|
+
content: 'Let me read the current implementation first.',
|
|
118
|
+
toolCalls: (
|
|
119
|
+
<div className="mt-3 space-y-2">
|
|
120
|
+
<ToolCallStep
|
|
121
|
+
type="read"
|
|
122
|
+
label="Read src/hooks/useFetchData.ts"
|
|
123
|
+
status="success"
|
|
124
|
+
output={`export function useFetchData<T>(url: string) {\n const [data, setData] = useState<T | null>(null)\n const [loading, setLoading] = useState(true)\n // ...`}
|
|
125
|
+
duration={48}
|
|
126
|
+
/>
|
|
127
|
+
<ToolCallStep
|
|
128
|
+
type="grep"
|
|
129
|
+
label="Search for cache references"
|
|
130
|
+
status="success"
|
|
131
|
+
output="No cache layer found. Data fetched on every mount."
|
|
132
|
+
duration={12}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
),
|
|
136
|
+
timestamp: ts(1),
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const AssistantMarkdownRich: Story = {
|
|
141
|
+
args: {
|
|
142
|
+
role: 'assistant',
|
|
143
|
+
content: `## Rate Limiter Design
|
|
144
|
+
|
|
145
|
+
The **sliding window** algorithm is the right choice here. Here's why:
|
|
146
|
+
|
|
147
|
+
| Algorithm | Burst Handling | Redis Ops | Accuracy |
|
|
148
|
+
|-----------|---------------|-----------|---------- |
|
|
149
|
+
| Fixed window | Poor | 1 | Low |
|
|
150
|
+
| Token bucket | Good | 2-3 | Medium |
|
|
151
|
+
| **Sliding window** | **Excellent** | **2** | **High** |
|
|
152
|
+
|
|
153
|
+
### Implementation
|
|
154
|
+
|
|
155
|
+
\`\`\`typescript
|
|
156
|
+
import Redis from 'ioredis'
|
|
157
|
+
|
|
158
|
+
export async function rateLimitCheck(
|
|
159
|
+
redis: Redis,
|
|
160
|
+
userId: string,
|
|
161
|
+
limit = 100,
|
|
162
|
+
windowMs = 60_000,
|
|
163
|
+
): Promise<{ allowed: boolean; remaining: number }> {
|
|
164
|
+
const now = Date.now()
|
|
165
|
+
const key = \`rl:\${userId}\`
|
|
166
|
+
const windowStart = now - windowMs
|
|
167
|
+
|
|
168
|
+
const pipeline = redis.pipeline()
|
|
169
|
+
pipeline.zremrangebyscore(key, '-inf', windowStart)
|
|
170
|
+
pipeline.zadd(key, now, \`\${now}-\${Math.random()}\`)
|
|
171
|
+
pipeline.zcard(key)
|
|
172
|
+
pipeline.pexpire(key, windowMs)
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const results = await pipeline.exec()
|
|
176
|
+
const count = (results?.[2]?.[1] as number) ?? 0
|
|
177
|
+
return { allowed: count <= limit, remaining: Math.max(0, limit - count) }
|
|
178
|
+
} catch {
|
|
179
|
+
// Fail open on Redis errors
|
|
180
|
+
return { allowed: true, remaining: limit }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
\`\`\`
|
|
184
|
+
|
|
185
|
+
> **Note:** The \`zremrangebyscore\` + \`zadd\` pair is atomic per key, keeping overhead to ~0.3ms p99 on a local Redis instance.`,
|
|
186
|
+
timestamp: ts(1),
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const SystemMessage: Story = {
|
|
191
|
+
args: {
|
|
192
|
+
role: 'system',
|
|
193
|
+
content: 'Session started. Connected to agent runtime v2.4.1.',
|
|
194
|
+
timestamp: ts(5),
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const Conversation: Story = {
|
|
199
|
+
render: () => (
|
|
200
|
+
<div className="space-y-4">
|
|
201
|
+
<ChatMessage
|
|
202
|
+
role="user"
|
|
203
|
+
content="Can you help me debug why my React query cache isn't working?"
|
|
204
|
+
timestamp={ts(5)}
|
|
205
|
+
/>
|
|
206
|
+
<ChatMessage
|
|
207
|
+
role="assistant"
|
|
208
|
+
content="Sure — let me look at your query setup. What version of TanStack Query are you on, and how are you configuring the `QueryClient`?"
|
|
209
|
+
timestamp={ts(4)}
|
|
210
|
+
/>
|
|
211
|
+
<ChatMessage
|
|
212
|
+
role="user"
|
|
213
|
+
content="v5.28. I'm using the defaults — just `new QueryClient()` with no options."
|
|
214
|
+
timestamp={ts(3)}
|
|
215
|
+
/>
|
|
216
|
+
<ChatMessage
|
|
217
|
+
role="assistant"
|
|
218
|
+
content={`The default \`staleTime\` in v5 is \`0\`, which means every component mount triggers a background refetch even if data is in cache. That explains the flicker.
|
|
219
|
+
|
|
220
|
+
Set a non-zero \`staleTime\`:
|
|
221
|
+
|
|
222
|
+
\`\`\`typescript
|
|
223
|
+
const queryClient = new QueryClient({
|
|
224
|
+
defaultOptions: {
|
|
225
|
+
queries: {
|
|
226
|
+
staleTime: 60 * 1000, // 1 minute
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
\`\`\`
|
|
231
|
+
|
|
232
|
+
With this, cached data is considered fresh for 60 seconds and no refetch occurs on mount.`}
|
|
233
|
+
timestamp={ts(2)}
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
),
|
|
237
|
+
}
|