@tangle-network/ui 1.0.0

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.
Files changed (220) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +33 -0
  4. package/dist/active-sessions-store-CeOmXgv5.d.ts +85 -0
  5. package/dist/artifact-pane-DvJyPWV4.d.ts +24 -0
  6. package/dist/auth.d.ts +74 -0
  7. package/dist/auth.js +15 -0
  8. package/dist/button-CMQuQEW_.d.ts +17 -0
  9. package/dist/chat.d.ts +232 -0
  10. package/dist/chat.js +30 -0
  11. package/dist/chunk-2NFQRQOD.js +1009 -0
  12. package/dist/chunk-2VH6PUXD.js +186 -0
  13. package/dist/chunk-34A66VBG.js +214 -0
  14. package/dist/chunk-3OI2QKFD.js +0 -0
  15. package/dist/chunk-4CLN43XT.js +45 -0
  16. package/dist/chunk-54SQQMMM.js +156 -0
  17. package/dist/chunk-5Z5ZYMOJ.js +0 -0
  18. package/dist/chunk-66BNMOVT.js +167 -0
  19. package/dist/chunk-6BGQA4BQ.js +0 -0
  20. package/dist/chunk-7UO2ZMRQ.js +133 -0
  21. package/dist/chunk-BX6AQMUS.js +183 -0
  22. package/dist/chunk-CD53GZOM.js +59 -0
  23. package/dist/chunk-CSAIKY36.js +54 -0
  24. package/dist/chunk-EEE55AVS.js +1201 -0
  25. package/dist/chunk-GYPQXTJU.js +230 -0
  26. package/dist/chunk-HFL6R6IF.js +37 -0
  27. package/dist/chunk-HJKCSXCH.js +737 -0
  28. package/dist/chunk-LISXUB4D.js +1222 -0
  29. package/dist/chunk-LQS34IGP.js +0 -0
  30. package/dist/chunk-MKTSMWVD.js +109 -0
  31. package/dist/chunk-NKDZ7GZE.js +192 -0
  32. package/dist/chunk-OEX7NZE3.js +321 -0
  33. package/dist/chunk-Q56BYXQF.js +61 -0
  34. package/dist/chunk-Q7EIIWTC.js +0 -0
  35. package/dist/chunk-REJESC5U.js +117 -0
  36. package/dist/chunk-RQGKSCEZ.js +0 -0
  37. package/dist/chunk-RQHJBTEU.js +10 -0
  38. package/dist/chunk-TMFOPHHN.js +299 -0
  39. package/dist/chunk-XGKULLYE.js +40 -0
  40. package/dist/chunk-XIHMJ7ZQ.js +614 -0
  41. package/dist/chunk-YJ2G3XO5.js +1048 -0
  42. package/dist/chunk-YNN4O57I.js +754 -0
  43. package/dist/code-block-DjXf8eOG.d.ts +19 -0
  44. package/dist/document-editor-pane-A5LT5H4N.js +12 -0
  45. package/dist/document-editor-pane-DyDEX_Zm.d.ts +124 -0
  46. package/dist/editor.d.ts +120 -0
  47. package/dist/editor.js +34 -0
  48. package/dist/files.d.ts +175 -0
  49. package/dist/files.js +20 -0
  50. package/dist/hooks.d.ts +56 -0
  51. package/dist/hooks.js +41 -0
  52. package/dist/index.d.ts +43 -0
  53. package/dist/index.js +446 -0
  54. package/dist/markdown.d.ts +15 -0
  55. package/dist/markdown.js +14 -0
  56. package/dist/message-BHWbxBtT.d.ts +15 -0
  57. package/dist/openui.d.ts +115 -0
  58. package/dist/openui.js +12 -0
  59. package/dist/parts-dj7AcUg0.d.ts +36 -0
  60. package/dist/primitives.d.ts +332 -0
  61. package/dist/primitives.js +191 -0
  62. package/dist/run-PfLmDAox.d.ts +41 -0
  63. package/dist/run.d.ts +69 -0
  64. package/dist/run.js +36 -0
  65. package/dist/sdk-hooks.d.ts +285 -0
  66. package/dist/sdk-hooks.js +31 -0
  67. package/dist/stores.d.ts +17 -0
  68. package/dist/stores.js +76 -0
  69. package/dist/tool-call-feed-Bs3MyQMT.d.ts +68 -0
  70. package/dist/tool-display-z4JcDmMQ.d.ts +32 -0
  71. package/dist/tool-previews.d.ts +48 -0
  72. package/dist/tool-previews.js +21 -0
  73. package/dist/types.d.ts +19 -0
  74. package/dist/types.js +1 -0
  75. package/dist/utils.d.ts +45 -0
  76. package/dist/utils.js +32 -0
  77. package/package.json +193 -0
  78. package/src/auth/auth.tsx +228 -0
  79. package/src/auth/index.ts +13 -0
  80. package/src/auth/login-layout.tsx +46 -0
  81. package/src/chat/agent-timeline.stories.tsx +429 -0
  82. package/src/chat/agent-timeline.tsx +360 -0
  83. package/src/chat/chat-container.tsx +486 -0
  84. package/src/chat/chat-input.stories.tsx +142 -0
  85. package/src/chat/chat-input.tsx +389 -0
  86. package/src/chat/chat-message.stories.tsx +237 -0
  87. package/src/chat/chat-message.tsx +129 -0
  88. package/src/chat/index.ts +18 -0
  89. package/src/chat/message-list.stories.tsx +336 -0
  90. package/src/chat/message-list.tsx +79 -0
  91. package/src/chat/thinking-indicator.stories.tsx +56 -0
  92. package/src/chat/thinking-indicator.tsx +30 -0
  93. package/src/chat/user-message.stories.tsx +92 -0
  94. package/src/chat/user-message.tsx +43 -0
  95. package/src/editor/document-editor-pane.tsx +351 -0
  96. package/src/editor/editor-provider.tsx +428 -0
  97. package/src/editor/editor-toolbar.tsx +130 -0
  98. package/src/editor/index.ts +31 -0
  99. package/src/editor/markdown-conversion.ts +21 -0
  100. package/src/editor/markdown-document-editor.tsx +137 -0
  101. package/src/editor/tiptap-editor.tsx +331 -0
  102. package/src/editor/use-editor.ts +221 -0
  103. package/src/files/file-artifact-pane.tsx +183 -0
  104. package/src/files/file-preview.tsx +342 -0
  105. package/src/files/file-tabs.tsx +71 -0
  106. package/src/files/file-tree.tsx +258 -0
  107. package/src/files/index.ts +17 -0
  108. package/src/files/rich-file-tree.stories.tsx +104 -0
  109. package/src/files/rich-file-tree.test.tsx +42 -0
  110. package/src/files/rich-file-tree.tsx +232 -0
  111. package/src/hooks/index.ts +10 -0
  112. package/src/hooks/use-auth.ts +153 -0
  113. package/src/hooks/use-auto-scroll.ts +59 -0
  114. package/src/hooks/use-dropdown-menu.ts +40 -0
  115. package/src/hooks/use-live-time.test.tsx +40 -0
  116. package/src/hooks/use-live-time.ts +27 -0
  117. package/src/hooks/use-realtime-session.ts +319 -0
  118. package/src/hooks/use-run-collapse-state.ts +25 -0
  119. package/src/hooks/use-run-groups.ts +111 -0
  120. package/src/hooks/use-sdk-session.ts +575 -0
  121. package/src/hooks/use-sse-stream.ts +475 -0
  122. package/src/hooks/use-tool-call-stream.ts +96 -0
  123. package/src/index.ts +14 -0
  124. package/src/lib/utils.ts +6 -0
  125. package/src/markdown/code-block.tsx +198 -0
  126. package/src/markdown/index.ts +2 -0
  127. package/src/markdown/markdown.stories.tsx +190 -0
  128. package/src/markdown/markdown.tsx +62 -0
  129. package/src/openui/index.ts +20 -0
  130. package/src/openui/openui-artifact-renderer.tsx +542 -0
  131. package/src/primitives/artifact-pane.tsx +91 -0
  132. package/src/primitives/avatar.stories.tsx +95 -0
  133. package/src/primitives/avatar.tsx +47 -0
  134. package/src/primitives/badge.stories.tsx +57 -0
  135. package/src/primitives/badge.tsx +97 -0
  136. package/src/primitives/button.stories.tsx +48 -0
  137. package/src/primitives/button.tsx +115 -0
  138. package/src/primitives/card.stories.tsx +53 -0
  139. package/src/primitives/card.tsx +98 -0
  140. package/src/primitives/code-block.stories.tsx +115 -0
  141. package/src/primitives/code-block.tsx +22 -0
  142. package/src/primitives/design-tokens.stories.tsx +162 -0
  143. package/src/primitives/dialog.stories.tsx +176 -0
  144. package/src/primitives/dialog.tsx +137 -0
  145. package/src/primitives/drop-zone.stories.tsx +123 -0
  146. package/src/primitives/drop-zone.tsx +131 -0
  147. package/src/primitives/dropdown-menu.stories.tsx +122 -0
  148. package/src/primitives/dropdown-menu.tsx +214 -0
  149. package/src/primitives/empty-state.stories.tsx +81 -0
  150. package/src/primitives/empty-state.tsx +40 -0
  151. package/src/primitives/index.ts +118 -0
  152. package/src/primitives/input.stories.tsx +113 -0
  153. package/src/primitives/input.tsx +136 -0
  154. package/src/primitives/label.stories.tsx +84 -0
  155. package/src/primitives/label.tsx +24 -0
  156. package/src/primitives/progress.stories.tsx +93 -0
  157. package/src/primitives/progress.tsx +50 -0
  158. package/src/primitives/segmented-control.test.tsx +328 -0
  159. package/src/primitives/segmented-control.tsx +154 -0
  160. package/src/primitives/select.stories.tsx +164 -0
  161. package/src/primitives/select.tsx +158 -0
  162. package/src/primitives/sidebar-drop-zone.stories.tsx +100 -0
  163. package/src/primitives/sidebar-drop-zone.tsx +149 -0
  164. package/src/primitives/skeleton.stories.tsx +79 -0
  165. package/src/primitives/skeleton.tsx +55 -0
  166. package/src/primitives/stat-card.stories.tsx +137 -0
  167. package/src/primitives/stat-card.tsx +97 -0
  168. package/src/primitives/switch.stories.tsx +85 -0
  169. package/src/primitives/switch.tsx +28 -0
  170. package/src/primitives/table.stories.tsx +170 -0
  171. package/src/primitives/table.tsx +116 -0
  172. package/src/primitives/tabs.stories.tsx +180 -0
  173. package/src/primitives/tabs.tsx +71 -0
  174. package/src/primitives/terminal-display.stories.tsx +191 -0
  175. package/src/primitives/terminal-display.tsx +189 -0
  176. package/src/primitives/theme-toggle.stories.tsx +32 -0
  177. package/src/primitives/theme-toggle.tsx +96 -0
  178. package/src/primitives/toast.stories.tsx +155 -0
  179. package/src/primitives/toast.tsx +190 -0
  180. package/src/primitives/upload-progress.stories.tsx +120 -0
  181. package/src/primitives/upload-progress.tsx +110 -0
  182. package/src/run/expanded-tool-detail.stories.tsx +182 -0
  183. package/src/run/expanded-tool-detail.tsx +186 -0
  184. package/src/run/index.ts +13 -0
  185. package/src/run/inline-thinking-item.stories.tsx +136 -0
  186. package/src/run/inline-thinking-item.tsx +120 -0
  187. package/src/run/inline-tool-item.stories.tsx +222 -0
  188. package/src/run/inline-tool-item.tsx +190 -0
  189. package/src/run/run-group.stories.tsx +322 -0
  190. package/src/run/run-group.tsx +569 -0
  191. package/src/run/run-item-primitives.tsx +17 -0
  192. package/src/run/tool-call-feed.stories.tsx +294 -0
  193. package/src/run/tool-call-feed.tsx +192 -0
  194. package/src/run/tool-call-step.stories.tsx +198 -0
  195. package/src/run/tool-call-step.tsx +240 -0
  196. package/src/sdk-hooks.ts +38 -0
  197. package/src/stores/active-sessions-store.ts +455 -0
  198. package/src/stores/chat-store.ts +43 -0
  199. package/src/stores/index.ts +2 -0
  200. package/src/tool-previews/command-preview.tsx +116 -0
  201. package/src/tool-previews/diff-preview.tsx +85 -0
  202. package/src/tool-previews/glob-results-preview.tsx +98 -0
  203. package/src/tool-previews/grep-results-preview.tsx +157 -0
  204. package/src/tool-previews/index.ts +22 -0
  205. package/src/tool-previews/preview-primitives.tsx +84 -0
  206. package/src/tool-previews/question-preview.tsx +101 -0
  207. package/src/tool-previews/web-search-preview.tsx +117 -0
  208. package/src/tool-previews/write-file-preview.tsx +80 -0
  209. package/src/types/branding.ts +11 -0
  210. package/src/types/index.ts +5 -0
  211. package/src/types/message.ts +13 -0
  212. package/src/types/parts.ts +51 -0
  213. package/src/types/run.ts +56 -0
  214. package/src/types/tool-display.ts +41 -0
  215. package/src/utils/copy-text.ts +30 -0
  216. package/src/utils/format.test.ts +43 -0
  217. package/src/utils/format.ts +56 -0
  218. package/src/utils/index.ts +10 -0
  219. package/src/utils/time-ago.ts +9 -0
  220. package/src/utils/tool-display.ts +238 -0
@@ -0,0 +1,155 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { useState } from 'react'
3
+ import type { Toast } from './toast'
4
+ import { ToastContainer, ToastProvider, useToast } from './toast'
5
+
6
+ const meta: Meta = {
7
+ title: 'Primitives/Toast',
8
+ parameters: { layout: 'centered', backgrounds: { default: 'dark' } },
9
+ }
10
+
11
+ export default meta
12
+ type Story = StoryObj
13
+
14
+ // Static display of all variants side by side (no auto-dismiss)
15
+ export const AllVariants: Story = {
16
+ name: 'All Variants',
17
+ render: () => {
18
+ const [toasts, setToasts] = useState<Toast[]>([
19
+ {
20
+ id: '1',
21
+ variant: 'success',
22
+ title: 'Session started',
23
+ description: 'sess_01j9x8k2m is running in us-east-1.',
24
+ },
25
+ {
26
+ id: '2',
27
+ variant: 'error',
28
+ title: 'Session failed to start',
29
+ description: 'Container exited with code 1. Check image pull logs.',
30
+ },
31
+ {
32
+ id: '3',
33
+ variant: 'warning',
34
+ title: 'Session nearing timeout',
35
+ description: 'sess_01j9x7r9 will auto-terminate in 5 minutes.',
36
+ },
37
+ {
38
+ id: '4',
39
+ variant: 'info',
40
+ title: 'Snapshot created',
41
+ description: 'snap_01j9xa1b2 saved successfully.',
42
+ },
43
+ {
44
+ id: '5',
45
+ variant: 'default',
46
+ title: 'Copied to clipboard',
47
+ },
48
+ ])
49
+
50
+ return (
51
+ <div className="flex flex-col gap-2 w-96">
52
+ <ToastContainer toasts={toasts} onDismiss={(id) => setToasts((t) => t.filter((x) => x.id !== id))} />
53
+ {toasts.map((t) => (
54
+ // Render inline for story visibility (not fixed positioned)
55
+ <div
56
+ key={t.id}
57
+ className="pointer-events-auto relative flex w-full items-center justify-between gap-3 overflow-hidden rounded-lg border p-4 shadow-lg"
58
+ style={{
59
+ borderColor:
60
+ t.variant === 'success'
61
+ ? 'rgb(34 197 94 / 0.2)'
62
+ : t.variant === 'error'
63
+ ? 'rgb(239 68 68 / 0.2)'
64
+ : t.variant === 'warning'
65
+ ? 'rgb(234 179 8 / 0.2)'
66
+ : t.variant === 'info'
67
+ ? 'rgb(59 130 246 / 0.2)'
68
+ : undefined,
69
+ background:
70
+ t.variant === 'success'
71
+ ? 'rgb(34 197 94 / 0.1)'
72
+ : t.variant === 'error'
73
+ ? 'rgb(239 68 68 / 0.1)'
74
+ : t.variant === 'warning'
75
+ ? 'rgb(234 179 8 / 0.1)'
76
+ : t.variant === 'info'
77
+ ? 'rgb(59 130 246 / 0.1)'
78
+ : undefined,
79
+ }}
80
+ >
81
+ <div>
82
+ <p
83
+ className="font-medium text-sm"
84
+ style={{
85
+ color:
86
+ t.variant === 'success'
87
+ ? 'rgb(74 222 128)'
88
+ : t.variant === 'error'
89
+ ? 'rgb(248 113 113)'
90
+ : t.variant === 'warning'
91
+ ? 'rgb(250 204 21)'
92
+ : t.variant === 'info'
93
+ ? 'rgb(96 165 250)'
94
+ : undefined,
95
+ }}
96
+ >
97
+ {t.title}
98
+ </p>
99
+ {t.description && (
100
+ <p className="mt-0.5 text-sm text-muted-foreground">{t.description}</p>
101
+ )}
102
+ </div>
103
+ </div>
104
+ ))}
105
+ </div>
106
+ )
107
+ },
108
+ }
109
+
110
+ function ToastDemo() {
111
+ const { success, error, warning, info, toast } = useToast()
112
+ return (
113
+ <div className="flex flex-wrap gap-2">
114
+ <button
115
+ onClick={() => success('Session started', 'sess_01j9x8k2m is running in us-east-1.')}
116
+ className="rounded-md bg-green-600/20 border border-green-500/30 px-3 py-2 text-sm text-green-400 hover:bg-green-600/30"
117
+ >
118
+ Success toast
119
+ </button>
120
+ <button
121
+ onClick={() => error('Session failed', 'Container exited with code 1.')}
122
+ className="rounded-md bg-red-600/20 border border-red-500/30 px-3 py-2 text-sm text-red-400 hover:bg-red-600/30"
123
+ >
124
+ Error toast
125
+ </button>
126
+ <button
127
+ onClick={() => warning('Session nearing timeout', 'Auto-terminate in 5 minutes.')}
128
+ className="rounded-md bg-yellow-600/20 border border-yellow-500/30 px-3 py-2 text-sm text-yellow-400 hover:bg-yellow-600/30"
129
+ >
130
+ Warning toast
131
+ </button>
132
+ <button
133
+ onClick={() => info('Snapshot saved', 'snap_01j9xa1b2 created successfully.')}
134
+ className="rounded-md bg-blue-600/20 border border-blue-500/30 px-3 py-2 text-sm text-blue-400 hover:bg-blue-600/30"
135
+ >
136
+ Info toast
137
+ </button>
138
+ <button
139
+ onClick={() => toast({ title: 'Copied to clipboard' })}
140
+ className="rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted"
141
+ >
142
+ Default toast
143
+ </button>
144
+ </div>
145
+ )
146
+ }
147
+
148
+ export const Interactive: Story = {
149
+ name: 'Interactive (with Provider)',
150
+ render: () => (
151
+ <ToastProvider>
152
+ <ToastDemo />
153
+ </ToastProvider>
154
+ ),
155
+ }
@@ -0,0 +1,190 @@
1
+ "use client";
2
+
3
+ import { cva } from "class-variance-authority";
4
+ import {
5
+ AlertCircle,
6
+ AlertTriangle,
7
+ CheckCircle2,
8
+ Info,
9
+ X,
10
+ } from "lucide-react";
11
+ import * as React from "react";
12
+ import { cn } from "../lib/utils";
13
+
14
+ const toastVariants = cva(
15
+ "pointer-events-auto relative flex w-full items-center justify-between gap-3 overflow-hidden rounded-lg border p-4 shadow-lg transition-all",
16
+ {
17
+ variants: {
18
+ variant: {
19
+ default: "border-border bg-background text-foreground",
20
+ success: "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)] text-[var(--surface-success-text)]",
21
+ error: "border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)] text-[var(--surface-danger-text)]",
22
+ warning: "border-[var(--surface-warning-border)] bg-[var(--surface-warning-bg)] text-[var(--surface-warning-text)]",
23
+ info: "border-[var(--surface-info-border)] bg-[var(--surface-info-bg)] text-[var(--surface-info-text)]",
24
+ },
25
+ },
26
+ defaultVariants: {
27
+ variant: "default",
28
+ },
29
+ },
30
+ );
31
+
32
+ const icons = {
33
+ success: CheckCircle2,
34
+ error: AlertCircle,
35
+ warning: AlertTriangle,
36
+ info: Info,
37
+ default: Info,
38
+ };
39
+
40
+ export interface Toast {
41
+ id: string;
42
+ title: string;
43
+ description?: string;
44
+ variant?: "default" | "success" | "error" | "warning" | "info";
45
+ duration?: number;
46
+ }
47
+
48
+ interface ToastProps extends Toast {
49
+ onDismiss: (id: string) => void;
50
+ }
51
+
52
+ function ToastComponent({
53
+ id,
54
+ title,
55
+ description,
56
+ variant = "default",
57
+ onDismiss,
58
+ }: ToastProps) {
59
+ const Icon = icons[variant];
60
+
61
+ return (
62
+ <div
63
+ className={cn(toastVariants({ variant }))}
64
+ role="alert"
65
+ aria-live="polite"
66
+ >
67
+ <div className="flex items-start gap-3">
68
+ <Icon className="h-5 w-5 shrink-0" aria-hidden="true" />
69
+ <div className="flex-1">
70
+ <p className="font-medium text-sm">{title}</p>
71
+ {description && (
72
+ <p className="mt-1 text-sm opacity-80">{description}</p>
73
+ )}
74
+ </div>
75
+ </div>
76
+ <button
77
+ type="button"
78
+ onClick={() => onDismiss(id)}
79
+ className="shrink-0 rounded-md p-1 opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
80
+ aria-label="Dismiss notification"
81
+ >
82
+ <X className="h-4 w-4" />
83
+ </button>
84
+ </div>
85
+ );
86
+ }
87
+
88
+ interface ToastContainerProps {
89
+ toasts: Toast[];
90
+ onDismiss: (id: string) => void;
91
+ }
92
+
93
+ export function ToastContainer({ toasts, onDismiss }: ToastContainerProps) {
94
+ return (
95
+ <div
96
+ className="fixed right-4 bottom-4 z-50 flex max-w-md flex-col gap-2"
97
+ aria-label="Notifications"
98
+ >
99
+ {toasts.map((toast) => (
100
+ <ToastComponent key={toast.id} {...toast} onDismiss={onDismiss} />
101
+ ))}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ // Toast Context and Hook
107
+ type ToastInput = Omit<Toast, "id">;
108
+
109
+ interface ToastContextValue {
110
+ toasts: Toast[];
111
+ toast: (input: ToastInput) => void;
112
+ success: (title: string, description?: string) => void;
113
+ error: (title: string, description?: string) => void;
114
+ warning: (title: string, description?: string) => void;
115
+ info: (title: string, description?: string) => void;
116
+ dismiss: (id: string) => void;
117
+ }
118
+
119
+ const ToastContext = React.createContext<ToastContextValue | null>(null);
120
+
121
+ export function ToastProvider({ children }: { children: React.ReactNode }) {
122
+ const [toasts, setToasts] = React.useState<Toast[]>([]);
123
+
124
+ const dismiss = React.useCallback((id: string) => {
125
+ setToasts((prev) => prev.filter((t) => t.id !== id));
126
+ }, []);
127
+
128
+ const toast = React.useCallback(
129
+ (input: ToastInput) => {
130
+ const id = Math.random().toString(36).slice(2);
131
+ const newToast: Toast = { id, ...input };
132
+ setToasts((prev) => [...prev, newToast]);
133
+
134
+ // Auto-dismiss after duration
135
+ const duration = input.duration ?? 5000;
136
+ if (duration > 0) {
137
+ setTimeout(() => dismiss(id), duration);
138
+ }
139
+ },
140
+ [dismiss],
141
+ );
142
+
143
+ const success = React.useCallback(
144
+ (title: string, description?: string) => {
145
+ toast({ title, description, variant: "success" });
146
+ },
147
+ [toast],
148
+ );
149
+
150
+ const error = React.useCallback(
151
+ (title: string, description?: string) => {
152
+ toast({ title, description, variant: "error" });
153
+ },
154
+ [toast],
155
+ );
156
+
157
+ const warning = React.useCallback(
158
+ (title: string, description?: string) => {
159
+ toast({ title, description, variant: "warning" });
160
+ },
161
+ [toast],
162
+ );
163
+
164
+ const info = React.useCallback(
165
+ (title: string, description?: string) => {
166
+ toast({ title, description, variant: "info" });
167
+ },
168
+ [toast],
169
+ );
170
+
171
+ const value = React.useMemo(
172
+ () => ({ toasts, toast, success, error, warning, info, dismiss }),
173
+ [toasts, toast, success, error, warning, info, dismiss],
174
+ );
175
+
176
+ return (
177
+ <ToastContext.Provider value={value}>
178
+ {children}
179
+ <ToastContainer toasts={toasts} onDismiss={dismiss} />
180
+ </ToastContext.Provider>
181
+ );
182
+ }
183
+
184
+ export function useToast() {
185
+ const context = React.useContext(ToastContext);
186
+ if (!context) {
187
+ throw new Error("useToast must be used within a ToastProvider");
188
+ }
189
+ return context;
190
+ }
@@ -0,0 +1,120 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { UploadProgress, type UploadFile } from './upload-progress'
3
+
4
+ const meta: Meta<typeof UploadProgress> = {
5
+ title: 'Primitives/UploadProgress',
6
+ component: UploadProgress,
7
+ parameters: { layout: 'centered', backgrounds: { default: 'dark' } },
8
+ decorators: [
9
+ (Story) => (
10
+ <div className="w-[420px]">
11
+ <Story />
12
+ </div>
13
+ ),
14
+ ],
15
+ }
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof UploadProgress>
19
+
20
+ const pending: UploadFile = {
21
+ id: '1',
22
+ name: 'dataset.csv',
23
+ size: 204800,
24
+ status: 'pending',
25
+ }
26
+
27
+ const uploading45: UploadFile = {
28
+ id: '2',
29
+ name: 'report-q4-2024.pdf',
30
+ size: 1572864,
31
+ status: 'uploading',
32
+ progress: 45,
33
+ }
34
+
35
+ const uploading0: UploadFile = {
36
+ id: '3',
37
+ name: 'model-weights.bin',
38
+ size: 52428800,
39
+ status: 'uploading',
40
+ progress: 0,
41
+ }
42
+
43
+ const complete: UploadFile = {
44
+ id: '4',
45
+ name: 'config.json',
46
+ size: 2048,
47
+ status: 'complete',
48
+ }
49
+
50
+ const error: UploadFile = {
51
+ id: '5',
52
+ name: 'too-large.zip',
53
+ size: 524288000,
54
+ status: 'error',
55
+ error: 'File exceeds 500 MB limit',
56
+ }
57
+
58
+ export const Pending: Story = {
59
+ name: 'Pending',
60
+ args: { files: [pending] },
61
+ }
62
+
63
+ export const UploadingStart: Story = {
64
+ name: 'Uploading — 0%',
65
+ args: { files: [uploading0], onRemove: (id) => console.log('remove', id) },
66
+ }
67
+
68
+ export const UploadingMid: Story = {
69
+ name: 'Uploading — 45%',
70
+ args: { files: [uploading45], onRemove: (id) => console.log('remove', id) },
71
+ }
72
+
73
+ export const UploadingAlmostDone: Story = {
74
+ name: 'Uploading — 90%',
75
+ args: {
76
+ files: [{ ...uploading45, progress: 90, name: 'export-final.xlsx' }],
77
+ onRemove: (id) => console.log('remove', id),
78
+ },
79
+ }
80
+
81
+ export const Complete: Story = {
82
+ name: 'Complete',
83
+ args: { files: [complete], onRemove: (id) => console.log('remove', id) },
84
+ }
85
+
86
+ export const Error: Story = {
87
+ name: 'Error with retry',
88
+ args: {
89
+ files: [error],
90
+ onRemove: (id) => console.log('remove', id),
91
+ onRetry: (id) => console.log('retry', id),
92
+ },
93
+ }
94
+
95
+ export const ErrorNoRetry: Story = {
96
+ name: 'Error — no retry handler',
97
+ args: {
98
+ files: [{ ...error, error: 'Network timeout' }],
99
+ onRemove: (id) => console.log('remove', id),
100
+ },
101
+ }
102
+
103
+ export const MixedQueue: Story = {
104
+ name: 'Mixed queue',
105
+ args: {
106
+ files: [complete, uploading45, pending, error],
107
+ onRemove: (id) => console.log('remove', id),
108
+ onRetry: (id) => console.log('retry', id),
109
+ },
110
+ }
111
+
112
+ export const Empty: Story = {
113
+ name: 'Empty (renders nothing)',
114
+ args: { files: [] },
115
+ }
116
+
117
+ export const NoActions: Story = {
118
+ name: 'No remove / retry handlers',
119
+ args: { files: [complete, uploading45, error] },
120
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * UploadProgress — file upload status indicators.
3
+ *
4
+ * Shows a list of files being uploaded with progress bars,
5
+ * completion checkmarks, and error states.
6
+ */
7
+
8
+ import { AlertCircle, CheckCircle2, FileText, Loader2, RefreshCw, X } from "lucide-react";
9
+ import { cn } from "../lib/utils";
10
+
11
+ export interface UploadFile {
12
+ id: string;
13
+ name: string;
14
+ size: number;
15
+ status: "pending" | "uploading" | "complete" | "error";
16
+ progress?: number; // 0-100
17
+ error?: string;
18
+ }
19
+
20
+ export interface UploadProgressProps {
21
+ files: UploadFile[];
22
+ onRemove?: (id: string) => void;
23
+ onRetry?: (id: string) => void;
24
+ className?: string;
25
+ }
26
+
27
+ function formatSize(bytes: number): string {
28
+ if (bytes < 1024) return `${bytes}B`;
29
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
30
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
31
+ }
32
+
33
+ export function UploadProgress({ files, onRemove, onRetry, className }: UploadProgressProps) {
34
+ if (files.length === 0) return null;
35
+
36
+ return (
37
+ <div className={cn("space-y-2", className)}>
38
+ {files.map((file) => (
39
+ <div
40
+ key={file.id}
41
+ className={cn(
42
+ "flex items-center gap-3 rounded-lg border px-3 py-2 text-sm",
43
+ file.status === "error"
44
+ ? "border-[var(--surface-danger-border)] bg-[var(--surface-danger-bg)]"
45
+ : file.status === "complete"
46
+ ? "border-[var(--surface-success-border)] bg-[var(--surface-success-bg)]"
47
+ : "border-border bg-card",
48
+ )}
49
+ >
50
+ {/* Icon */}
51
+ {file.status === "complete" && (
52
+ <CheckCircle2 className="h-4 w-4 shrink-0 text-[var(--surface-success-text)]" />
53
+ )}
54
+ {file.status === "error" && (
55
+ <AlertCircle className="h-4 w-4 shrink-0 text-[var(--surface-danger-text)]" />
56
+ )}
57
+ {file.status === "uploading" && (
58
+ <Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
59
+ )}
60
+ {file.status === "pending" && (
61
+ <FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
62
+ )}
63
+
64
+ {/* Name + size */}
65
+ <div className="min-w-0 flex-1">
66
+ <div className="flex items-center gap-2">
67
+ <span className="truncate font-medium text-foreground">{file.name}</span>
68
+ <span className="shrink-0 text-xs text-muted-foreground">{formatSize(file.size)}</span>
69
+ </div>
70
+ {/* Progress bar */}
71
+ {file.status === "uploading" && file.progress !== undefined && (
72
+ <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-muted/50">
73
+ <div
74
+ className="h-full rounded-full bg-primary transition-all"
75
+ style={{ width: `${file.progress}%` }}
76
+ />
77
+ </div>
78
+ )}
79
+ {/* Error message */}
80
+ {file.status === "error" && file.error && (
81
+ <p className="mt-0.5 text-xs text-[var(--surface-danger-text)]">{file.error}</p>
82
+ )}
83
+ </div>
84
+
85
+ {/* Actions */}
86
+ <div className="flex shrink-0 items-center gap-1">
87
+ {file.status === "error" && onRetry && (
88
+ <button
89
+ type="button"
90
+ onClick={() => onRetry(file.id)}
91
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
92
+ >
93
+ <RefreshCw className="h-3.5 w-3.5" />
94
+ </button>
95
+ )}
96
+ {onRemove && (
97
+ <button
98
+ type="button"
99
+ onClick={() => onRemove(file.id)}
100
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
101
+ >
102
+ <X className="h-3.5 w-3.5" />
103
+ </button>
104
+ )}
105
+ </div>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ );
110
+ }