@agent-native/core 0.137.0 → 0.137.2

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 (47) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/app/components/AgentCompletionSound.tsx +105 -0
  3. package/corpus/templates/analytics/app/components/layout/Layout.tsx +44 -35
  4. package/corpus/templates/analytics/app/i18n-data.ts +38 -0
  5. package/corpus/templates/analytics/app/pages/Settings.tsx +53 -1
  6. package/corpus/templates/analytics/app/pages/settings/settings-search.ts +6 -0
  7. package/corpus/templates/analytics/changelog/2026-08-04-choose-whether-analytics-plays-a-bell-when-the-agent-finishe.md +6 -0
  8. package/corpus/templates/analytics/shared/analytics-user-prefs.ts +2 -0
  9. package/corpus/templates/slides/app/components/editor/AddSlidePopover.tsx +258 -0
  10. package/corpus/templates/slides/app/components/editor/EditorActionCluster.tsx +130 -0
  11. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +25 -337
  12. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +73 -53
  13. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +321 -0
  14. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +32 -11
  15. package/corpus/templates/slides/app/components/editor/SlideContextToolbar.tsx +804 -0
  16. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +101 -45
  17. package/corpus/templates/slides/app/components/editor/SlideOverflowWarning.tsx +7 -4
  18. package/corpus/templates/slides/app/components/editor/bullet-editing.ts +11 -3
  19. package/corpus/templates/slides/app/components/editor/commit-active-edit.ts +21 -0
  20. package/corpus/templates/slides/app/components/editor/list-editing.ts +219 -0
  21. package/corpus/templates/slides/app/components/editor/selection-overlay-measurement.ts +0 -3
  22. package/corpus/templates/slides/app/components/editor/slide-style.ts +199 -0
  23. package/corpus/templates/slides/app/global.css +14 -3
  24. package/corpus/templates/slides/app/i18n/en-US.ts +8 -1
  25. package/corpus/templates/slides/app/lib/recent-references.ts +82 -0
  26. package/corpus/templates/slides/app/pages/DeckEditor.tsx +51 -22
  27. package/corpus/templates/slides/app/pages/Index.tsx +236 -181
  28. package/corpus/templates/slides/changelog/2026-08-01-add-slide-moved-to-the-toolbar-and-the-slide-rail-is-now-mor.md +9 -0
  29. package/corpus/templates/slides/changelog/2026-08-01-add-slide-undo-redo-and-the-text-tool-now-lead-the-slide-too.md +9 -0
  30. package/corpus/templates/slides/changelog/2026-08-01-slide-styling-now-appears-in-a-contextual-toolbar-above-the-.md +6 -0
  31. package/corpus/templates/slides/changelog/2026-08-01-the-slide-style-side-panel-is-retired-all-styling-now-lives-.md +6 -0
  32. package/corpus/templates/slides/changelog/2026-08-03-the-slide-toolbar-is-denser-swatch-only-colors-dropdowns-for.md +10 -0
  33. package/corpus/templates/slides/changelog/2026-08-04-bullet-and-numbered-list-buttons-in-the-slide-toolbar-conver.md +6 -0
  34. package/corpus/templates/slides/changelog/2026-08-04-italic-and-underline-are-now-one-click-away-in-the-slide-too.md +6 -0
  35. package/dist/client/DefaultSpinner.d.ts +0 -12
  36. package/dist/client/DefaultSpinner.js +22 -2
  37. package/dist/client/onboarding/use-onboarding.js +3 -0
  38. package/dist/collab/routes.d.ts +1 -1
  39. package/dist/collab/struct-routes.d.ts +1 -1
  40. package/dist/deploy/build.js +2 -1
  41. package/dist/notifications/routes.d.ts +2 -2
  42. package/dist/progress/routes.d.ts +1 -1
  43. package/dist/server/realtime-token.d.ts +1 -1
  44. package/dist/templates/workspace-core/.agents/skills/address-feedback/SKILL.md +25 -9
  45. package/package.json +2 -2
  46. package/src/templates/workspace-core/.agents/skills/address-feedback/SKILL.md +25 -9
  47. package/corpus/templates/slides/app/components/editor/SlideStyleInspector.tsx +0 -763
@@ -27,14 +27,14 @@ import {
27
27
  IconAdjustments,
28
28
  IconPencilPlus,
29
29
  IconPin,
30
- IconLetterT,
31
30
  IconTool,
32
31
  IconDownload,
33
32
  IconSun,
34
33
  IconMoon,
35
34
  IconDotsVertical,
36
- IconPalette,
37
35
  IconLoader2,
36
+ IconArrowBackUp,
37
+ IconArrowForwardUp,
38
38
  } from "@tabler/icons-react";
39
39
  import { useTheme } from "next-themes";
40
40
  import { useState, useRef, useEffect } from "react";
@@ -57,7 +57,11 @@ import {
57
57
  } from "@/components/ui/tooltip";
58
58
  import { SaveStatusIndicator } from "@/components/visual-editor";
59
59
  import type { Deck, Slide, SlideLayout } from "@/context/DeckContext";
60
- import { defaultSlideContent, useSaveState } from "@/context/DeckContext";
60
+ import {
61
+ defaultSlideContent,
62
+ useDecks,
63
+ useSaveState,
64
+ } from "@/context/DeckContext";
61
65
  import {
62
66
  ASPECT_RATIO_VALUES,
63
67
  type AspectRatio,
@@ -65,7 +69,10 @@ import {
65
69
  } from "@/lib/aspect-ratios";
66
70
  import type { GoogleSlidesExportResult } from "@/lib/export-google-slides-client";
67
71
  import { parseUploadResponse } from "@/lib/upload-response";
72
+ import { shortcutLabel } from "@/lib/utils";
68
73
 
74
+ import { commitActiveEditThenRun } from "./commit-active-edit";
75
+ import { EditorActionCluster } from "./EditorActionCluster";
69
76
  import { ExportMenu } from "./ExportMenu";
70
77
  interface EditorToolbarProps {
71
78
  deck: Deck;
@@ -99,10 +106,6 @@ interface EditorToolbarProps {
99
106
  commentsOpen?: boolean;
100
107
  /** Toggle the comments panel */
101
108
  onToggleComments?: () => void;
102
- /** Whether the style panel is open */
103
- styleOpen?: boolean;
104
- /** Toggle the style panel */
105
- onToggleStyle?: () => void;
106
109
  /** Number of unresolved comments on the current slide */
107
110
  unresolvedCommentCount?: number;
108
111
  /** Current user email for avatar display */
@@ -139,6 +142,16 @@ interface EditorToolbarProps {
139
142
  aspectRatio?: AspectRatio;
140
143
  /** Change the deck's aspect ratio */
141
144
  onSetAspectRatio?: (ratio: AspectRatio) => void;
145
+ /** Insert a blank slide after the current one */
146
+ onAddEmptySlide?: () => void;
147
+ /** Duplicate the current slide */
148
+ onDuplicateCurrentSlide?: () => void;
149
+ /** Id of the current slide, so an agent add-slide lands in the right place */
150
+ currentSlideId?: string;
151
+ /** True while an agent add-slide request is in flight */
152
+ addSlideGenerating?: boolean;
153
+ /** Called when an agent add-slide request is submitted */
154
+ onAddSlideGeneratingChange?: (generating: boolean) => void;
142
155
  }
143
156
 
144
157
  const slideLayoutOptions: { value: SlideLayout; labelKey: string }[] = [
@@ -249,8 +262,6 @@ export default function EditorToolbar({
249
262
  agentActive,
250
263
  commentsOpen,
251
264
  onToggleComments,
252
- styleOpen,
253
- onToggleStyle,
254
265
  unresolvedCommentCount = 0,
255
266
  currentUserEmail,
256
267
  animationsOpen,
@@ -269,6 +280,11 @@ export default function EditorToolbar({
269
280
  onExportGoogleSlides,
270
281
  aspectRatio,
271
282
  onSetAspectRatio,
283
+ onAddEmptySlide,
284
+ onDuplicateCurrentSlide,
285
+ currentSlideId,
286
+ addSlideGenerating = false,
287
+ onAddSlideGeneratingChange,
272
288
  canEdit = true,
273
289
  }: EditorToolbarProps) {
274
290
  const t = useT();
@@ -317,6 +333,11 @@ export default function EditorToolbar({
317
333
  setLayoutOpen(false);
318
334
  };
319
335
  const [toolsOpen, setToolsOpen] = useState(false);
336
+ // The contextual toolbar hosts the action cluster whenever it is on screen.
337
+ // That row rides on SlideEditor, which only mounts for a real slide, so an
338
+ // empty deck must keep this fallback or it has no way to add one.
339
+ const contextToolbarVisible = canEdit && Boolean(currentSlide);
340
+ const { undo, redo, canUndo, canRedo } = useDecks();
320
341
  const fileInputRef = useRef<HTMLInputElement>(null);
321
342
  const [importing, setImporting] = useState(false);
322
343
  const { setTheme, resolvedTheme } = useTheme();
@@ -442,6 +463,26 @@ export default function EditorToolbar({
442
463
  <TooltipContent>{t("editorToolbar.toggleSlideList")}</TooltipContent>
443
464
  </Tooltip>
444
465
 
466
+ {/* Add slide and the text-box tool live at the head of the contextual
467
+ * toolbar below. That row is desktop-only and needs a slide to mount on,
468
+ * so keep a fallback here for narrow screens and empty decks. */}
469
+ {canEdit && (
470
+ <EditorActionCluster
471
+ className={contextToolbarVisible ? "lg:hidden" : undefined}
472
+ deckId={deckId}
473
+ deckTitle={deckTitle}
474
+ currentSlideId={currentSlideId}
475
+ slideCount={slideCount}
476
+ currentSlideIndex={currentSlideIndex}
477
+ addSlideGenerating={addSlideGenerating}
478
+ onAddSlideGeneratingChange={onAddSlideGeneratingChange}
479
+ onAddEmptySlide={onAddEmptySlide}
480
+ onDuplicateCurrentSlide={onDuplicateCurrentSlide}
481
+ textBoxMode={textBoxMode}
482
+ onToggleTextBoxMode={onToggleTextBoxMode}
483
+ />
484
+ )}
485
+
445
486
  {/* Deck title */}
446
487
  <input
447
488
  type="text"
@@ -745,29 +786,6 @@ graph TD
745
786
  </>
746
787
  )}
747
788
 
748
- {canEdit && onToggleTextBoxMode && (
749
- <Tooltip>
750
- <TooltipTrigger asChild>
751
- <button
752
- type="button"
753
- onClick={onToggleTextBoxMode}
754
- data-toolbar-textbox-button
755
- aria-label={t("editorToolbar.addTextBox")}
756
- aria-pressed={textBoxMode}
757
- aria-keyshortcuts="T"
758
- className={`${TOOLBAR_ICON_BUTTON_CLASS} ${
759
- textBoxMode
760
- ? "bg-accent text-foreground"
761
- : "text-muted-foreground hover:bg-accent hover:text-foreground/70"
762
- }`}
763
- >
764
- <IconLetterT className="size-4" />
765
- </button>
766
- </TooltipTrigger>
767
- <TooltipContent>{t("editorToolbar.addTextBox")} (T)</TooltipContent>
768
- </Tooltip>
769
- )}
770
-
771
789
  {/* Slide tools palette — animations, tweaks, draw, comment-pin all live
772
790
  * inside one popover so the toolbar doesn't drown in icons. Hidden in
773
791
  * view-only mode since none of these affordances apply. */}
@@ -879,27 +897,6 @@ graph TD
879
897
  className="flex-shrink-0 mr-0.5"
880
898
  />
881
899
 
882
- {/* Style toggle */}
883
- {canEdit && currentSlide && onToggleStyle && (
884
- <Tooltip>
885
- <TooltipTrigger asChild>
886
- <button
887
- onClick={onToggleStyle}
888
- data-slide-style-trigger="true"
889
- className={`${TOOLBAR_ICON_BUTTON_CLASS} relative ${
890
- styleOpen
891
- ? "bg-accent text-foreground"
892
- : "text-muted-foreground hover:bg-accent hover:text-foreground/70"
893
- }`}
894
- aria-label={t("styleInspector.title")}
895
- >
896
- <IconPalette className="size-4" />
897
- </button>
898
- </TooltipTrigger>
899
- <TooltipContent>{t("styleInspector.title")}</TooltipContent>
900
- </Tooltip>
901
- )}
902
-
903
900
  {/* Comments toggle */}
904
901
  {onToggleComments && (
905
902
  <Tooltip>
@@ -1008,6 +1005,29 @@ graph TD
1008
1005
  <TooltipContent>{t("editorToolbar.more")}</TooltipContent>
1009
1006
  </Tooltip>
1010
1007
  <DropdownMenuContent align="end" className="w-48">
1008
+ {canEdit && (
1009
+ <>
1010
+ <DropdownMenuItem
1011
+ disabled={!canUndo}
1012
+ onSelect={() => commitActiveEditThenRun(undo)}
1013
+ >
1014
+ <IconArrowBackUp className="w-4 h-4 mr-2" />
1015
+ {t("editorToolbar.undoWithShortcut", {
1016
+ shortcut: shortcutLabel("Cmd+Z"),
1017
+ })}
1018
+ </DropdownMenuItem>
1019
+ <DropdownMenuItem
1020
+ disabled={!canRedo}
1021
+ onSelect={() => commitActiveEditThenRun(redo)}
1022
+ >
1023
+ <IconArrowForwardUp className="w-4 h-4 mr-2" />
1024
+ {t("editorToolbar.redoWithShortcut", {
1025
+ shortcut: shortcutLabel("Cmd+Shift+Z"),
1026
+ })}
1027
+ </DropdownMenuItem>
1028
+ <DropdownMenuSeparator />
1029
+ </>
1030
+ )}
1011
1031
  <DropdownMenuItem
1012
1032
  disabled={importing}
1013
1033
  onSelect={() => fileInputRef.current?.click()}
@@ -0,0 +1,321 @@
1
+ import {
2
+ IconArrowRight,
3
+ IconPalette,
4
+ IconPresentation,
5
+ IconUpload,
6
+ } from "@tabler/icons-react";
7
+ import type { ChangeEvent, ReactNode } from "react";
8
+
9
+ import { Button } from "@/components/ui/button";
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from "@/components/ui/dialog";
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectGroup,
21
+ SelectItem,
22
+ SelectLabel,
23
+ SelectTrigger,
24
+ SelectValue,
25
+ } from "@/components/ui/select";
26
+ import type { Deck } from "@/context/DeckContext";
27
+ import type { RecentReference } from "@/lib/recent-references";
28
+ import { cn } from "@/lib/utils";
29
+
30
+ export interface NewDeckReferenceSelection {
31
+ designSystemId?: string | null;
32
+ referenceDeckId?: string | null;
33
+ }
34
+
35
+ interface DesignSystemOption {
36
+ id: string;
37
+ title: string;
38
+ isDefault?: boolean;
39
+ }
40
+
41
+ interface NewDeckReferenceStepProps {
42
+ open: boolean;
43
+ designSystems: DesignSystemOption[];
44
+ decks: Deck[];
45
+ defaultDesignSystemId: string | null;
46
+ defaultReferenceDeckId: string | null;
47
+ recentReferences: RecentReference[];
48
+ onSelect: (selection: NewDeckReferenceSelection) => void;
49
+ onImport: (files: File[]) => Promise<void>;
50
+ onSkip: () => void;
51
+ onOpenChange: (open: boolean) => void;
52
+ importing?: boolean;
53
+ title: string;
54
+ designSystemLabel: string;
55
+ referenceDeckLabel: string;
56
+ chooseDeckLabel: string;
57
+ importFileLabel: string;
58
+ importingLabel: string;
59
+ skipLabel: string;
60
+ defaultSuffix: string;
61
+ starredLabel: string;
62
+ otherDecksLabel: string;
63
+ description: string;
64
+ promptNote?: string;
65
+ }
66
+
67
+ export function NewDeckReferenceStep({
68
+ open,
69
+ designSystems,
70
+ decks,
71
+ defaultDesignSystemId,
72
+ defaultReferenceDeckId,
73
+ recentReferences,
74
+ onSelect,
75
+ onImport,
76
+ onSkip,
77
+ onOpenChange,
78
+ importing = false,
79
+ title,
80
+ designSystemLabel,
81
+ referenceDeckLabel,
82
+ chooseDeckLabel,
83
+ importFileLabel,
84
+ importingLabel,
85
+ skipLabel,
86
+ defaultSuffix,
87
+ starredLabel,
88
+ otherDecksLabel,
89
+ description,
90
+ promptNote,
91
+ }: NewDeckReferenceStepProps) {
92
+ const designSystemById = new Map(
93
+ designSystems.map((designSystem) => [designSystem.id, designSystem]),
94
+ );
95
+ const deckById = new Map(decks.map((deck) => [deck.id, deck]));
96
+ const recentOptions = recentReferences
97
+ .map((reference) => {
98
+ const item =
99
+ reference.kind === "design-system"
100
+ ? designSystemById.get(reference.id)
101
+ : deckById.get(reference.id);
102
+ return item ? { reference, item } : null;
103
+ })
104
+ .filter(
105
+ (
106
+ value,
107
+ ): value is {
108
+ reference: RecentReference;
109
+ item: DesignSystemOption | Deck;
110
+ } => value !== null,
111
+ );
112
+ const defaultReferenceKeys = new Set(
113
+ [
114
+ defaultDesignSystemId ? `design-system:${defaultDesignSystemId}` : null,
115
+ defaultReferenceDeckId ? `deck:${defaultReferenceDeckId}` : null,
116
+ ].filter((value): value is string => value !== null),
117
+ );
118
+ const visibleRecentOptions = recentOptions.filter(
119
+ ({ reference }) =>
120
+ !defaultReferenceKeys.has(`${reference.kind}:${reference.id}`),
121
+ );
122
+ const starredDecks = decks.filter((deck) => deck.starred);
123
+ const otherDecks = decks.filter((deck) => !deck.starred);
124
+
125
+ const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
126
+ const files = Array.from(event.target.files ?? []);
127
+ event.target.value = "";
128
+ if (files.length === 0) return;
129
+ await onImport(files);
130
+ };
131
+
132
+ return (
133
+ <Dialog open={open} onOpenChange={onOpenChange}>
134
+ <DialogContent className="max-h-[min(720px,calc(100vh-32px))] overflow-y-auto sm:max-w-2xl">
135
+ <DialogHeader>
136
+ <DialogTitle>{title}</DialogTitle>
137
+ <DialogDescription>{description}</DialogDescription>
138
+ </DialogHeader>
139
+
140
+ <div className="grid gap-4">
141
+ {(defaultDesignSystemId || defaultReferenceDeckId) && (
142
+ <div className="grid gap-2 sm:grid-cols-2">
143
+ {defaultDesignSystemId &&
144
+ designSystemById.has(defaultDesignSystemId) && (
145
+ <ReferenceCard
146
+ icon={<IconPalette className="size-4" />}
147
+ label={
148
+ designSystemById.get(defaultDesignSystemId)?.title ??
149
+ designSystemLabel
150
+ }
151
+ meta={`${designSystemLabel}${defaultSuffix}`}
152
+ onClick={() =>
153
+ onSelect({
154
+ designSystemId: defaultDesignSystemId,
155
+ referenceDeckId: null,
156
+ })
157
+ }
158
+ />
159
+ )}
160
+ {defaultReferenceDeckId &&
161
+ deckById.has(defaultReferenceDeckId) && (
162
+ <ReferenceCard
163
+ icon={<IconPresentation className="size-4" />}
164
+ label={deckById.get(defaultReferenceDeckId)?.title ?? ""}
165
+ meta={`${referenceDeckLabel}${defaultSuffix}`}
166
+ onClick={() =>
167
+ onSelect({
168
+ designSystemId: null,
169
+ referenceDeckId: defaultReferenceDeckId,
170
+ })
171
+ }
172
+ />
173
+ )}
174
+ </div>
175
+ )}
176
+
177
+ {visibleRecentOptions.length > 0 && (
178
+ <div className="grid gap-2">
179
+ <div className="grid gap-2 sm:grid-cols-2">
180
+ {visibleRecentOptions.map(({ reference, item }) => (
181
+ <ReferenceCard
182
+ key={`${reference.kind}:${reference.id}`}
183
+ icon={
184
+ reference.kind === "design-system" ? (
185
+ <IconPalette className="size-4" />
186
+ ) : (
187
+ <IconPresentation className="size-4" />
188
+ )
189
+ }
190
+ label={item.title}
191
+ meta={
192
+ reference.kind === "design-system"
193
+ ? designSystemLabel
194
+ : referenceDeckLabel
195
+ }
196
+ onClick={() =>
197
+ onSelect(
198
+ reference.kind === "design-system"
199
+ ? {
200
+ designSystemId: reference.id,
201
+ referenceDeckId: null,
202
+ }
203
+ : {
204
+ designSystemId: null,
205
+ referenceDeckId: reference.id,
206
+ },
207
+ )
208
+ }
209
+ />
210
+ ))}
211
+ </div>
212
+ </div>
213
+ )}
214
+
215
+ {decks.length > 0 && (
216
+ <div className="grid gap-2">
217
+ <span className="text-xs font-medium text-muted-foreground">
218
+ {referenceDeckLabel}
219
+ </span>
220
+ <Select
221
+ onValueChange={(value) =>
222
+ onSelect({ designSystemId: null, referenceDeckId: value })
223
+ }
224
+ >
225
+ <SelectTrigger className="w-full">
226
+ <SelectValue placeholder={chooseDeckLabel} />
227
+ </SelectTrigger>
228
+ <SelectContent>
229
+ {starredDecks.length > 0 && (
230
+ <SelectGroup>
231
+ <SelectLabel>{starredLabel}</SelectLabel>
232
+ {starredDecks.map((deck) => (
233
+ <SelectItem key={deck.id} value={deck.id}>
234
+ {deck.title}
235
+ </SelectItem>
236
+ ))}
237
+ </SelectGroup>
238
+ )}
239
+ {otherDecks.length > 0 && (
240
+ <SelectGroup>
241
+ <SelectLabel>{otherDecksLabel}</SelectLabel>
242
+ {otherDecks.map((deck) => (
243
+ <SelectItem key={deck.id} value={deck.id}>
244
+ {deck.title}
245
+ </SelectItem>
246
+ ))}
247
+ </SelectGroup>
248
+ )}
249
+ </SelectContent>
250
+ </Select>
251
+ </div>
252
+ )}
253
+
254
+ <div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
255
+ <label
256
+ className={cn(
257
+ "inline-flex cursor-pointer items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-accent",
258
+ importing && "pointer-events-none opacity-60",
259
+ )}
260
+ >
261
+ <IconUpload className="size-4" />
262
+ {importing ? importingLabel : importFileLabel}
263
+ <input
264
+ type="file"
265
+ className="sr-only"
266
+ accept=".pptx,.pdf,.docx"
267
+ multiple
268
+ disabled={importing}
269
+ onChange={(event) => void handleImport(event)}
270
+ />
271
+ </label>
272
+ </div>
273
+ </div>
274
+
275
+ {promptNote && (
276
+ <p className="truncate border-t border-border pt-3 text-xs text-muted-foreground">
277
+ {promptNote}
278
+ </p>
279
+ )}
280
+
281
+ <div className="flex items-center justify-between border-t border-border pt-3">
282
+ <Button type="button" variant="ghost" onClick={onSkip}>
283
+ {skipLabel}
284
+ </Button>
285
+ <IconArrowRight className="size-4 text-muted-foreground" />
286
+ </div>
287
+ </DialogContent>
288
+ </Dialog>
289
+ );
290
+ }
291
+
292
+ function ReferenceCard({
293
+ icon,
294
+ label,
295
+ meta,
296
+ onClick,
297
+ }: {
298
+ icon: ReactNode;
299
+ label: string;
300
+ meta: string;
301
+ onClick: () => void;
302
+ }) {
303
+ return (
304
+ <button
305
+ type="button"
306
+ onClick={onClick}
307
+ className="flex min-w-0 items-center gap-3 rounded-lg border border-border bg-card px-3 py-3 text-start transition-colors hover:border-foreground/30 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
308
+ >
309
+ <span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-accent text-muted-foreground">
310
+ {icon}
311
+ </span>
312
+ <span className="min-w-0">
313
+ <span className="block truncate text-sm font-medium">{label}</span>
314
+ <span className="block truncate text-xs text-muted-foreground">
315
+ {meta}
316
+ </span>
317
+ </span>
318
+ <IconArrowRight className="ms-auto size-4 shrink-0 text-muted-foreground" />
319
+ </button>
320
+ );
321
+ }
@@ -16,6 +16,37 @@ export interface UploadedFile {
16
16
  size: number;
17
17
  }
18
18
 
19
+ export async function uploadPromptFiles(
20
+ files: File[],
21
+ ): Promise<UploadedFile[]> {
22
+ if (files.length === 0) return [];
23
+ const formData = new FormData();
24
+ files.forEach((file) => formData.append("files", file));
25
+ const response = await fetch(`${appBasePath()}/api/uploads`, {
26
+ method: "POST",
27
+ body: formData,
28
+ });
29
+ if (!response.ok) {
30
+ let message = "Upload failed";
31
+ try {
32
+ const data: unknown = await response.json();
33
+ if (
34
+ data &&
35
+ typeof data === "object" &&
36
+ "error" in data &&
37
+ typeof data.error === "string" &&
38
+ data.error.trim()
39
+ ) {
40
+ message = data.error;
41
+ }
42
+ } catch (error) {
43
+ throw new Error(`Upload failed (${response.status})`, { cause: error });
44
+ }
45
+ throw new Error(message);
46
+ }
47
+ return (await response.json()) as UploadedFile[];
48
+ }
49
+
19
50
  /**
20
51
  * Radix popovers portal to `document.body`, so a mousedown inside the model
21
52
  * picker or attachment menu reads as "outside" any panel that hosts a composer.
@@ -137,17 +168,7 @@ export default function PromptPopover({
137
168
  if (files.length === 0) return [];
138
169
  setUploading(true);
139
170
  try {
140
- const formData = new FormData();
141
- files.forEach((f) => formData.append("files", f));
142
- const res = await fetch(`${appBasePath()}/api/uploads`, {
143
- method: "POST",
144
- body: formData,
145
- });
146
- if (!res.ok) {
147
- const data = await res.json().catch(() => null);
148
- throw new Error(data?.error || "Upload failed");
149
- }
150
- return (await res.json()) as UploadedFile[];
171
+ return await uploadPromptFiles(files);
151
172
  } finally {
152
173
  setUploading(false);
153
174
  }