@hogoshi/ui 0.1.7 → 0.1.9

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.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,446 @@
1
+ "use client";
2
+
3
+ import {
4
+ Alert01Icon,
5
+ ArrowDown01Icon,
6
+ ArrowUp02Icon,
7
+ Cancel01Icon,
8
+ Pin02Icon,
9
+ StopCircleIcon,
10
+ Tick02Icon,
11
+ } 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";
19
+ import { cn } from "../lib/cn";
20
+ import type { AppIcon } from "./icon";
21
+ import { Icon } from "./icon";
22
+ import { Image, ImageGallery, type ViewerImage } from "./image-viewer";
23
+ import { Button } from "./button";
24
+ import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "./collapsible";
25
+ import { Spinner } from "./spinner";
26
+ import { Badge } from "./badge";
27
+
28
+ const workingText =
29
+ "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";
30
+
31
+ function ChatCaret() {
32
+ return (
33
+ <span
34
+ aria-hidden
35
+ 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"
36
+ />
37
+ );
38
+ }
39
+
40
+ export function ChatMessage({
41
+ role,
42
+ streaming = false,
43
+ className,
44
+ children,
45
+ }: {
46
+ role: "user" | "assistant";
47
+ streaming?: boolean;
48
+ className?: string;
49
+ children: ReactNode;
50
+ }) {
51
+ return (
52
+ <div className={cn("group/chat flex w-full flex-col", role === "user" && "items-end", className)}>
53
+ {role === "user" ? (
54
+ <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
+ {children}
56
+ {streaming ? <ChatCaret /> : null}
57
+ </div>
58
+ ) : (
59
+ <div className="hogoshi-selectable text-fg w-full min-w-0 text-sm leading-relaxed">
60
+ {children}
61
+ {streaming ? <ChatCaret /> : null}
62
+ </div>
63
+ )}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export function ChatActions({ className, ...props }: ComponentProps<"div">) {
69
+ return (
70
+ <div
71
+ className={cn(
72
+ "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",
73
+ className,
74
+ )}
75
+ {...props}
76
+ />
77
+ );
78
+ }
79
+
80
+ export function ChatThinking({
81
+ status,
82
+ state = "working",
83
+ reasoning,
84
+ defaultOpen = false,
85
+ className,
86
+ }: {
87
+ status: ReactNode;
88
+ state?: "working" | "attention";
89
+ reasoning?: ReactNode;
90
+ defaultOpen?: boolean;
91
+ className?: string;
92
+ }) {
93
+ const attention = state === "attention";
94
+ const label = (
95
+ <span className={cn("text-xs font-medium", attention ? "text-warning" : workingText)}>{status}</span>
96
+ );
97
+
98
+ if (!reasoning) {
99
+ return (
100
+ <div className={cn("flex items-center gap-2", className)}>
101
+ {attention ? (
102
+ <Icon
103
+ icon={Alert01Icon}
104
+ variant="stroke"
105
+ size={14}
106
+ className="text-warning animate-[loader-pulse_calc(var(--motion-duration)*2)_var(--motion-ease)_infinite]"
107
+ />
108
+ ) : null}
109
+ {label}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ return (
115
+ <Collapsible defaultOpen={defaultOpen} className={className}>
116
+ <CollapsibleTrigger className="group/thinking py-0.5">
117
+ <Icon
118
+ icon={ArrowDown01Icon}
119
+ variant="stroke"
120
+ size={12}
121
+ className="text-fg-subtle transition-transform duration-[var(--motion-duration)] ease-[var(--motion-ease)] group-data-[panel-open]/thinking:rotate-180"
122
+ />
123
+ {label}
124
+ </CollapsibleTrigger>
125
+ <CollapsiblePanel className="hogoshi-selectable text-fg-muted py-1 pl-5 text-xs leading-relaxed">
126
+ {reasoning}
127
+ </CollapsiblePanel>
128
+ </Collapsible>
129
+ );
130
+ }
131
+
132
+ export type ChatToolStatus = "running" | "success" | "error" | "attention";
133
+
134
+ function ToolStatusIcon({ status }: { status: ChatToolStatus }) {
135
+ if (status === "running") return <Spinner size="sm" className="text-fg-muted shrink-0" />;
136
+ if (status === "success")
137
+ return <Icon icon={Tick02Icon} variant="stroke" size={16} className="text-success shrink-0" />;
138
+ if (status === "error")
139
+ return <Icon icon={Cancel01Icon} variant="stroke" size={16} className="text-danger shrink-0" />;
140
+ return (
141
+ <Icon
142
+ icon={Alert01Icon}
143
+ variant="stroke"
144
+ size={16}
145
+ className="text-warning animate-[loader-pulse_calc(var(--motion-duration)*2)_var(--motion-ease)_infinite] shrink-0"
146
+ />
147
+ );
148
+ }
149
+
150
+ export function ChatToolCall({
151
+ status,
152
+ label,
153
+ meta,
154
+ defaultOpen = false,
155
+ className,
156
+ children,
157
+ }: {
158
+ status: ChatToolStatus;
159
+ label: ReactNode;
160
+ meta?: ReactNode;
161
+ defaultOpen?: boolean;
162
+ className?: string;
163
+ children?: ReactNode;
164
+ }) {
165
+ if (!children) {
166
+ return (
167
+ <div className={cn("flex min-w-0 items-center gap-2", className)}>
168
+ <ToolStatusIcon status={status} />
169
+ <span className="text-fg truncate text-sm">{label}</span>
170
+ {meta ? <span className="text-fg-subtle min-w-0 truncate text-xs">{meta}</span> : null}
171
+ </div>
172
+ );
173
+ }
174
+
175
+ return (
176
+ <Collapsible defaultOpen={defaultOpen} className={className}>
177
+ <CollapsibleTrigger className="group/tool gap-2 py-0.5">
178
+ <ToolStatusIcon status={status} />
179
+ <span className="text-fg truncate text-sm">{label}</span>
180
+ {meta ? <span className="text-fg-subtle min-w-0 truncate text-xs">{meta}</span> : null}
181
+ <Icon
182
+ icon={ArrowDown01Icon}
183
+ variant="stroke"
184
+ size={12}
185
+ 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
+ />
187
+ </CollapsibleTrigger>
188
+ <CollapsiblePanel className="hogoshi-selectable my-1 ml-[7px] border-border border-l py-1 pr-1 pl-3.5">
189
+ {children}
190
+ </CollapsiblePanel>
191
+ </Collapsible>
192
+ );
193
+ }
194
+
195
+ type DiffKind = "file" | "hunk" | "add" | "del" | "ctx";
196
+
197
+ const diffLineClass: Record<DiffKind, string> = {
198
+ file: "text-fg-subtle",
199
+ hunk: "text-accent bg-accent-muted/40",
200
+ add: "text-success bg-success/10",
201
+ del: "text-danger bg-danger/10",
202
+ ctx: "text-fg-muted",
203
+ };
204
+
205
+ function diffKind(line: string): DiffKind {
206
+ if (line.startsWith("diff --git") || line.startsWith("+++") || line.startsWith("---")) return "file";
207
+ if (line.startsWith("@@")) return "hunk";
208
+ if (line.startsWith("+")) return "add";
209
+ if (line.startsWith("-")) return "del";
210
+ return "ctx";
211
+ }
212
+
213
+ export function ChatDiff({ diff, className, ...props }: { diff: string } & ComponentProps<"div">) {
214
+ const lines = diff.replace(/\n$/, "").split("\n");
215
+ return (
216
+ <div
217
+ className={cn(
218
+ "border-border bg-bg-subtle max-h-72 overflow-auto rounded-[var(--radius-md)] border font-mono text-xs",
219
+ className,
220
+ )}
221
+ {...props}
222
+ >
223
+ {lines.map((line, index) => (
224
+ <div key={index} className={cn("px-3 py-px whitespace-pre", diffLineClass[diffKind(line)])}>
225
+ {line || " "}
226
+ </div>
227
+ ))}
228
+ </div>
229
+ );
230
+ }
231
+
232
+ export function ChatComposer({
233
+ value,
234
+ onValueChange,
235
+ onSubmit,
236
+ onStop,
237
+ onKeyDown,
238
+ busy = false,
239
+ placeholder,
240
+ disabled,
241
+ actions,
242
+ className,
243
+ }: {
244
+ value: string;
245
+ onValueChange: (value: string) => void;
246
+ onSubmit?: () => void;
247
+ onStop?: () => void;
248
+ onKeyDown?: ComponentProps<"textarea">["onKeyDown"];
249
+ busy?: boolean;
250
+ placeholder?: string;
251
+ disabled?: boolean;
252
+ actions?: ReactNode;
253
+ className?: string;
254
+ }) {
255
+ const textarea = useRef<HTMLTextAreaElement>(null);
256
+
257
+ useEffect(() => {
258
+ const el = textarea.current;
259
+ if (!el) return;
260
+ el.style.height = "auto";
261
+ el.style.height = `${Math.min(el.scrollHeight, 192)}px`;
262
+ }, [value]);
263
+
264
+ return (
265
+ <div
266
+ className={cn(
267
+ "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",
268
+ className,
269
+ )}
270
+ >
271
+ <textarea
272
+ ref={textarea}
273
+ rows={1}
274
+ value={value}
275
+ placeholder={placeholder}
276
+ disabled={disabled}
277
+ onChange={(event) => onValueChange(event.target.value)}
278
+ onKeyDown={(event) => {
279
+ onKeyDown?.(event);
280
+ if (event.defaultPrevented) return;
281
+ if (event.key === "Enter" && !event.shiftKey && !busy && !disabled) {
282
+ event.preventDefault();
283
+ onSubmit?.();
284
+ }
285
+ }}
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"
287
+ />
288
+ <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>
290
+ {busy ? (
291
+ onStop ? (
292
+ <Button size="icon-sm" variant="ghost" aria-label="Stop" onClick={onStop}>
293
+ <Icon icon={StopCircleIcon} variant="stroke" />
294
+ </Button>
295
+ ) : null
296
+ ) : (
297
+ <Button
298
+ size="icon-sm"
299
+ variant="primary"
300
+ aria-label="Send"
301
+ disabled={disabled || !value.trim()}
302
+ onClick={() => onSubmit?.()}
303
+ >
304
+ <Icon icon={ArrowUp02Icon} variant="stroke" />
305
+ </Button>
306
+ )}
307
+ </div>
308
+ </div>
309
+ );
310
+ }
311
+
312
+ export interface ChatSuggestItem {
313
+ id: string;
314
+ label: ReactNode;
315
+ hint?: ReactNode;
316
+ icon?: AppIcon;
317
+ group?: string;
318
+ }
319
+
320
+ export function ChatSuggest({
321
+ open,
322
+ items,
323
+ activeIndex = 0,
324
+ onActiveIndexChange,
325
+ onSelect,
326
+ emptyLabel = "No results",
327
+ className,
328
+ }: {
329
+ open: boolean;
330
+ items: ChatSuggestItem[];
331
+ activeIndex?: number;
332
+ onActiveIndexChange?: (index: number) => void;
333
+ onSelect: (item: ChatSuggestItem) => void;
334
+ emptyLabel?: string;
335
+ className?: string;
336
+ }) {
337
+ if (!open) return null;
338
+
339
+ const groups: { name?: string; items: { item: ChatSuggestItem; index: number }[] }[] = [];
340
+ let flat = 0;
341
+ for (const item of items) {
342
+ const last = groups[groups.length - 1];
343
+ if (last && last.name === item.group) {
344
+ last.items.push({ item, index: flat });
345
+ } else {
346
+ groups.push({ name: item.group, items: [{ item, index: flat }] });
347
+ }
348
+ flat += 1;
349
+ }
350
+
351
+ return (
352
+ <div
353
+ role="listbox"
354
+ className={cn(
355
+ "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",
356
+ className,
357
+ )}
358
+ >
359
+ {items.length === 0 ? (
360
+ <p className="text-fg-muted px-2 py-3 text-center text-sm">{emptyLabel}</p>
361
+ ) : (
362
+ groups.map((group, groupIndex) => (
363
+ <div key={group.name ?? groupIndex}>
364
+ {group.name ? (
365
+ <p className="text-fg-subtle px-2 py-1.5 text-[11px] font-medium tracking-wider uppercase">{group.name}</p>
366
+ ) : null}
367
+ {group.items.map(({ item, index }) => (
368
+ <button
369
+ key={item.id}
370
+ type="button"
371
+ role="option"
372
+ aria-selected={index === activeIndex}
373
+ className={cn(
374
+ "flex w-full cursor-pointer items-center gap-2 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm outline-none",
375
+ index === activeIndex ? "bg-bg-subtle text-fg" : "text-fg-muted",
376
+ )}
377
+ onMouseEnter={() => onActiveIndexChange?.(index)}
378
+ onClick={() => onSelect(item)}
379
+ >
380
+ {item.icon ? <Icon icon={item.icon} variant="stroke" size={16} className="text-fg-muted shrink-0" /> : null}
381
+ <span className="min-w-0 flex-1 truncate text-left">{item.label}</span>
382
+ {item.hint ? <span className="text-fg-subtle shrink-0 text-xs">{item.hint}</span> : null}
383
+ </button>
384
+ ))}
385
+ </div>
386
+ ))
387
+ )}
388
+ </div>
389
+ );
390
+ }
391
+
392
+ export function ChatMedia({
393
+ images,
394
+ pinned = false,
395
+ onPin,
396
+ actions,
397
+ className,
398
+ }: {
399
+ images: ViewerImage[];
400
+ pinned?: boolean;
401
+ onPin?: () => void;
402
+ actions?: ReactNode;
403
+ className?: string;
404
+ }) {
405
+ const single = images.length === 1;
406
+ if (images.length === 0) return null;
407
+ return (
408
+ <div className={cn("group/media relative max-w-lg", className)}>
409
+ {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" />
411
+ ) : (
412
+ <ImageGallery images={images} />
413
+ )}
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}
441
+ {actions}
442
+ </div>
443
+ ) : null}
444
+ </div>
445
+ );
446
+ }
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import { Dialog as BaseDialog } from "@base-ui/react/dialog";
4
- import { FullScreenIcon } from "@hugeicons-pro/core-solid-rounded";
5
4
  import {
6
5
  ArrowLeft01Icon,
7
6
  ArrowRight01Icon,
@@ -24,9 +23,7 @@ import {
24
23
  import { cn } from "../lib/cn";
25
24
  import { ContextMenuContent, ContextMenuItem, ContextMenuRoot, ContextMenuTrigger } from "./context-menu";
26
25
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./dropdown-menu";
27
- import { CollapseScreenIcon } from "./collapse-screen-icon";
28
26
  import { Icon } from "./icon";
29
- import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
30
27
  import { backdropMotion, popupMotion } from "./popup";
31
28
 
32
29
  export interface ViewerImage {
@@ -320,28 +317,11 @@ export function ImageGallery({
320
317
  className?: string;
321
318
  imageClassName?: string;
322
319
  }) {
323
- const root = useRef<HTMLDivElement>(null);
324
320
  const [open, setOpen] = useState(false);
325
321
  const [index, setIndex] = useState(0);
326
- const [full, setFull] = useState(false);
327
- useEffect(() => {
328
- const sync = () => setFull(document.fullscreenElement === root.current);
329
- document.addEventListener("fullscreenchange", sync);
330
- return () => document.removeEventListener("fullscreenchange", sync);
331
- }, []);
332
- const toggleFull = () => {
333
- const node = root.current as (HTMLDivElement & { webkitRequestFullscreen?: () => void }) | null;
334
- if (document.fullscreenElement) void document.exitFullscreen();
335
- else if (node?.requestFullscreen) void node.requestFullscreen();
336
- else node?.webkitRequestFullscreen?.();
337
- };
338
322
  return (
339
323
  <>
340
- <div
341
- ref={root}
342
- className={cn("relative min-w-0 pb-10", full && "bg-bg flex h-full w-full items-center justify-center p-6 pb-16")}
343
- >
344
- <div className={cn("grid min-w-0 grid-cols-[repeat(auto-fill,minmax(min(100%,8rem),1fr))] gap-2", full && "w-full max-w-5xl", className)}>
324
+ <div className={cn("grid min-w-0 grid-cols-[repeat(auto-fill,minmax(min(100%,8rem),1fr))] gap-2", className)}>
345
325
  {images.map((image, at) => (
346
326
  <button
347
327
  key={`${image.src}-${at}`}
@@ -366,23 +346,6 @@ export function ImageGallery({
366
346
  </button>
367
347
  ))}
368
348
  </div>
369
- <Tooltip>
370
- <TooltipTrigger
371
- delay={0}
372
- render={
373
- <button
374
- type="button"
375
- aria-label={full ? "Exit fullscreen" : "Fullscreen"}
376
- className="text-fg-muted hover:bg-bg-muted hover:text-fg absolute right-2 bottom-2 inline-flex size-7 items-center justify-center rounded-[var(--radius-sm)]"
377
- onClick={toggleFull}
378
- />
379
- }
380
- >
381
- <Icon icon={full ? CollapseScreenIcon : FullScreenIcon} size={16} />
382
- </TooltipTrigger>
383
- <TooltipContent>{full ? "Exit fullscreen" : "Fullscreen"}</TooltipContent>
384
- </Tooltip>
385
- </div>
386
349
  <ImageViewer images={images} index={index} open={open} onOpenChange={setOpen} onIndexChange={setIndex} />
387
350
  </>
388
351
  );
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 { 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
  }