@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogoshi/ui",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 className="hogoshi-code w-full min-w-0 max-w-full p-4 pr-24 font-mono text-xs wrap-anywhere whitespace-pre-wrap">
114
- <code className="wrap-anywhere whitespace-pre-wrap" dangerouslySetInnerHTML={{ __html: painted.html }} />
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
@@ -330,6 +330,11 @@ nav,
330
330
  to { opacity: 1; transform: scale(1); }
331
331
  }
332
332
 
333
+ @keyframes chat-caret {
334
+ 0%, 55% { opacity: 1; }
335
+ 56%, 100% { opacity: 0; }
336
+ }
337
+
333
338
  html[data-theme-switching] body {
334
339
  pointer-events: none !important;
335
340
  }