@hogoshi/ui 0.1.8 → 0.1.10
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/package.json +1 -1
- package/src/components/chat.tsx +613 -0
- package/src/components/code-block.tsx +10 -2
- package/src/index.ts +11 -0
- package/src/styles.css +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Alert01Icon,
|
|
5
|
+
ArrowDown01Icon,
|
|
6
|
+
ArrowUp02Icon,
|
|
7
|
+
Cancel01Icon,
|
|
8
|
+
File01Icon,
|
|
9
|
+
FlashIcon,
|
|
10
|
+
Pin02Icon,
|
|
11
|
+
StopCircleIcon,
|
|
12
|
+
Tick02Icon,
|
|
13
|
+
} from "@hugeicons-pro/core-stroke-rounded";
|
|
14
|
+
import { Pin02Icon as Pin02SolidIcon } from "@hugeicons-pro/core-solid-rounded";
|
|
15
|
+
import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react";
|
|
16
|
+
import { cn } from "../lib/cn";
|
|
17
|
+
import type { AppIcon } from "./icon";
|
|
18
|
+
import { Icon } from "./icon";
|
|
19
|
+
import { Image, ImageGallery, ImageViewer, type ViewerImage } from "./image-viewer";
|
|
20
|
+
import { Button } from "./button";
|
|
21
|
+
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./collapsible";
|
|
22
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./dropdown-menu";
|
|
23
|
+
import { Spinner } from "./spinner";
|
|
24
|
+
|
|
25
|
+
const workingText =
|
|
26
|
+
"animate-[shimmer_1.8s_linear_infinite] bg-[linear-gradient(90deg,var(--fg-muted)_20%,var(--fg)_50%,var(--fg-muted)_80%)] bg-[length:200%_100%] bg-clip-text text-transparent";
|
|
27
|
+
|
|
28
|
+
function ChatCaret() {
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
aria-hidden
|
|
32
|
+
className="bg-fg animate-[chat-caret_1.1s_var(--motion-ease)_infinite] ml-0.5 inline-block h-[1em] w-[2px] translate-y-[2px] rounded-full"
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ChatMessage({
|
|
38
|
+
role,
|
|
39
|
+
streaming = false,
|
|
40
|
+
attachments,
|
|
41
|
+
actions,
|
|
42
|
+
className,
|
|
43
|
+
children,
|
|
44
|
+
}: {
|
|
45
|
+
role: "user" | "assistant";
|
|
46
|
+
streaming?: boolean;
|
|
47
|
+
attachments?: ViewerImage[];
|
|
48
|
+
actions?: ReactNode;
|
|
49
|
+
className?: string;
|
|
50
|
+
children: ReactNode;
|
|
51
|
+
}) {
|
|
52
|
+
const [viewing, setViewing] = useState(false);
|
|
53
|
+
const [viewIndex, setViewIndex] = useState(0);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn("group/chat flex w-full min-w-0 flex-col", role === "user" && "items-end", className)}>
|
|
57
|
+
{attachments?.length ? (
|
|
58
|
+
<div
|
|
59
|
+
className={cn(
|
|
60
|
+
"mb-2 flex w-full max-w-[85%] flex-wrap gap-1.5",
|
|
61
|
+
role === "user" ? "justify-end" : "justify-start",
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
{attachments.map((image, at) => (
|
|
65
|
+
<button
|
|
66
|
+
key={`${image.src}-${at}`}
|
|
67
|
+
type="button"
|
|
68
|
+
aria-label={`Open ${image.alt ?? "attachment"}`}
|
|
69
|
+
className="group/attachment border-border bg-bg-subtle focus-visible:ring-ring size-16 cursor-zoom-in overflow-hidden rounded-[var(--radius-sm)] border outline-none focus-visible:ring-2"
|
|
70
|
+
onClick={() => {
|
|
71
|
+
setViewIndex(at);
|
|
72
|
+
setViewing(true);
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<img
|
|
76
|
+
src={image.src}
|
|
77
|
+
alt={image.alt ?? ""}
|
|
78
|
+
loading="lazy"
|
|
79
|
+
draggable={false}
|
|
80
|
+
className="size-full object-cover transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-hover/attachment:scale-[1.05]"
|
|
81
|
+
/>
|
|
82
|
+
</button>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
) : null}
|
|
86
|
+
{role === "user" ? (
|
|
87
|
+
<div className="hogoshi-selectable bg-bg-subtle max-w-[85%] rounded-[var(--radius-md)] px-3.5 py-2.5 text-sm leading-relaxed">
|
|
88
|
+
{children}
|
|
89
|
+
{streaming ? <ChatCaret /> : null}
|
|
90
|
+
</div>
|
|
91
|
+
) : (
|
|
92
|
+
<div className="hogoshi-selectable text-fg w-full min-w-0 text-sm leading-relaxed">
|
|
93
|
+
{children}
|
|
94
|
+
{streaming ? <ChatCaret /> : null}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
{actions}
|
|
98
|
+
{attachments?.length ? (
|
|
99
|
+
<ImageViewer
|
|
100
|
+
images={attachments}
|
|
101
|
+
index={viewIndex}
|
|
102
|
+
open={viewing}
|
|
103
|
+
onOpenChange={setViewing}
|
|
104
|
+
onIndexChange={setViewIndex}
|
|
105
|
+
/>
|
|
106
|
+
) : null}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function ChatActions({ className, ...props }: ComponentProps<"div">) {
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
className={cn(
|
|
115
|
+
"flex items-center gap-0.5 opacity-0 transition-opacity duration-[var(--motion-duration)] ease-[var(--motion-ease)] focus-within:opacity-100 group-hover/chat:opacity-100",
|
|
116
|
+
className,
|
|
117
|
+
)}
|
|
118
|
+
{...props}
|
|
119
|
+
/>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function ChatThinking({
|
|
124
|
+
status,
|
|
125
|
+
state = "working",
|
|
126
|
+
reasoning,
|
|
127
|
+
defaultOpen = false,
|
|
128
|
+
className,
|
|
129
|
+
}: {
|
|
130
|
+
status: ReactNode;
|
|
131
|
+
state?: "working" | "attention";
|
|
132
|
+
reasoning?: ReactNode;
|
|
133
|
+
defaultOpen?: boolean;
|
|
134
|
+
className?: string;
|
|
135
|
+
}) {
|
|
136
|
+
const attention = state === "attention";
|
|
137
|
+
const label = (
|
|
138
|
+
<span className={cn("text-xs font-medium", attention ? "text-warning" : workingText)}>{status}</span>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (!reasoning) {
|
|
142
|
+
return (
|
|
143
|
+
<div className={cn("flex min-w-0 items-center gap-2", className)}>
|
|
144
|
+
{attention ? (
|
|
145
|
+
<Icon
|
|
146
|
+
icon={Alert01Icon}
|
|
147
|
+
variant="stroke"
|
|
148
|
+
size={14}
|
|
149
|
+
className="text-warning animate-[loader-pulse_calc(var(--motion-duration)*2)_var(--motion-ease)_infinite]"
|
|
150
|
+
/>
|
|
151
|
+
) : null}
|
|
152
|
+
{label}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<Collapsible defaultOpen={defaultOpen} className={cn("w-full min-w-0", className)}>
|
|
159
|
+
<CollapsibleTrigger className="group/thinking min-w-0 gap-2 py-0.5">
|
|
160
|
+
<Icon
|
|
161
|
+
icon={ArrowDown01Icon}
|
|
162
|
+
variant="stroke"
|
|
163
|
+
size={12}
|
|
164
|
+
className="text-fg-subtle shrink-0 transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-data-[panel-open]/thinking:rotate-180"
|
|
165
|
+
/>
|
|
166
|
+
<span className="truncate">{label}</span>
|
|
167
|
+
</CollapsibleTrigger>
|
|
168
|
+
<CollapsiblePanel className="hogoshi-selectable text-fg-muted min-w-0 py-1 pl-5 text-xs leading-relaxed">
|
|
169
|
+
{reasoning}
|
|
170
|
+
</CollapsiblePanel>
|
|
171
|
+
</Collapsible>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type ChatToolStatus = "running" | "success" | "error" | "attention";
|
|
176
|
+
|
|
177
|
+
function ToolStatusIcon({ status }: { status: ChatToolStatus }) {
|
|
178
|
+
if (status === "running") return <Spinner size="sm" className="text-fg-muted shrink-0" />;
|
|
179
|
+
if (status === "success")
|
|
180
|
+
return <Icon icon={Tick02Icon} variant="stroke" size={16} className="text-success shrink-0" />;
|
|
181
|
+
if (status === "error")
|
|
182
|
+
return <Icon icon={Cancel01Icon} variant="stroke" size={16} className="text-danger shrink-0" />;
|
|
183
|
+
return (
|
|
184
|
+
<Icon
|
|
185
|
+
icon={Alert01Icon}
|
|
186
|
+
variant="stroke"
|
|
187
|
+
size={16}
|
|
188
|
+
className="text-warning animate-[loader-pulse_calc(var(--motion-duration)*2)_var(--motion-ease)_infinite] shrink-0"
|
|
189
|
+
/>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function ChatToolCall({
|
|
194
|
+
status,
|
|
195
|
+
label,
|
|
196
|
+
meta,
|
|
197
|
+
defaultOpen = false,
|
|
198
|
+
className,
|
|
199
|
+
children,
|
|
200
|
+
}: {
|
|
201
|
+
status: ChatToolStatus;
|
|
202
|
+
label: ReactNode;
|
|
203
|
+
meta?: ReactNode;
|
|
204
|
+
defaultOpen?: boolean;
|
|
205
|
+
className?: string;
|
|
206
|
+
children?: ReactNode;
|
|
207
|
+
}) {
|
|
208
|
+
if (!children) {
|
|
209
|
+
return (
|
|
210
|
+
<div className={cn("flex min-w-0 items-center gap-2", className)}>
|
|
211
|
+
<ToolStatusIcon status={status} />
|
|
212
|
+
<span className="text-fg truncate text-sm">{label}</span>
|
|
213
|
+
{meta ? <span className="text-fg-subtle min-w-0 truncate text-xs">{meta}</span> : null}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<Collapsible defaultOpen={defaultOpen} className={cn("w-full min-w-0", className)}>
|
|
220
|
+
<CollapsibleTrigger className="group/tool min-w-0 gap-2 py-0.5">
|
|
221
|
+
<ToolStatusIcon status={status} />
|
|
222
|
+
<span className="text-fg truncate text-sm">{label}</span>
|
|
223
|
+
{meta ? <span className="text-fg-subtle min-w-0 truncate text-xs">{meta}</span> : null}
|
|
224
|
+
<Icon
|
|
225
|
+
icon={ArrowDown01Icon}
|
|
226
|
+
variant="stroke"
|
|
227
|
+
size={12}
|
|
228
|
+
className="text-fg-subtle ml-auto shrink-0 transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-data-[panel-open]/tool:rotate-180"
|
|
229
|
+
/>
|
|
230
|
+
</CollapsibleTrigger>
|
|
231
|
+
<CollapsiblePanel className="hogoshi-selectable my-1 ml-[7px] min-w-0 border-border border-l py-1 pr-1 pl-3.5">
|
|
232
|
+
{children}
|
|
233
|
+
</CollapsiblePanel>
|
|
234
|
+
</Collapsible>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
type DiffKind = "file" | "hunk" | "add" | "del" | "ctx";
|
|
239
|
+
|
|
240
|
+
const diffLineClass: Record<DiffKind, string> = {
|
|
241
|
+
file: "text-fg-subtle",
|
|
242
|
+
hunk: "text-accent bg-accent-muted/40",
|
|
243
|
+
add: "text-success bg-success/10",
|
|
244
|
+
del: "text-danger bg-danger/10",
|
|
245
|
+
ctx: "text-fg-muted",
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
function diffKind(line: string): DiffKind {
|
|
249
|
+
if (line.startsWith("diff --git") || line.startsWith("+++") || line.startsWith("---")) return "file";
|
|
250
|
+
if (line.startsWith("@@")) return "hunk";
|
|
251
|
+
if (line.startsWith("+")) return "add";
|
|
252
|
+
if (line.startsWith("-")) return "del";
|
|
253
|
+
return "ctx";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function ChatDiff({ diff, className, ...props }: { diff: string } & ComponentProps<"div">) {
|
|
257
|
+
const lines = diff.replace(/\n$/, "").split("\n");
|
|
258
|
+
return (
|
|
259
|
+
<div
|
|
260
|
+
className={cn(
|
|
261
|
+
"border-border bg-bg-subtle max-h-72 min-w-0 w-full overflow-auto rounded-[var(--radius-md)] border font-mono text-xs",
|
|
262
|
+
className,
|
|
263
|
+
)}
|
|
264
|
+
{...props}
|
|
265
|
+
>
|
|
266
|
+
{lines.map((line, index) => (
|
|
267
|
+
<div key={index} className={cn("px-3 py-px whitespace-pre", diffLineClass[diffKind(line)])}>
|
|
268
|
+
{line || " "}
|
|
269
|
+
</div>
|
|
270
|
+
))}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface ChatContextItem {
|
|
276
|
+
id: string;
|
|
277
|
+
label: string;
|
|
278
|
+
src?: string;
|
|
279
|
+
icon?: AppIcon;
|
|
280
|
+
pinned?: boolean;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface ChatModelOption {
|
|
284
|
+
id: string;
|
|
285
|
+
label: string;
|
|
286
|
+
hint?: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function ContextChip({
|
|
290
|
+
item,
|
|
291
|
+
onTogglePin,
|
|
292
|
+
onRemove,
|
|
293
|
+
}: {
|
|
294
|
+
item: ChatContextItem;
|
|
295
|
+
onTogglePin?: () => void;
|
|
296
|
+
onRemove?: () => void;
|
|
297
|
+
}) {
|
|
298
|
+
return (
|
|
299
|
+
<span className="border-border bg-bg-subtle text-fg inline-flex h-7 max-w-[16rem] min-w-0 items-center gap-1.5 rounded-full border py-1 pr-1 pl-2.5">
|
|
300
|
+
{item.src ? (
|
|
301
|
+
<img src={item.src} alt="" draggable={false} className="size-4 shrink-0 rounded-full object-cover" />
|
|
302
|
+
) : (
|
|
303
|
+
<Icon icon={item.icon ?? File01Icon} variant="stroke" size={12} className="text-fg-muted shrink-0" />
|
|
304
|
+
)}
|
|
305
|
+
<span className="min-w-0 truncate text-xs">{item.label}</span>
|
|
306
|
+
{onTogglePin ? (
|
|
307
|
+
<button
|
|
308
|
+
type="button"
|
|
309
|
+
aria-label={item.pinned ? `Unpin ${item.label}` : `Pin ${item.label}`}
|
|
310
|
+
aria-pressed={item.pinned}
|
|
311
|
+
onClick={onTogglePin}
|
|
312
|
+
className={cn(
|
|
313
|
+
"text-fg-muted hover:text-fg focus-visible:ring-ring inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-2",
|
|
314
|
+
item.pinned && "text-accent",
|
|
315
|
+
)}
|
|
316
|
+
>
|
|
317
|
+
<Icon icon={item.pinned ? Pin02SolidIcon : Pin02Icon} size={12} />
|
|
318
|
+
</button>
|
|
319
|
+
) : null}
|
|
320
|
+
{onRemove ? (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
aria-label={`Remove ${item.label}`}
|
|
324
|
+
onClick={onRemove}
|
|
325
|
+
className="text-fg-muted hover:text-fg focus-visible:ring-ring inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none focus-visible:ring-2"
|
|
326
|
+
>
|
|
327
|
+
<Icon icon={Cancel01Icon} variant="stroke" size={12} />
|
|
328
|
+
</button>
|
|
329
|
+
) : null}
|
|
330
|
+
</span>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function ModelMenu({
|
|
335
|
+
model,
|
|
336
|
+
models,
|
|
337
|
+
onSelect,
|
|
338
|
+
}: {
|
|
339
|
+
model: ChatModelOption;
|
|
340
|
+
models: ChatModelOption[];
|
|
341
|
+
onSelect?: (id: string) => void;
|
|
342
|
+
}) {
|
|
343
|
+
return (
|
|
344
|
+
<DropdownMenu>
|
|
345
|
+
<DropdownMenuTrigger
|
|
346
|
+
render={
|
|
347
|
+
<Button size="sm" variant="ghost" aria-label="Select model" className="text-fg-muted h-7 px-1.5" />
|
|
348
|
+
}
|
|
349
|
+
>
|
|
350
|
+
<Icon icon={FlashIcon} variant="stroke" size={14} className="text-fg-subtle" />
|
|
351
|
+
<span className="max-w-40 truncate text-xs">{model.label}</span>
|
|
352
|
+
</DropdownMenuTrigger>
|
|
353
|
+
<DropdownMenuContent>
|
|
354
|
+
{models.map((option) => (
|
|
355
|
+
<DropdownMenuItem key={option.id} onClick={() => onSelect?.(option.id)}>
|
|
356
|
+
{option.id === model.id ? (
|
|
357
|
+
<Icon icon={Tick02Icon} variant="stroke" size={16} className="text-accent shrink-0" />
|
|
358
|
+
) : (
|
|
359
|
+
<span aria-hidden className="size-4 shrink-0" />
|
|
360
|
+
)}
|
|
361
|
+
<span className="truncate">{option.label}</span>
|
|
362
|
+
{option.hint ? <span className="text-fg-subtle ml-auto text-xs">{option.hint}</span> : null}
|
|
363
|
+
</DropdownMenuItem>
|
|
364
|
+
))}
|
|
365
|
+
</DropdownMenuContent>
|
|
366
|
+
</DropdownMenu>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function ChatComposer({
|
|
371
|
+
value,
|
|
372
|
+
onValueChange,
|
|
373
|
+
onSubmit,
|
|
374
|
+
onStop,
|
|
375
|
+
onKeyDown,
|
|
376
|
+
busy = false,
|
|
377
|
+
placeholder,
|
|
378
|
+
disabled,
|
|
379
|
+
context,
|
|
380
|
+
onToggleContextPin,
|
|
381
|
+
onRemoveContext,
|
|
382
|
+
model,
|
|
383
|
+
models,
|
|
384
|
+
onModelChange,
|
|
385
|
+
actions,
|
|
386
|
+
className,
|
|
387
|
+
}: {
|
|
388
|
+
value: string;
|
|
389
|
+
onValueChange: (value: string) => void;
|
|
390
|
+
onSubmit?: () => void;
|
|
391
|
+
onStop?: () => void;
|
|
392
|
+
onKeyDown?: ComponentProps<"textarea">["onKeyDown"];
|
|
393
|
+
busy?: boolean;
|
|
394
|
+
placeholder?: string;
|
|
395
|
+
disabled?: boolean;
|
|
396
|
+
context?: ChatContextItem[];
|
|
397
|
+
onToggleContextPin?: (id: string) => void;
|
|
398
|
+
onRemoveContext?: (id: string) => void;
|
|
399
|
+
model?: ChatModelOption;
|
|
400
|
+
models?: ChatModelOption[];
|
|
401
|
+
onModelChange?: (id: string) => void;
|
|
402
|
+
actions?: ReactNode;
|
|
403
|
+
className?: string;
|
|
404
|
+
}) {
|
|
405
|
+
const textarea = useRef<HTMLTextAreaElement>(null);
|
|
406
|
+
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
const el = textarea.current;
|
|
409
|
+
if (!el) return;
|
|
410
|
+
el.style.height = "auto";
|
|
411
|
+
el.style.height = `${Math.min(el.scrollHeight, 192)}px`;
|
|
412
|
+
}, [value]);
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<div
|
|
416
|
+
className={cn(
|
|
417
|
+
"border-border bg-bg-elevated focus-within:ring-ring focus-within:border-accent rounded-[var(--radius-lg)] border transition-[border-color,box-shadow] duration-[var(--motion-duration)] ease-[var(--motion-ease)] focus-within:ring-2",
|
|
418
|
+
className,
|
|
419
|
+
)}
|
|
420
|
+
>
|
|
421
|
+
{context?.length ? (
|
|
422
|
+
<div className="flex flex-wrap gap-1.5 px-2.5 pt-2.5">
|
|
423
|
+
{context.map((item) => (
|
|
424
|
+
<ContextChip
|
|
425
|
+
key={item.id}
|
|
426
|
+
item={item}
|
|
427
|
+
onTogglePin={onToggleContextPin ? () => onToggleContextPin(item.id) : undefined}
|
|
428
|
+
onRemove={onRemoveContext ? () => onRemoveContext(item.id) : undefined}
|
|
429
|
+
/>
|
|
430
|
+
))}
|
|
431
|
+
</div>
|
|
432
|
+
) : null}
|
|
433
|
+
<textarea
|
|
434
|
+
ref={textarea}
|
|
435
|
+
rows={1}
|
|
436
|
+
value={value}
|
|
437
|
+
placeholder={placeholder}
|
|
438
|
+
disabled={disabled}
|
|
439
|
+
onChange={(event) => onValueChange(event.target.value)}
|
|
440
|
+
onKeyDown={(event) => {
|
|
441
|
+
onKeyDown?.(event);
|
|
442
|
+
if (event.defaultPrevented) return;
|
|
443
|
+
if (event.key === "Enter" && !event.shiftKey && !busy && !disabled) {
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
onSubmit?.();
|
|
446
|
+
}
|
|
447
|
+
}}
|
|
448
|
+
className={cn(
|
|
449
|
+
"placeholder:text-fg-subtle max-h-48 w-full resize-none bg-transparent px-3.5 pb-2 text-sm leading-relaxed outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
|
450
|
+
context?.length ? "pt-2.5" : "pt-3",
|
|
451
|
+
)}
|
|
452
|
+
/>
|
|
453
|
+
<div className="flex items-center justify-between gap-2 px-2 pb-2">
|
|
454
|
+
<div className="flex min-w-0 items-center gap-0.5">
|
|
455
|
+
{model && models?.length ? <ModelMenu model={model} models={models} onSelect={onModelChange} /> : null}
|
|
456
|
+
{actions}
|
|
457
|
+
</div>
|
|
458
|
+
{busy ? (
|
|
459
|
+
onStop ? (
|
|
460
|
+
<Button size="icon-sm" variant="ghost" aria-label="Stop" onClick={onStop}>
|
|
461
|
+
<Icon icon={StopCircleIcon} variant="stroke" />
|
|
462
|
+
</Button>
|
|
463
|
+
) : null
|
|
464
|
+
) : (
|
|
465
|
+
<Button
|
|
466
|
+
size="icon-sm"
|
|
467
|
+
variant="primary"
|
|
468
|
+
aria-label="Send"
|
|
469
|
+
disabled={disabled || !value.trim()}
|
|
470
|
+
onClick={() => onSubmit?.()}
|
|
471
|
+
>
|
|
472
|
+
<Icon icon={ArrowUp02Icon} variant="stroke" />
|
|
473
|
+
</Button>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export interface ChatSuggestItem {
|
|
481
|
+
id: string;
|
|
482
|
+
label: ReactNode;
|
|
483
|
+
hint?: ReactNode;
|
|
484
|
+
icon?: AppIcon;
|
|
485
|
+
group?: string;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export function ChatSuggest({
|
|
489
|
+
open,
|
|
490
|
+
items,
|
|
491
|
+
activeIndex = 0,
|
|
492
|
+
onActiveIndexChange,
|
|
493
|
+
onSelect,
|
|
494
|
+
emptyLabel = "No results",
|
|
495
|
+
className,
|
|
496
|
+
}: {
|
|
497
|
+
open: boolean;
|
|
498
|
+
items: ChatSuggestItem[];
|
|
499
|
+
activeIndex?: number;
|
|
500
|
+
onActiveIndexChange?: (index: number) => void;
|
|
501
|
+
onSelect: (item: ChatSuggestItem) => void;
|
|
502
|
+
emptyLabel?: string;
|
|
503
|
+
className?: string;
|
|
504
|
+
}) {
|
|
505
|
+
if (!open) return null;
|
|
506
|
+
|
|
507
|
+
const groups: { name?: string; items: { item: ChatSuggestItem; index: number }[] }[] = [];
|
|
508
|
+
let flat = 0;
|
|
509
|
+
for (const item of items) {
|
|
510
|
+
const last = groups[groups.length - 1];
|
|
511
|
+
if (last && last.name === item.group) {
|
|
512
|
+
last.items.push({ item, index: flat });
|
|
513
|
+
} else {
|
|
514
|
+
groups.push({ name: item.group, items: [{ item, index: flat }] });
|
|
515
|
+
}
|
|
516
|
+
flat += 1;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return (
|
|
520
|
+
<div
|
|
521
|
+
role="listbox"
|
|
522
|
+
className={cn(
|
|
523
|
+
"border-border bg-bg-elevated/70 animate-[item-in_var(--motion-duration)_var(--motion-ease)] z-[var(--z-popover)] max-h-64 overflow-y-auto overscroll-contain rounded-[var(--radius-md)] border p-1 shadow-[var(--shadow-md)] backdrop-blur-2xl outline-none",
|
|
524
|
+
className,
|
|
525
|
+
)}
|
|
526
|
+
>
|
|
527
|
+
{items.length === 0 ? (
|
|
528
|
+
<p className="text-fg-muted px-2 py-3 text-center text-sm">{emptyLabel}</p>
|
|
529
|
+
) : (
|
|
530
|
+
groups.map((group, groupIndex) => (
|
|
531
|
+
<div key={group.name ?? groupIndex}>
|
|
532
|
+
{group.name ? (
|
|
533
|
+
<p className="text-fg-subtle px-2 py-1.5 text-[11px] font-medium tracking-wider uppercase">{group.name}</p>
|
|
534
|
+
) : null}
|
|
535
|
+
{group.items.map(({ item, index }) => (
|
|
536
|
+
<button
|
|
537
|
+
key={item.id}
|
|
538
|
+
type="button"
|
|
539
|
+
role="option"
|
|
540
|
+
aria-selected={index === activeIndex}
|
|
541
|
+
className={cn(
|
|
542
|
+
"flex w-full cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm outline-none",
|
|
543
|
+
index === activeIndex ? "bg-bg-subtle text-fg" : "text-fg-muted",
|
|
544
|
+
)}
|
|
545
|
+
onMouseEnter={() => onActiveIndexChange?.(index)}
|
|
546
|
+
onClick={() => onSelect(item)}
|
|
547
|
+
>
|
|
548
|
+
{item.icon ? <Icon icon={item.icon} variant="stroke" size={16} className="text-fg-muted shrink-0" /> : null}
|
|
549
|
+
<span className="min-w-0 flex-1 truncate text-left">{item.label}</span>
|
|
550
|
+
{item.hint ? <span className="text-fg-subtle shrink-0 text-xs">{item.hint}</span> : null}
|
|
551
|
+
</button>
|
|
552
|
+
))}
|
|
553
|
+
</div>
|
|
554
|
+
))
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function ChatMedia({
|
|
561
|
+
images,
|
|
562
|
+
pinned = false,
|
|
563
|
+
onPin,
|
|
564
|
+
actions,
|
|
565
|
+
className,
|
|
566
|
+
}: {
|
|
567
|
+
images: ViewerImage[];
|
|
568
|
+
pinned?: boolean;
|
|
569
|
+
onPin?: () => void;
|
|
570
|
+
actions?: ReactNode;
|
|
571
|
+
className?: string;
|
|
572
|
+
}) {
|
|
573
|
+
if (images.length === 0) return null;
|
|
574
|
+
const single = images.length === 1;
|
|
575
|
+
return (
|
|
576
|
+
<div className={cn("group/media relative min-w-0 max-w-lg", className)}>
|
|
577
|
+
{single ? (
|
|
578
|
+
<Image
|
|
579
|
+
src={images[0].src}
|
|
580
|
+
alt={images[0].alt}
|
|
581
|
+
name={images[0].name}
|
|
582
|
+
className="border-border max-h-60 w-auto max-w-full rounded-[var(--radius-md)] border object-cover"
|
|
583
|
+
/>
|
|
584
|
+
) : (
|
|
585
|
+
<ImageGallery images={images} className="min-w-0" />
|
|
586
|
+
)}
|
|
587
|
+
{onPin || actions ? (
|
|
588
|
+
<div
|
|
589
|
+
className={cn(
|
|
590
|
+
"absolute top-2 right-2 flex items-center gap-1 transition-opacity duration-[var(--motion-duration)] ease-[var(--motion-ease)] focus-within:opacity-100 group-hover/media:opacity-100",
|
|
591
|
+
pinned ? "opacity-100" : "opacity-0",
|
|
592
|
+
)}
|
|
593
|
+
>
|
|
594
|
+
{onPin ? (
|
|
595
|
+
<button
|
|
596
|
+
type="button"
|
|
597
|
+
aria-label={pinned ? "Unpin from context" : "Pin to context"}
|
|
598
|
+
aria-pressed={pinned}
|
|
599
|
+
onClick={onPin}
|
|
600
|
+
className={cn(
|
|
601
|
+
"border-border bg-bg-elevated/80 text-fg-muted hover:text-fg pointer-events-auto inline-flex size-7 cursor-pointer items-center justify-center rounded-[var(--radius-sm)] border backdrop-blur-md",
|
|
602
|
+
pinned && "text-accent",
|
|
603
|
+
)}
|
|
604
|
+
>
|
|
605
|
+
<Icon icon={pinned ? Pin02SolidIcon : Pin02Icon} size={14} />
|
|
606
|
+
</button>
|
|
607
|
+
) : null}
|
|
608
|
+
{actions}
|
|
609
|
+
</div>
|
|
610
|
+
) : null}
|
|
611
|
+
</div>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
@@ -59,12 +59,14 @@ export function CodeBlock({
|
|
|
59
59
|
language = "auto",
|
|
60
60
|
variant = "full",
|
|
61
61
|
showLanguage = true,
|
|
62
|
+
scroll = false,
|
|
62
63
|
className,
|
|
63
64
|
}: {
|
|
64
65
|
code: string;
|
|
65
66
|
language?: string;
|
|
66
67
|
variant?: "full" | "snippet";
|
|
67
68
|
showLanguage?: boolean;
|
|
69
|
+
scroll?: boolean;
|
|
68
70
|
className?: string;
|
|
69
71
|
}) {
|
|
70
72
|
const [copied, setCopied] = useState(false);
|
|
@@ -110,8 +112,14 @@ export function CodeBlock({
|
|
|
110
112
|
<TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
|
|
111
113
|
</Tooltip>
|
|
112
114
|
</div>
|
|
113
|
-
<pre
|
|
114
|
-
|
|
115
|
+
<pre
|
|
116
|
+
className={cn(
|
|
117
|
+
"hogoshi-code w-full min-w-0 max-w-full p-4 pr-24 font-mono text-xs",
|
|
118
|
+
scroll ? "max-h-[28rem] overflow-auto whitespace-pre" : "wrap-anywhere whitespace-pre-wrap",
|
|
119
|
+
className,
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
<code className={scroll ? "whitespace-pre" : "wrap-anywhere whitespace-pre-wrap"} dangerouslySetInnerHTML={{ __html: painted.html }} />
|
|
115
123
|
</pre>
|
|
116
124
|
</div>
|
|
117
125
|
);
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,17 @@ export {
|
|
|
20
20
|
useSidebar,
|
|
21
21
|
} from "./components/sidebar";
|
|
22
22
|
export { Card, CardDescription, CardHeader, CardTitle } from "./components/card";
|
|
23
|
+
export {
|
|
24
|
+
ChatActions,
|
|
25
|
+
ChatComposer,
|
|
26
|
+
ChatDiff,
|
|
27
|
+
ChatMedia,
|
|
28
|
+
ChatMessage,
|
|
29
|
+
ChatSuggest,
|
|
30
|
+
ChatThinking,
|
|
31
|
+
ChatToolCall,
|
|
32
|
+
} from "./components/chat";
|
|
33
|
+
export type { ChatContextItem, ChatModelOption, ChatSuggestItem, ChatToolStatus } from "./components/chat";
|
|
23
34
|
export { BootScreen, PageLoader } from "./components/page-loader";
|
|
24
35
|
export { Breadcrumb } from "./components/breadcrumb";
|
|
25
36
|
export type { BreadcrumbItem } from "./components/breadcrumb";
|
package/src/styles.css
CHANGED