@vvedantb/ui 0.17.1 → 0.18.1

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 (130) hide show
  1. package/README.md +251 -3
  2. package/package.json +13 -5
  3. package/shell/src/SidebarResize.tsx +1 -4
  4. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
  5. package/src/ai-elements/activity-question-utils.ts +26 -0
  6. package/src/ai-elements/activity-question.tsx +159 -0
  7. package/src/ai-elements/activity-shared.tsx +459 -0
  8. package/src/ai-elements/activity-step-detail.tsx +115 -0
  9. package/src/ai-elements/activity-step-label.ts +981 -0
  10. package/src/ai-elements/activity-tasks-utils.ts +199 -0
  11. package/src/ai-elements/activity-tasks.tsx +770 -0
  12. package/src/ai-elements/artifact.tsx +162 -52
  13. package/src/ai-elements/attachments.tsx +62 -32
  14. package/src/ai-elements/code-block.tsx +76 -22
  15. package/src/ai-elements/context.tsx +370 -0
  16. package/src/ai-elements/conversation.tsx +186 -62
  17. package/src/ai-elements/file-chip.tsx +53 -0
  18. package/src/ai-elements/message.tsx +161 -50
  19. package/src/ai-elements/model-picker-content.tsx +505 -0
  20. package/src/ai-elements/model-picker-types.ts +56 -0
  21. package/src/ai-elements/model-picker.tsx +301 -0
  22. package/src/ai-elements/model-selector.tsx +102 -21
  23. package/src/ai-elements/plan.tsx +130 -0
  24. package/src/ai-elements/prompt-input-settings.tsx +144 -0
  25. package/src/ai-elements/prompt-input-speech.tsx +189 -0
  26. package/src/ai-elements/prompt-input-traits.tsx +281 -0
  27. package/src/ai-elements/prompt-input.tsx +320 -134
  28. package/src/ai-elements/provider-icon.tsx +133 -0
  29. package/src/ai-elements/queue.tsx +293 -9
  30. package/src/ai-elements/shimmer.tsx +72 -1
  31. package/src/ai-elements/simple-model-ladder.tsx +108 -0
  32. package/src/ai-elements/speech-input.tsx +5 -3
  33. package/src/ai-elements/test-results.tsx +510 -0
  34. package/src/ai-elements/trace.tsx +243 -0
  35. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  36. package/src/ai-elements/web-preview.tsx +349 -0
  37. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  38. package/src/index.ts +66 -12
  39. package/src/kibo/avatar-stack.tsx +40 -0
  40. package/src/kibo/contribution-graph.tsx +539 -0
  41. package/src/kibo/cursor.tsx +72 -0
  42. package/src/kibo/gantt-features.tsx +439 -0
  43. package/src/kibo/gantt-header.tsx +181 -0
  44. package/src/kibo/gantt-provider.tsx +657 -0
  45. package/src/kibo/gantt-sidebar.tsx +157 -0
  46. package/src/kibo/gantt-timeline.tsx +154 -0
  47. package/src/kibo/index.ts +151 -0
  48. package/src/kibo/kanban.tsx +338 -0
  49. package/src/kibo/kanbanDropTarget.ts +25 -0
  50. package/src/kibo/list.tsx +162 -0
  51. package/src/kibo/theme-switcher.tsx +109 -0
  52. package/src/kibo/video-player.tsx +237 -0
  53. package/src/markdown/Markdown.tsx +112 -0
  54. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  55. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  56. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  57. package/src/markdown/MarkdownLink.tsx +92 -0
  58. package/src/markdown/codeFence.ts +57 -0
  59. package/src/markdown/codeHighlight.ts +138 -0
  60. package/src/markdown/incrementalBlocks.ts +182 -0
  61. package/src/markdown/index.ts +3 -0
  62. package/src/markdown/markdownClipboard.ts +178 -0
  63. package/src/markdown/mermaid.ts +155 -0
  64. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  65. package/src/motion/presets.ts +24 -0
  66. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  67. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  68. package/src/ui/_menu-parts.tsx +29 -0
  69. package/src/ui/accordion.alt.tsx +46 -53
  70. package/src/ui/accordion.tsx +59 -10
  71. package/src/ui/avatar.tsx +56 -0
  72. package/src/ui/badge.tsx +45 -21
  73. package/src/ui/border-beam.tsx +97 -0
  74. package/src/ui/button-group.tsx +50 -21
  75. package/src/ui/button.tsx +67 -45
  76. package/src/ui/calendar.tsx +48 -5
  77. package/src/ui/card.tsx +77 -8
  78. package/src/ui/carousel.tsx +60 -0
  79. package/src/ui/centered-spinner.tsx +30 -0
  80. package/src/ui/checkbox.tsx +34 -16
  81. package/src/ui/clear-input.alt.tsx +33 -75
  82. package/src/ui/clear-input.tsx +140 -15
  83. package/src/ui/collapsible.tsx +40 -2
  84. package/src/ui/command.tsx +143 -70
  85. package/src/ui/context-menu.tsx +159 -93
  86. package/src/ui/crossfade-icon.tsx +123 -0
  87. package/src/ui/dialog.tsx +154 -70
  88. package/src/ui/dropdown-menu.tsx +177 -117
  89. package/src/ui/hover-card.tsx +59 -17
  90. package/src/ui/input-group.alt.tsx +83 -83
  91. package/src/ui/input-group.tsx +129 -58
  92. package/src/ui/input.tsx +13 -4
  93. package/src/ui/label.tsx +11 -8
  94. package/src/ui/list-row.tsx +232 -0
  95. package/src/ui/loading-state.tsx +215 -0
  96. package/src/ui/popover.tsx +56 -23
  97. package/src/ui/progress.tsx +28 -16
  98. package/src/ui/refresh-spin-icon.tsx +41 -0
  99. package/src/ui/search-input.tsx +66 -15
  100. package/src/ui/select.tsx +171 -100
  101. package/src/ui/separator.tsx +23 -14
  102. package/src/ui/slider.tsx +21 -7
  103. package/src/ui/sonner.tsx +68 -30
  104. package/src/ui/spinner.tsx +229 -11
  105. package/src/ui/status-dot.tsx +84 -0
  106. package/src/ui/surface.tsx +49 -0
  107. package/src/ui/switch.tsx +29 -15
  108. package/src/ui/table.tsx +38 -16
  109. package/src/ui/tabs.tsx +123 -35
  110. package/src/ui/tabsSliding.ts +33 -1
  111. package/src/ui/textarea.tsx +13 -4
  112. package/src/ui/tooltip.tsx +50 -14
  113. package/src/ui/ui-config.tsx +149 -0
  114. package/src/utils/cat-color.ts +123 -0
  115. package/src/utils/dayjs.ts +6 -0
  116. package/src/utils/gesture.ts +59 -0
  117. package/src/utils/runtimeVisibility.ts +167 -0
  118. package/src/utils/sharedClock.ts +96 -0
  119. package/src/utils/surface-radius.ts +16 -0
  120. package/src/utils/useDragSensors.ts +58 -0
  121. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  122. package/tokens/examples/tokens-moved/README.md +20 -0
  123. package/tokens/examples/tokens-moved/index.html +12 -0
  124. package/tokens/examples/tokens-moved/main.tsx +6 -0
  125. package/tokens/examples/tokens-moved/package.json +14 -0
  126. package/tokens/examples/tokens-moved/styles.css +12 -0
  127. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  128. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  129. package/tokens/src/css/border-beam.css +116 -0
  130. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,159 @@
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ IconCheck,
4
+ IconMessageQuestion,
5
+ IconPencil,
6
+ } from "@tabler/icons-react";
7
+
8
+ import { Badge } from "../ui/badge";
9
+ import { useCn, useUiConfig } from "../ui/ui-config";
10
+ import { freeTextAnswer, selectedLabels } from "./activity-question-utils";
11
+ import type { ActivityQuestion } from "./activity-shared";
12
+
13
+ /**
14
+ * `UiConfig.ActivityQuestionCard`: `classes.selectedOption` replaces the fill
15
+ * of the picked option row (default `bg-surface-tertiary`).
16
+ */
17
+ export interface ActivityQuestionCardConfig {
18
+ classes?: { selectedOption?: string };
19
+ }
20
+
21
+ const OPTION_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
22
+
23
+ const CHIP_CLASS =
24
+ "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md text-3xs font-bold tracking-wide";
25
+
26
+ function OptionRow({
27
+ chip,
28
+ label,
29
+ description,
30
+ isSelected,
31
+ }: {
32
+ chip: ReactNode;
33
+ label: string;
34
+ description?: string;
35
+ isSelected: boolean;
36
+ }) {
37
+ const merge = useCn();
38
+ const selectedClass =
39
+ useUiConfig("ActivityQuestionCard")?.classes?.selectedOption ?? "bg-surface-tertiary";
40
+ return (
41
+ <div
42
+ className={merge(
43
+ "flex items-start gap-2 rounded-md px-2 py-1.5",
44
+ isSelected ? selectedClass : undefined,
45
+ )}
46
+ >
47
+ <span
48
+ className={merge(
49
+ CHIP_CLASS,
50
+ isSelected
51
+ ? "bg-primary text-primary-foreground"
52
+ : "bg-secondary text-muted-foreground",
53
+ )}
54
+ >
55
+ {chip}
56
+ </span>
57
+ <div className="min-w-0 flex-1">
58
+ <span
59
+ className={merge(
60
+ "text-sm leading-snug",
61
+ isSelected
62
+ ? "font-medium text-foreground"
63
+ : "text-muted-foreground",
64
+ )}
65
+ >
66
+ {label}
67
+ </span>
68
+ {description ? (
69
+ <p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
70
+ {description}
71
+ </p>
72
+ ) : null}
73
+ </div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Read-only record of an AskUserQuestion prompt and the answer given. The live
80
+ * prompt is `MultipleChoiceQuestion` in the composer dock; this is the history
81
+ * left behind in the transcript once the question is answered.
82
+ */
83
+ export function ActivityQuestionCard({
84
+ questions,
85
+ answers,
86
+ }: {
87
+ questions: ActivityQuestion[];
88
+ answers?: Record<string, string>;
89
+ }) {
90
+ if (questions.length === 0) return null;
91
+
92
+ return (
93
+ <div className="rounded-surface bg-card p-3 space-y-3">
94
+ <div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
95
+ <IconMessageQuestion size={14} />
96
+ {questions.length > 1 ? "Questions" : "Question"}
97
+ </div>
98
+ {questions.map((question, questionIndex) => {
99
+ const answer = answers?.[question.question];
100
+ const hasAnswer = answer !== undefined && answer.trim().length > 0;
101
+ const chosen = selectedLabels(answer, question);
102
+ const freeText = freeTextAnswer(answer, chosen);
103
+ return (
104
+ <div
105
+ key={`${question.question}-${questionIndex}`}
106
+ className="space-y-1.5"
107
+ >
108
+ <div className="flex items-start justify-between gap-2">
109
+ <p className="text-sm font-medium text-foreground">
110
+ {question.question}
111
+ </p>
112
+ {question.header ? (
113
+ <Badge
114
+ variant="secondary"
115
+ className="shrink-0 text-3xs font-semibold"
116
+ >
117
+ {question.header}
118
+ </Badge>
119
+ ) : null}
120
+ </div>
121
+ <div className="space-y-0.5">
122
+ {question.options.map((option, optionIndex) => {
123
+ const isSelected = chosen.includes(option.label);
124
+ return (
125
+ <OptionRow
126
+ key={`${option.label}-${optionIndex}`}
127
+ chip={
128
+ isSelected ? (
129
+ <IconCheck size={12} strokeWidth={3} />
130
+ ) : (
131
+ (OPTION_LETTERS[optionIndex] ?? String(optionIndex + 1))
132
+ )
133
+ }
134
+ label={option.label}
135
+ description={option.description}
136
+ isSelected={isSelected}
137
+ />
138
+ );
139
+ })}
140
+ {freeText ? (
141
+ <OptionRow
142
+ chip={<IconPencil size={12} strokeWidth={3} />}
143
+ label="Other"
144
+ description={freeText}
145
+ isSelected
146
+ />
147
+ ) : null}
148
+ </div>
149
+ {hasAnswer ? null : (
150
+ <p className="text-xs text-muted-foreground">
151
+ Answered in the next message
152
+ </p>
153
+ )}
154
+ </div>
155
+ );
156
+ })}
157
+ </div>
158
+ );
159
+ }
@@ -0,0 +1,459 @@
1
+ "use client";
2
+
3
+ import type { ComponentType } from "react";
4
+ import {
5
+ IconFileSearch,
6
+ IconPencil,
7
+ IconFilePlus,
8
+ IconTerminal2,
9
+ IconFolderSearch,
10
+ IconFileText,
11
+ IconWorld,
12
+ IconSearch,
13
+ IconSitemap,
14
+ IconBook2,
15
+ IconTool,
16
+ IconMessage,
17
+ IconListCheck,
18
+ IconInfoCircle,
19
+ IconAnchor,
20
+ IconSteeringWheel,
21
+ IconBrain,
22
+ } from "@tabler/icons-react";
23
+ import { Spinner } from "../ui/spinner";
24
+ import { useCn, useUiConfig } from "../ui/ui-config";
25
+ import { useEffect, useState, useSyncExternalStore } from "react";
26
+
27
+ import { quantizedSnapshot, subscribeQuantized } from "../utils/sharedClock";
28
+
29
+ /** Props the activity timeline passes to its Markdown renderer. */
30
+ export interface ActivityMarkdownProps {
31
+ className?: string;
32
+ /** `streaming` while the text is still arriving. */
33
+ mode?: "static" | "streaming";
34
+ children: string;
35
+ }
36
+
37
+ /**
38
+ * `UiConfig.ActivityTasks`: defaults for the agent activity timeline
39
+ * (`ActivityTasks`, `ActivityThinkingIcon`).
40
+ */
41
+ export interface ActivityTasksConfig {
42
+ /** Agent name in the streaming header ("<name> is thinking..."). Default "Agent"; the `name` prop wins. */
43
+ name?: string;
44
+ /** Renders reasoning and mid-turn reply text. Default: `Streamdown`. */
45
+ markdown?: ComponentType<ActivityMarkdownProps>;
46
+ /**
47
+ * An image for `ActivityThinkingIcon` (for example the app's logo mark),
48
+ * shown 16px and round. Without it the icon is the brain glyph.
49
+ */
50
+ thinkingIcon?: { src: string; alt: string };
51
+ }
52
+
53
+ /** One item in a todo checklist step (type "todos"). */
54
+ export interface ActivityTodoItem {
55
+ content: string;
56
+ status: "pending" | "in_progress" | "completed";
57
+ }
58
+
59
+ export interface ActivityStepOutput {
60
+ text: string;
61
+ exitCode?: number;
62
+ truncated?: boolean;
63
+ }
64
+
65
+ export interface ActivityStepEdit {
66
+ oldText: string;
67
+ newText: string;
68
+ }
69
+
70
+ /** One choice offered by an AskUserQuestion prompt. */
71
+ export interface ActivityQuestionOption {
72
+ label: string;
73
+ description?: string;
74
+ }
75
+
76
+ /** One question of an AskUserQuestion prompt, with the choices shown. */
77
+ export interface ActivityQuestion {
78
+ question: string;
79
+ header?: string;
80
+ multiSelect?: boolean;
81
+ options: ActivityQuestionOption[];
82
+ }
83
+
84
+ export interface ActivityStep {
85
+ type:
86
+ | "read"
87
+ | "edit"
88
+ | "write"
89
+ | "bash"
90
+ | "search_files"
91
+ | "search_code"
92
+ | "web_fetch"
93
+ | "web_search"
94
+ | "subtask"
95
+ | "notebook"
96
+ | "thinking"
97
+ | "reasoning"
98
+ | "response"
99
+ | "question"
100
+ | "todos"
101
+ | "tool"
102
+ | "notice"
103
+ | "hook"
104
+ | "status"
105
+ /** A queued user message the agent took into this live turn (Steer). */
106
+ | "steer";
107
+ label: string;
108
+ detail?: string;
109
+ /** Full, unshortened path for file-type steps. Powers the chat File Viewer. */
110
+ path?: string;
111
+ /** `pending`: a planned step that has not started (sandbox startup stages). */
112
+ status: "complete" | "active" | "pending";
113
+ /** The tool_use id that produced this step (Claude only). */
114
+ toolUseId?: string;
115
+ /** Parent `Agent` tool_use id — set on steps that ran inside a subagent. */
116
+ parentToolUseId?: string;
117
+ /** Todo checklist snapshot (type "todos" only). */
118
+ todos?: ActivityTodoItem[];
119
+ /** Bash command (fuller than detail, capped). */
120
+ command?: string;
121
+ /** Tool result transcript (tail-capped). */
122
+ output?: ActivityStepOutput;
123
+ /** Edit before/after snippets. */
124
+ edits?: ActivityStepEdit[];
125
+ /** Codex file_change paths. */
126
+ files?: string[];
127
+ /** Write tool content head preview. */
128
+ contentPreview?: string;
129
+ /** True when the tool failed or exited non-zero. */
130
+ isError?: boolean;
131
+ /** Wall time from push → complete (ms). */
132
+ durationMs?: number;
133
+ /** AskUserQuestion prompt (type "question" only): the questions and options shown to the user. */
134
+ questions?: ActivityQuestion[];
135
+ /** AskUserQuestion answers keyed by question text. Absent when the turn ended without a structured answer. */
136
+ answers?: Record<string, string>;
137
+ /**
138
+ * The transcript query stripped this step's expanded-only fields (`output`,
139
+ * `edits`, `contentPreview`) to keep the chat subscription small. The row
140
+ * still opens; the body fills in once the full payload is fetched.
141
+ */
142
+ hasHiddenDetail?: boolean;
143
+ }
144
+
145
+ /** True when the step has expandable rich detail to show. */
146
+ export function activityStepHasRichDetail(step: ActivityStep): boolean {
147
+ return Boolean(
148
+ step.command ||
149
+ step.output ||
150
+ (step.edits && step.edits.length > 0) ||
151
+ (step.files && step.files.length > 0) ||
152
+ step.contentPreview ||
153
+ step.hasHiddenDetail,
154
+ );
155
+ }
156
+
157
+ /** Glyph for thinking steps: the app's mark from `UiConfig.ActivityTasks.thinkingIcon`, or a brain. */
158
+ export function ActivityThinkingIcon({ className }: { className?: string }) {
159
+ const image = useUiConfig("ActivityTasks")?.thinkingIcon;
160
+ const merge = useCn();
161
+ if (!image) return <IconBrain aria-hidden className={className} />;
162
+ return (
163
+ <img
164
+ src={image.src}
165
+ alt={image.alt}
166
+ width={16}
167
+ height={16}
168
+ className={merge("rounded-full", className)}
169
+ />
170
+ );
171
+ }
172
+
173
+ /** The `status` step glyph: the `circle` {@link Spinner}. */
174
+ function ActivityStatusSpinner() {
175
+ return <Spinner variant="circle" />;
176
+ }
177
+
178
+ /** Glyph and fallback label per step type. */
179
+ export const activityStepConfig = {
180
+ read: { icon: IconFileSearch, defaultLabel: "Read file" },
181
+ edit: { icon: IconPencil, defaultLabel: "Edited file" },
182
+ write: { icon: IconFilePlus, defaultLabel: "Created file" },
183
+ bash: { icon: IconTerminal2, defaultLabel: "Ran command" },
184
+ search_files: { icon: IconFolderSearch, defaultLabel: "Found files" },
185
+ search_code: { icon: IconFileText, defaultLabel: "Searched code" },
186
+ web_fetch: { icon: IconWorld, defaultLabel: "Fetched URL" },
187
+ web_search: { icon: IconSearch, defaultLabel: "Web search" },
188
+ subtask: { icon: IconSitemap, defaultLabel: "Ran agent" },
189
+ notebook: { icon: IconBook2, defaultLabel: "Edited notebook" },
190
+ thinking: { icon: ActivityThinkingIcon, defaultLabel: "Thinking..." },
191
+ reasoning: { icon: ActivityThinkingIcon, defaultLabel: "Thinking..." },
192
+ response: { icon: IconMessage, defaultLabel: "Response" },
193
+ question: { icon: IconMessage, defaultLabel: "Asked a question" },
194
+ todos: { icon: IconListCheck, defaultLabel: "Task list" },
195
+ tool: { icon: IconTool, defaultLabel: "Used tool" },
196
+ notice: { icon: IconInfoCircle, defaultLabel: "Notice" },
197
+ hook: { icon: IconAnchor, defaultLabel: "Hook" },
198
+ status: { icon: ActivityStatusSpinner, defaultLabel: "Status" },
199
+ steer: { icon: IconSteeringWheel, defaultLabel: "Steered" },
200
+ };
201
+
202
+ const SPINNER_VERBS = [
203
+ "Accomplishing",
204
+ "Actioning",
205
+ "Actualizing",
206
+ "Architecting",
207
+ "Baking",
208
+ "Beaming",
209
+ "Beboppin'",
210
+ "Befuddling",
211
+ "Billowing",
212
+ "Blanching",
213
+ "Bloviating",
214
+ "Boogieing",
215
+ "Boondoggling",
216
+ "Booping",
217
+ "Bootstrapping",
218
+ "Brewing",
219
+ "Bunning",
220
+ "Burrowing",
221
+ "Calculating",
222
+ "Canoodling",
223
+ "Caramelizing",
224
+ "Cascading",
225
+ "Catapulting",
226
+ "Cerebrating",
227
+ "Channeling",
228
+ "Channelling",
229
+ "Choreographing",
230
+ "Churning",
231
+ "Clauding",
232
+ "Coalescing",
233
+ "Cogitating",
234
+ "Combobulating",
235
+ "Composing",
236
+ "Computing",
237
+ "Concocting",
238
+ "Considering",
239
+ "Contemplating",
240
+ "Cooking",
241
+ "Crafting",
242
+ "Creating",
243
+ "Crunching",
244
+ "Crystallizing",
245
+ "Cultivating",
246
+ "Deciphering",
247
+ "Deliberating",
248
+ "Determining",
249
+ "Dilly-dallying",
250
+ "Discombobulating",
251
+ "Doing",
252
+ "Doodling",
253
+ "Drizzling",
254
+ "Ebbing",
255
+ "Effecting",
256
+ "Elucidating",
257
+ "Embellishing",
258
+ "Enchanting",
259
+ "Envisioning",
260
+ "Evaporating",
261
+ "Fermenting",
262
+ "Fiddle-faddling",
263
+ "Finagling",
264
+ "Flambéing",
265
+ "Flibbertigibbeting",
266
+ "Flowing",
267
+ "Flummoxing",
268
+ "Fluttering",
269
+ "Forging",
270
+ "Forming",
271
+ "Frolicking",
272
+ "Frosting",
273
+ "Gallivanting",
274
+ "Galloping",
275
+ "Garnishing",
276
+ "Generating",
277
+ "Gesticulating",
278
+ "Germinating",
279
+ "Gitifying",
280
+ "Grooving",
281
+ "Gusting",
282
+ "Harmonizing",
283
+ "Hashing",
284
+ "Hatching",
285
+ "Herding",
286
+ "Honking",
287
+ "Hullaballooing",
288
+ "Hyperspacing",
289
+ "Ideating",
290
+ "Imagining",
291
+ "Improvising",
292
+ "Incubating",
293
+ "Inferring",
294
+ "Infusing",
295
+ "Ionizing",
296
+ "Jitterbugging",
297
+ "Julienning",
298
+ "Kneading",
299
+ "Leavening",
300
+ "Levitating",
301
+ "Lollygagging",
302
+ "Manifesting",
303
+ "Marinating",
304
+ "Meandering",
305
+ "Metamorphosing",
306
+ "Misting",
307
+ "Moonwalking",
308
+ "Moseying",
309
+ "Mulling",
310
+ "Mustering",
311
+ "Musing",
312
+ "Nebulizing",
313
+ "Nesting",
314
+ "Newspapering",
315
+ "Noodling",
316
+ "Nucleating",
317
+ "Orbiting",
318
+ "Orchestrating",
319
+ "Osmosing",
320
+ "Perambulating",
321
+ "Percolating",
322
+ "Perusing",
323
+ "Philosophising",
324
+ "Photosynthesizing",
325
+ "Pollinating",
326
+ "Pondering",
327
+ "Pontificating",
328
+ "Pouncing",
329
+ "Precipitating",
330
+ "Prestidigitating",
331
+ "Processing",
332
+ "Proofing",
333
+ "Propagating",
334
+ "Puttering",
335
+ "Puzzling",
336
+ "Quantumizing",
337
+ "Razzle-dazzling",
338
+ "Razzmatazzing",
339
+ "Recombobulating",
340
+ "Reticulating",
341
+ "Roosting",
342
+ "Ruminating",
343
+ "Sautéing",
344
+ "Scampering",
345
+ "Schlepping",
346
+ "Scurrying",
347
+ "Seasoning",
348
+ "Shenaniganing",
349
+ "Shimmying",
350
+ "Simmering",
351
+ "Skedaddling",
352
+ "Sketching",
353
+ "Slithering",
354
+ "Smooshing",
355
+ "Sock-hopping",
356
+ "Spelunking",
357
+ "Spinning",
358
+ "Sprouting",
359
+ "Stewing",
360
+ "Sublimating",
361
+ "Swirling",
362
+ "Swooping",
363
+ "Symbioting",
364
+ "Synthesizing",
365
+ "Tempering",
366
+ "Thinking",
367
+ "Thundering",
368
+ "Tinkering",
369
+ "Tomfoolering",
370
+ "Topsy-turvying",
371
+ "Transfiguring",
372
+ "Transmuting",
373
+ "Twisting",
374
+ "Undulating",
375
+ "Unfurling",
376
+ "Unravelling",
377
+ "Vibing",
378
+ "Waddling",
379
+ "Wandering",
380
+ "Warping",
381
+ "Whatchamacalliting",
382
+ "Whirlpooling",
383
+ "Whirring",
384
+ "Whisking",
385
+ "Wibbling",
386
+ "Working",
387
+ "Wrangling",
388
+ "Zesting",
389
+ "Zigzagging",
390
+ ] as const;
391
+
392
+ export function getRandomActivityVerb(): string {
393
+ return SPINNER_VERBS[Math.floor(Math.random() * SPINNER_VERBS.length)] ?? "Working";
394
+ }
395
+
396
+ export function useActivitySpinnerVerb(active: boolean): string {
397
+ const [verb, setVerb] = useState(getRandomActivityVerb);
398
+ useEffect(() => {
399
+ if (!active) return;
400
+ let id: ReturnType<typeof setInterval> | undefined;
401
+ const start = () => {
402
+ if (id !== undefined) return;
403
+ id = setInterval(() => {
404
+ setVerb(getRandomActivityVerb());
405
+ }, 3000);
406
+ };
407
+ const stop = () => {
408
+ if (id === undefined) return;
409
+ clearInterval(id);
410
+ id = undefined;
411
+ };
412
+ const onVisibility = () => {
413
+ if (
414
+ typeof document !== "undefined" &&
415
+ document.visibilityState === "hidden"
416
+ ) {
417
+ stop();
418
+ return;
419
+ }
420
+ start();
421
+ };
422
+ onVisibility();
423
+ if (typeof document !== "undefined") {
424
+ document.addEventListener("visibilitychange", onVisibility);
425
+ }
426
+ return () => {
427
+ stop();
428
+ if (typeof document !== "undefined") {
429
+ document.removeEventListener("visibilitychange", onVisibility);
430
+ }
431
+ };
432
+ }, [active]);
433
+ return verb;
434
+ }
435
+
436
+ const SECOND_MS = 1000;
437
+ const noopSubscribe = () => () => {};
438
+
439
+ export function useActivityElapsedSeconds(
440
+ startedAt: number | undefined,
441
+ active: boolean,
442
+ ) {
443
+ const tick = useSyncExternalStore(
444
+ active && startedAt
445
+ ? (onChange) => subscribeQuantized(SECOND_MS, onChange)
446
+ : noopSubscribe,
447
+ () => quantizedSnapshot(SECOND_MS),
448
+ () => quantizedSnapshot(SECOND_MS),
449
+ );
450
+ if (!active || !startedAt) return 0;
451
+ return Math.max(0, Math.floor((tick - startedAt) / 1000));
452
+ }
453
+
454
+ export function formatActivityElapsed(seconds: number): string {
455
+ if (seconds < 60) return `${seconds}s`;
456
+ const m = Math.floor(seconds / 60);
457
+ const s = seconds % 60;
458
+ return s > 0 ? `${m}m ${s}s` : `${m}m`;
459
+ }