@hogoshi/ui 0.1.9 → 0.1.11

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.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -4,26 +4,24 @@ import {
4
4
  Alert01Icon,
5
5
  ArrowDown01Icon,
6
6
  ArrowUp02Icon,
7
+ Attachment01Icon,
7
8
  Cancel01Icon,
8
- Pin02Icon,
9
+ File01Icon,
10
+ FlashIcon,
9
11
  StopCircleIcon,
10
12
  Tick02Icon,
13
+ Upload04Icon,
11
14
  } from "@hugeicons-pro/core-stroke-rounded";
12
- 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";
23
+ import { Separator } from "./separator";
25
24
  import { Spinner } from "./spinner";
26
- import { Badge } from "./badge";
27
25
 
28
26
  const workingText =
29
27
  "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 +38,52 @@ function ChatCaret() {
40
38
  export function ChatMessage({
41
39
  role,
42
40
  streaming = false,
41
+ attachments,
42
+ actions,
43
43
  className,
44
44
  children,
45
45
  }: {
46
46
  role: "user" | "assistant";
47
47
  streaming?: boolean;
48
+ attachments?: ViewerImage[];
49
+ actions?: ReactNode;
48
50
  className?: string;
49
51
  children: ReactNode;
50
52
  }) {
53
+ const [viewing, setViewing] = useState(false);
54
+ const [viewIndex, setViewIndex] = useState(0);
55
+
51
56
  return (
52
- <div className={cn("group/chat flex w-full flex-col", role === "user" && "items-end", className)}>
57
+ <div className={cn("group/chat flex w-full min-w-0 flex-col", role === "user" && "items-end", className)}>
58
+ {attachments?.length ? (
59
+ <div
60
+ className={cn(
61
+ "mb-2 flex w-full max-w-[85%] flex-wrap gap-1.5",
62
+ role === "user" ? "justify-end" : "justify-start",
63
+ )}
64
+ >
65
+ {attachments.map((image, at) => (
66
+ <button
67
+ key={`${image.src}-${at}`}
68
+ type="button"
69
+ aria-label={`Open ${image.alt ?? "attachment"}`}
70
+ 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"
71
+ onClick={() => {
72
+ setViewIndex(at);
73
+ setViewing(true);
74
+ }}
75
+ >
76
+ <img
77
+ src={image.src}
78
+ alt={image.alt ?? ""}
79
+ loading="lazy"
80
+ draggable={false}
81
+ className="size-full object-cover transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-hover/attachment:scale-[1.05]"
82
+ />
83
+ </button>
84
+ ))}
85
+ </div>
86
+ ) : null}
53
87
  {role === "user" ? (
54
88
  <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
89
  {children}
@@ -61,6 +95,16 @@ export function ChatMessage({
61
95
  {streaming ? <ChatCaret /> : null}
62
96
  </div>
63
97
  )}
98
+ {actions}
99
+ {attachments?.length ? (
100
+ <ImageViewer
101
+ images={attachments}
102
+ index={viewIndex}
103
+ open={viewing}
104
+ onOpenChange={setViewing}
105
+ onIndexChange={setViewIndex}
106
+ />
107
+ ) : null}
64
108
  </div>
65
109
  );
66
110
  }
@@ -97,7 +141,7 @@ export function ChatThinking({
97
141
 
98
142
  if (!reasoning) {
99
143
  return (
100
- <div className={cn("flex items-center gap-2", className)}>
144
+ <div className={cn("flex min-w-0 items-center gap-2", className)}>
101
145
  {attention ? (
102
146
  <Icon
103
147
  icon={Alert01Icon}
@@ -112,17 +156,17 @@ export function ChatThinking({
112
156
  }
113
157
 
114
158
  return (
115
- <Collapsible defaultOpen={defaultOpen} className={className}>
116
- <CollapsibleTrigger className="group/thinking py-0.5">
159
+ <Collapsible defaultOpen={defaultOpen} className={cn("w-full min-w-0", className)}>
160
+ <CollapsibleTrigger className="group/thinking min-w-0 gap-2 py-0.5">
117
161
  <Icon
118
162
  icon={ArrowDown01Icon}
119
163
  variant="stroke"
120
164
  size={12}
121
- className="text-fg-subtle transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-data-[panel-open]/thinking:rotate-180"
165
+ className="text-fg-subtle shrink-0 transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-data-[panel-open]/thinking:rotate-180"
122
166
  />
123
- {label}
167
+ <span className="truncate">{label}</span>
124
168
  </CollapsibleTrigger>
125
- <CollapsiblePanel className="hogoshi-selectable text-fg-muted py-1 pl-5 text-xs leading-relaxed">
169
+ <CollapsiblePanel className="hogoshi-selectable text-fg-muted min-w-0 py-1 pl-5 text-xs leading-relaxed">
126
170
  {reasoning}
127
171
  </CollapsiblePanel>
128
172
  </Collapsible>
@@ -173,8 +217,8 @@ export function ChatToolCall({
173
217
  }
174
218
 
175
219
  return (
176
- <Collapsible defaultOpen={defaultOpen} className={className}>
177
- <CollapsibleTrigger className="group/tool gap-2 py-0.5">
220
+ <Collapsible defaultOpen={defaultOpen} className={cn("w-full min-w-0", className)}>
221
+ <CollapsibleTrigger className="group/tool min-w-0 gap-2 py-0.5">
178
222
  <ToolStatusIcon status={status} />
179
223
  <span className="text-fg truncate text-sm">{label}</span>
180
224
  {meta ? <span className="text-fg-subtle min-w-0 truncate text-xs">{meta}</span> : null}
@@ -185,7 +229,7 @@ export function ChatToolCall({
185
229
  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
230
  />
187
231
  </CollapsibleTrigger>
188
- <CollapsiblePanel className="hogoshi-selectable my-1 ml-[7px] border-border border-l py-1 pr-1 pl-3.5">
232
+ <CollapsiblePanel className="hogoshi-selectable my-1 ml-[7px] min-w-0 border-border border-l py-1 pr-1 pl-3.5">
189
233
  {children}
190
234
  </CollapsiblePanel>
191
235
  </Collapsible>
@@ -215,7 +259,7 @@ export function ChatDiff({ diff, className, ...props }: { diff: string } & Compo
215
259
  return (
216
260
  <div
217
261
  className={cn(
218
- "border-border bg-bg-subtle max-h-72 overflow-auto rounded-[var(--radius-md)] border font-mono text-xs",
262
+ "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
263
  className,
220
264
  )}
221
265
  {...props}
@@ -229,6 +273,139 @@ export function ChatDiff({ diff, className, ...props }: { diff: string } & Compo
229
273
  );
230
274
  }
231
275
 
276
+ export interface ChatContextItem {
277
+ id: string;
278
+ label: string;
279
+ src?: string;
280
+ icon?: AppIcon;
281
+ }
282
+
283
+ export interface ChatModelOption {
284
+ id: string;
285
+ label: string;
286
+ hint?: string;
287
+ }
288
+
289
+ function ContextChip({ item, onRemove }: { item: ChatContextItem; onRemove?: () => void }) {
290
+ return (
291
+ <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">
292
+ {item.src ? (
293
+ <img src={item.src} alt="" draggable={false} className="size-4 shrink-0 rounded-full object-cover" />
294
+ ) : (
295
+ <Icon icon={item.icon ?? File01Icon} variant="stroke" size={12} className="text-fg-muted shrink-0" />
296
+ )}
297
+ <span className="min-w-0 truncate text-xs">{item.label}</span>
298
+ {onRemove ? (
299
+ <button
300
+ type="button"
301
+ aria-label={`Remove ${item.label}`}
302
+ onClick={onRemove}
303
+ 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"
304
+ >
305
+ <Icon icon={Cancel01Icon} variant="stroke" size={12} />
306
+ </button>
307
+ ) : null}
308
+ </span>
309
+ );
310
+ }
311
+
312
+ function ModelMenu({
313
+ model,
314
+ models,
315
+ onSelect,
316
+ }: {
317
+ model: ChatModelOption;
318
+ models: ChatModelOption[];
319
+ onSelect?: (id: string) => void;
320
+ }) {
321
+ return (
322
+ <DropdownMenu>
323
+ <DropdownMenuTrigger
324
+ render={
325
+ <Button size="sm" variant="ghost" aria-label="Select model" className="text-fg-muted h-7 px-1.5" />
326
+ }
327
+ >
328
+ <span className="inline-flex items-center gap-1.5">
329
+ <Icon icon={FlashIcon} variant="stroke" size={14} className="text-fg-subtle shrink-0" />
330
+ <span className="max-w-40 truncate text-xs">{model.label}</span>
331
+ </span>
332
+ </DropdownMenuTrigger>
333
+ <DropdownMenuContent>
334
+ {models.map((option) => (
335
+ <DropdownMenuItem key={option.id} onClick={() => onSelect?.(option.id)}>
336
+ {option.id === model.id ? (
337
+ <Icon icon={Tick02Icon} variant="stroke" size={16} className="text-accent shrink-0" />
338
+ ) : (
339
+ <span aria-hidden className="size-4 shrink-0" />
340
+ )}
341
+ <span className="truncate">{option.label}</span>
342
+ {option.hint ? <span className="text-fg-subtle ml-auto text-xs">{option.hint}</span> : null}
343
+ </DropdownMenuItem>
344
+ ))}
345
+ </DropdownMenuContent>
346
+ </DropdownMenu>
347
+ );
348
+ }
349
+
350
+ export function ChatAttach({
351
+ items = [],
352
+ onInsert,
353
+ onUpload,
354
+ accept,
355
+ multiple = true,
356
+ className,
357
+ }: {
358
+ items?: ChatSuggestItem[];
359
+ onInsert?: (id: string) => void;
360
+ onUpload?: (files: File[]) => void;
361
+ accept?: string;
362
+ multiple?: boolean;
363
+ className?: string;
364
+ }) {
365
+ const input = useRef<HTMLInputElement>(null);
366
+ return (
367
+ <>
368
+ <input
369
+ ref={input}
370
+ type="file"
371
+ accept={accept}
372
+ multiple={multiple}
373
+ className="hidden"
374
+ onChange={(event) => {
375
+ const files = [...(event.target.files ?? [])];
376
+ event.target.value = "";
377
+ if (files.length) onUpload?.(files);
378
+ }}
379
+ />
380
+ <DropdownMenu>
381
+ <DropdownMenuTrigger render={<Button size="icon-sm" variant="ghost" aria-label="Attach" className={className} />}>
382
+ <Icon icon={Attachment01Icon} variant="stroke" />
383
+ </DropdownMenuTrigger>
384
+ <DropdownMenuContent>
385
+ {items.length ? (
386
+ <>
387
+ <p className="text-fg-subtle px-2 py-1.5 text-[11px] font-medium tracking-wider uppercase">Project files</p>
388
+ <div className="max-h-60 overflow-y-auto overscroll-contain">
389
+ {items.map((item) => (
390
+ <DropdownMenuItem key={item.id} onClick={() => onInsert?.(item.id)}>
391
+ <Icon icon={item.icon ?? File01Icon} variant="stroke" size={16} className="text-fg-muted shrink-0" />
392
+ <span className="min-w-0 truncate">{item.label}</span>
393
+ </DropdownMenuItem>
394
+ ))}
395
+ </div>
396
+ <Separator className="my-1" />
397
+ </>
398
+ ) : null}
399
+ <DropdownMenuItem onClick={() => input.current?.click()}>
400
+ <Icon icon={Upload04Icon} variant="stroke" size={16} className="text-fg-muted shrink-0" />
401
+ Upload from computer
402
+ </DropdownMenuItem>
403
+ </DropdownMenuContent>
404
+ </DropdownMenu>
405
+ </>
406
+ );
407
+ }
408
+
232
409
  export function ChatComposer({
233
410
  value,
234
411
  onValueChange,
@@ -238,6 +415,11 @@ export function ChatComposer({
238
415
  busy = false,
239
416
  placeholder,
240
417
  disabled,
418
+ context,
419
+ onRemoveContext,
420
+ model,
421
+ models,
422
+ onModelChange,
241
423
  actions,
242
424
  className,
243
425
  }: {
@@ -249,6 +431,11 @@ export function ChatComposer({
249
431
  busy?: boolean;
250
432
  placeholder?: string;
251
433
  disabled?: boolean;
434
+ context?: ChatContextItem[];
435
+ onRemoveContext?: (id: string) => void;
436
+ model?: ChatModelOption;
437
+ models?: ChatModelOption[];
438
+ onModelChange?: (id: string) => void;
252
439
  actions?: ReactNode;
253
440
  className?: string;
254
441
  }) {
@@ -268,6 +455,17 @@ export function ChatComposer({
268
455
  className,
269
456
  )}
270
457
  >
458
+ {context?.length ? (
459
+ <div className="flex flex-wrap gap-1.5 px-2.5 pt-2.5">
460
+ {context.map((item) => (
461
+ <ContextChip
462
+ key={item.id}
463
+ item={item}
464
+ onRemove={onRemoveContext ? () => onRemoveContext(item.id) : undefined}
465
+ />
466
+ ))}
467
+ </div>
468
+ ) : null}
271
469
  <textarea
272
470
  ref={textarea}
273
471
  rows={1}
@@ -283,10 +481,16 @@ export function ChatComposer({
283
481
  onSubmit?.();
284
482
  }
285
483
  }}
286
- className="placeholder:text-fg-subtle max-h-48 w-full resize-none bg-transparent px-3.5 pt-3 pb-2 text-sm leading-relaxed outline-none disabled:cursor-not-allowed disabled:opacity-50"
484
+ className={cn(
485
+ "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",
486
+ context?.length ? "pt-2.5" : "pt-3",
487
+ )}
287
488
  />
288
489
  <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">{actions}</div>
490
+ <div className="flex min-w-0 items-center gap-0.5">
491
+ {model && models?.length ? <ModelMenu model={model} models={models} onSelect={onModelChange} /> : null}
492
+ {actions}
493
+ </div>
290
494
  {busy ? (
291
495
  onStop ? (
292
496
  <Button size="icon-sm" variant="ghost" aria-label="Stop" onClick={onStop}>
@@ -391,53 +595,29 @@ export function ChatSuggest({
391
595
 
392
596
  export function ChatMedia({
393
597
  images,
394
- pinned = false,
395
- onPin,
396
598
  actions,
397
599
  className,
398
600
  }: {
399
601
  images: ViewerImage[];
400
- pinned?: boolean;
401
- onPin?: () => void;
402
602
  actions?: ReactNode;
403
603
  className?: string;
404
604
  }) {
405
- const single = images.length === 1;
406
605
  if (images.length === 0) return null;
606
+ const single = images.length === 1;
407
607
  return (
408
- <div className={cn("group/media relative max-w-lg", className)}>
608
+ <div className={cn("group/media relative min-w-0 max-w-lg", className)}>
409
609
  {single ? (
410
- <Image src={images[0].src} alt={images[0].alt} name={images[0].name} className="max-h-80 w-auto rounded-[var(--radius-md)] object-cover" />
610
+ <Image
611
+ src={images[0].src}
612
+ alt={images[0].alt}
613
+ name={images[0].name}
614
+ className="border-border max-h-48 w-auto max-w-full rounded-[var(--radius-md)] border object-cover"
615
+ />
411
616
  ) : (
412
- <ImageGallery images={images} />
617
+ <ImageGallery images={images} className="min-w-0" />
413
618
  )}
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
- {onPin || actions ? (
421
- <div
422
- className={cn(
423
- "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",
424
- pinned ? "opacity-100" : "opacity-0",
425
- )}
426
- >
427
- {onPin ? (
428
- <button
429
- type="button"
430
- aria-label={pinned ? "Unpin from context" : "Pin to context"}
431
- aria-pressed={pinned}
432
- onClick={onPin}
433
- className={cn(
434
- "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",
435
- pinned && "text-accent",
436
- )}
437
- >
438
- <Icon icon={pinned ? Pin02SolidIcon : Pin02Icon} size={14} />
439
- </button>
440
- ) : null}
619
+ {actions ? (
620
+ <div className="absolute top-2 right-2 flex items-center gap-1 opacity-0 transition-opacity duration-[var(--motion-duration)] ease-[var(--motion-ease)] focus-within:opacity-100 group-hover/media:opacity-100">
441
621
  {actions}
442
622
  </div>
443
623
  ) : null}
@@ -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
@@ -22,6 +22,7 @@ export {
22
22
  export { Card, CardDescription, CardHeader, CardTitle } from "./components/card";
23
23
  export {
24
24
  ChatActions,
25
+ ChatAttach,
25
26
  ChatComposer,
26
27
  ChatDiff,
27
28
  ChatMedia,
@@ -30,7 +31,7 @@ export {
30
31
  ChatThinking,
31
32
  ChatToolCall,
32
33
  } from "./components/chat";
33
- export type { ChatSuggestItem, ChatToolStatus } from "./components/chat";
34
+ export type { ChatContextItem, ChatModelOption, ChatSuggestItem, ChatToolStatus } from "./components/chat";
34
35
  export { BootScreen, PageLoader } from "./components/page-loader";
35
36
  export { Breadcrumb } from "./components/breadcrumb";
36
37
  export type { BreadcrumbItem } from "./components/breadcrumb";