@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogoshi/ui",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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="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"
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">{actions}</div>
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 src={images[0].src} alt={images[0].alt} name={images[0].name} className="max-h-80 w-auto rounded-[var(--radius-md)] object-cover" />
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 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
@@ -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";