@hogoshi/ui 0.1.9 → 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 +198 -31
- package/src/components/code-block.tsx +10 -2
- package/src/index.ts +1 -1
package/package.json
CHANGED
package/src/components/chat.tsx
CHANGED
|
@@ -5,25 +5,22 @@ import {
|
|
|
5
5
|
ArrowDown01Icon,
|
|
6
6
|
ArrowUp02Icon,
|
|
7
7
|
Cancel01Icon,
|
|
8
|
+
File01Icon,
|
|
9
|
+
FlashIcon,
|
|
8
10
|
Pin02Icon,
|
|
9
11
|
StopCircleIcon,
|
|
10
12
|
Tick02Icon,
|
|
11
13
|
} from "@hugeicons-pro/core-stroke-rounded";
|
|
12
14
|
import { Pin02Icon as Pin02SolidIcon } from "@hugeicons-pro/core-solid-rounded";
|
|
13
|
-
import {
|
|
14
|
-
useEffect,
|
|
15
|
-
useRef,
|
|
16
|
-
type ComponentProps,
|
|
17
|
-
type ReactNode,
|
|
18
|
-
} from "react";
|
|
15
|
+
import { useEffect, useRef, useState, type ComponentProps, type ReactNode } from "react";
|
|
19
16
|
import { cn } from "../lib/cn";
|
|
20
17
|
import type { AppIcon } from "./icon";
|
|
21
18
|
import { Icon } from "./icon";
|
|
22
|
-
import { Image, ImageGallery, type ViewerImage } from "./image-viewer";
|
|
19
|
+
import { Image, ImageGallery, ImageViewer, type ViewerImage } from "./image-viewer";
|
|
23
20
|
import { Button } from "./button";
|
|
24
21
|
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./collapsible";
|
|
22
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./dropdown-menu";
|
|
25
23
|
import { Spinner } from "./spinner";
|
|
26
|
-
import { Badge } from "./badge";
|
|
27
24
|
|
|
28
25
|
const workingText =
|
|
29
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";
|
|
@@ -40,16 +37,52 @@ function ChatCaret() {
|
|
|
40
37
|
export function ChatMessage({
|
|
41
38
|
role,
|
|
42
39
|
streaming = false,
|
|
40
|
+
attachments,
|
|
41
|
+
actions,
|
|
43
42
|
className,
|
|
44
43
|
children,
|
|
45
44
|
}: {
|
|
46
45
|
role: "user" | "assistant";
|
|
47
46
|
streaming?: boolean;
|
|
47
|
+
attachments?: ViewerImage[];
|
|
48
|
+
actions?: ReactNode;
|
|
48
49
|
className?: string;
|
|
49
50
|
children: ReactNode;
|
|
50
51
|
}) {
|
|
52
|
+
const [viewing, setViewing] = useState(false);
|
|
53
|
+
const [viewIndex, setViewIndex] = useState(0);
|
|
54
|
+
|
|
51
55
|
return (
|
|
52
|
-
<div className={cn("group/chat flex w-full flex-col", role === "user" && "items-end", className)}>
|
|
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}
|
|
53
86
|
{role === "user" ? (
|
|
54
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">
|
|
55
88
|
{children}
|
|
@@ -61,6 +94,16 @@ export function ChatMessage({
|
|
|
61
94
|
{streaming ? <ChatCaret /> : null}
|
|
62
95
|
</div>
|
|
63
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}
|
|
64
107
|
</div>
|
|
65
108
|
);
|
|
66
109
|
}
|
|
@@ -97,7 +140,7 @@ export function ChatThinking({
|
|
|
97
140
|
|
|
98
141
|
if (!reasoning) {
|
|
99
142
|
return (
|
|
100
|
-
<div className={cn("flex items-center gap-2", className)}>
|
|
143
|
+
<div className={cn("flex min-w-0 items-center gap-2", className)}>
|
|
101
144
|
{attention ? (
|
|
102
145
|
<Icon
|
|
103
146
|
icon={Alert01Icon}
|
|
@@ -112,17 +155,17 @@ export function ChatThinking({
|
|
|
112
155
|
}
|
|
113
156
|
|
|
114
157
|
return (
|
|
115
|
-
<Collapsible defaultOpen={defaultOpen} className={className}>
|
|
116
|
-
<CollapsibleTrigger className="group/thinking py-0.5">
|
|
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">
|
|
117
160
|
<Icon
|
|
118
161
|
icon={ArrowDown01Icon}
|
|
119
162
|
variant="stroke"
|
|
120
163
|
size={12}
|
|
121
|
-
className="text-fg-subtle transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-data-[panel-open]/thinking:rotate-180"
|
|
164
|
+
className="text-fg-subtle shrink-0 transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-data-[panel-open]/thinking:rotate-180"
|
|
122
165
|
/>
|
|
123
|
-
{label}
|
|
166
|
+
<span className="truncate">{label}</span>
|
|
124
167
|
</CollapsibleTrigger>
|
|
125
|
-
<CollapsiblePanel className="hogoshi-selectable text-fg-muted py-1 pl-5 text-xs leading-relaxed">
|
|
168
|
+
<CollapsiblePanel className="hogoshi-selectable text-fg-muted min-w-0 py-1 pl-5 text-xs leading-relaxed">
|
|
126
169
|
{reasoning}
|
|
127
170
|
</CollapsiblePanel>
|
|
128
171
|
</Collapsible>
|
|
@@ -173,8 +216,8 @@ export function ChatToolCall({
|
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
return (
|
|
176
|
-
<Collapsible defaultOpen={defaultOpen} className={className}>
|
|
177
|
-
<CollapsibleTrigger className="group/tool gap-2 py-0.5">
|
|
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">
|
|
178
221
|
<ToolStatusIcon status={status} />
|
|
179
222
|
<span className="text-fg truncate text-sm">{label}</span>
|
|
180
223
|
{meta ? <span className="text-fg-subtle min-w-0 truncate text-xs">{meta}</span> : null}
|
|
@@ -185,7 +228,7 @@ export function ChatToolCall({
|
|
|
185
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"
|
|
186
229
|
/>
|
|
187
230
|
</CollapsibleTrigger>
|
|
188
|
-
<CollapsiblePanel className="hogoshi-selectable my-1 ml-[7px] border-border border-l py-1 pr-1 pl-3.5">
|
|
231
|
+
<CollapsiblePanel className="hogoshi-selectable my-1 ml-[7px] min-w-0 border-border border-l py-1 pr-1 pl-3.5">
|
|
189
232
|
{children}
|
|
190
233
|
</CollapsiblePanel>
|
|
191
234
|
</Collapsible>
|
|
@@ -215,7 +258,7 @@ export function ChatDiff({ diff, className, ...props }: { diff: string } & Compo
|
|
|
215
258
|
return (
|
|
216
259
|
<div
|
|
217
260
|
className={cn(
|
|
218
|
-
"border-border bg-bg-subtle max-h-72 overflow-auto rounded-[var(--radius-md)] border font-mono text-xs",
|
|
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",
|
|
219
262
|
className,
|
|
220
263
|
)}
|
|
221
264
|
{...props}
|
|
@@ -229,6 +272,101 @@ export function ChatDiff({ diff, className, ...props }: { diff: string } & Compo
|
|
|
229
272
|
);
|
|
230
273
|
}
|
|
231
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
|
+
|
|
232
370
|
export function ChatComposer({
|
|
233
371
|
value,
|
|
234
372
|
onValueChange,
|
|
@@ -238,6 +376,12 @@ export function ChatComposer({
|
|
|
238
376
|
busy = false,
|
|
239
377
|
placeholder,
|
|
240
378
|
disabled,
|
|
379
|
+
context,
|
|
380
|
+
onToggleContextPin,
|
|
381
|
+
onRemoveContext,
|
|
382
|
+
model,
|
|
383
|
+
models,
|
|
384
|
+
onModelChange,
|
|
241
385
|
actions,
|
|
242
386
|
className,
|
|
243
387
|
}: {
|
|
@@ -249,6 +393,12 @@ export function ChatComposer({
|
|
|
249
393
|
busy?: boolean;
|
|
250
394
|
placeholder?: string;
|
|
251
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;
|
|
252
402
|
actions?: ReactNode;
|
|
253
403
|
className?: string;
|
|
254
404
|
}) {
|
|
@@ -268,6 +418,18 @@ export function ChatComposer({
|
|
|
268
418
|
className,
|
|
269
419
|
)}
|
|
270
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}
|
|
271
433
|
<textarea
|
|
272
434
|
ref={textarea}
|
|
273
435
|
rows={1}
|
|
@@ -283,10 +445,16 @@ export function ChatComposer({
|
|
|
283
445
|
onSubmit?.();
|
|
284
446
|
}
|
|
285
447
|
}}
|
|
286
|
-
className=
|
|
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
|
+
)}
|
|
287
452
|
/>
|
|
288
453
|
<div className="flex items-center justify-between gap-2 px-2 pb-2">
|
|
289
|
-
<div className="flex min-w-0 items-center gap-0.5">
|
|
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>
|
|
290
458
|
{busy ? (
|
|
291
459
|
onStop ? (
|
|
292
460
|
<Button size="icon-sm" variant="ghost" aria-label="Stop" onClick={onStop}>
|
|
@@ -402,21 +570,20 @@ export function ChatMedia({
|
|
|
402
570
|
actions?: ReactNode;
|
|
403
571
|
className?: string;
|
|
404
572
|
}) {
|
|
405
|
-
const single = images.length === 1;
|
|
406
573
|
if (images.length === 0) return null;
|
|
574
|
+
const single = images.length === 1;
|
|
407
575
|
return (
|
|
408
|
-
<div className={cn("group/media relative max-w-lg", className)}>
|
|
576
|
+
<div className={cn("group/media relative min-w-0 max-w-lg", className)}>
|
|
409
577
|
{single ? (
|
|
410
|
-
<Image
|
|
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
|
+
/>
|
|
411
584
|
) : (
|
|
412
|
-
<ImageGallery images={images} />
|
|
585
|
+
<ImageGallery images={images} className="min-w-0" />
|
|
413
586
|
)}
|
|
414
|
-
{pinned ? (
|
|
415
|
-
<Badge tone="accent" className="absolute top-2 left-2 gap-1 py-0.5">
|
|
416
|
-
<Icon icon={Pin02SolidIcon} size={12} />
|
|
417
|
-
Pinned
|
|
418
|
-
</Badge>
|
|
419
|
-
) : null}
|
|
420
587
|
{onPin || actions ? (
|
|
421
588
|
<div
|
|
422
589
|
className={cn(
|
|
@@ -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
|
@@ -30,7 +30,7 @@ export {
|
|
|
30
30
|
ChatThinking,
|
|
31
31
|
ChatToolCall,
|
|
32
32
|
} from "./components/chat";
|
|
33
|
-
export type { ChatSuggestItem, ChatToolStatus } from "./components/chat";
|
|
33
|
+
export type { ChatContextItem, ChatModelOption, ChatSuggestItem, ChatToolStatus } from "./components/chat";
|
|
34
34
|
export { BootScreen, PageLoader } from "./components/page-loader";
|
|
35
35
|
export { Breadcrumb } from "./components/breadcrumb";
|
|
36
36
|
export type { BreadcrumbItem } from "./components/breadcrumb";
|