@aphrody/m3-ai 3.3.2 → 3.3.4

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 (48) hide show
  1. package/README.md +7 -7
  2. package/dist/A2APeerInspector.d.ts +41 -0
  3. package/dist/A2APeerInspector.d.ts.map +1 -0
  4. package/dist/A2ATaskTimeline.d.ts +28 -0
  5. package/dist/A2ATaskTimeline.d.ts.map +1 -0
  6. package/dist/AgUiEventStream.d.ts +87 -0
  7. package/dist/AgUiEventStream.d.ts.map +1 -0
  8. package/dist/GeminiAccountMenu.d.ts +55 -0
  9. package/dist/GeminiAccountMenu.d.ts.map +1 -0
  10. package/dist/GeminiPersonalContextEdit.d.ts +29 -0
  11. package/dist/GeminiPersonalContextEdit.d.ts.map +1 -0
  12. package/dist/GoogleSpotlightBar.d.ts +32 -0
  13. package/dist/GoogleSpotlightBar.d.ts.map +1 -0
  14. package/dist/Markdown.d.ts +2 -1
  15. package/dist/Markdown.d.ts.map +1 -1
  16. package/dist/McpAppCard.d.ts +24 -0
  17. package/dist/McpAppCard.d.ts.map +1 -0
  18. package/dist/ModelSelector.d.ts.map +1 -1
  19. package/dist/TaskRow.d.ts.map +1 -1
  20. package/dist/ToolCall.d.ts.map +1 -1
  21. package/dist/index.d.ts +15 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3629 -1950
  24. package/dist/index.js.map +32 -1
  25. package/package.json +17 -20
  26. package/src/A2APeerInspector.tsx +378 -0
  27. package/src/A2ATaskTimeline.tsx +218 -0
  28. package/src/AgUiEventStream.ts +488 -0
  29. package/src/ChatComposer.tsx +239 -0
  30. package/src/GeminiAccountMenu.tsx +212 -0
  31. package/src/GeminiPersonalContextEdit.tsx +147 -0
  32. package/src/GoogleSpotlightBar.tsx +165 -0
  33. package/src/InlineBranchCard.tsx +97 -0
  34. package/src/InlineWorkerCard.tsx +182 -0
  35. package/src/Markdown.tsx +701 -0
  36. package/src/McpAppCard.tsx +161 -0
  37. package/src/MessageBubble.tsx +95 -0
  38. package/src/ModelSelector.tsx +300 -0
  39. package/src/TaskCreateForm.tsx +123 -0
  40. package/src/TaskDetail.tsx +224 -0
  41. package/src/TaskList.tsx +136 -0
  42. package/src/TaskPriorityIcon.tsx +39 -0
  43. package/src/TaskRow.tsx +177 -0
  44. package/src/TaskStatusIcon.tsx +39 -0
  45. package/src/ToolCall.tsx +639 -0
  46. package/src/index.ts +107 -0
  47. package/src/internal.tsx +107 -0
  48. package/src/types.ts +212 -0
@@ -0,0 +1,239 @@
1
+ // SPDX-License-Identifier: Apache-2.0 AND MIT
2
+ // Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/ChatComposer.tsx), MIT, rebuilt on Material Design 3.
3
+
4
+ "use client";
5
+
6
+ import { CircleButton, Icon, Popover, SelectPill, usePopover } from "@aphrody/m3-primitives";
7
+ import { m3Durations, m3Easings } from "@aphrody/m3-motion";
8
+ import { clsx } from "clsx";
9
+ import { AnimatePresence, motion } from "motion/react";
10
+ import { type ReactNode } from "react";
11
+
12
+ import { focusRing } from "./internal";
13
+ import { ModelSelector } from "./ModelSelector";
14
+ import type { ModelOption } from "./types";
15
+
16
+ export interface ChatComposerProps {
17
+ /** Current draft text */
18
+ draft: string;
19
+ /** Called when draft changes */
20
+ onDraftChange: (value: string) => void;
21
+ /** Called when user hits send (Enter or send button) */
22
+ onSend: () => void;
23
+ /** Placeholder for the textarea */
24
+ placeholder?: string;
25
+ /** Show an optional heading above the composer */
26
+ heading?: ReactNode;
27
+ /** Disable the composer — draft can still change, but send/voice are blocked */
28
+ isSending?: boolean;
29
+ /** Project selector config — omit to hide */
30
+ projectSelector?: {
31
+ value: string;
32
+ options: string[];
33
+ onChange: (project: string) => void;
34
+ popover: ReturnType<typeof usePopover>;
35
+ };
36
+ /** Model selector config — omit to hide */
37
+ modelSelector?: {
38
+ value: string;
39
+ options: ModelOption[];
40
+ onChange: (model: string) => void;
41
+ };
42
+ /** Voice button handler — omit to hide */
43
+ onOpenVoice?: () => void;
44
+ /** Optional content rendered at the far right of the toolbar (before send) */
45
+ toolbarExtra?: ReactNode;
46
+ /** Stop handler: while `isSending`, the send button becomes an M3 `stop` icon button. */
47
+ onStop?: () => void;
48
+ /** Additional className on the composer container */
49
+ className?: string;
50
+ }
51
+
52
+ const iconButtonEnter = {
53
+ initial: { scale: 0.6, opacity: 0 },
54
+ animate: {
55
+ scale: 1,
56
+ opacity: 1,
57
+ transition: { duration: m3Durations.short4, ease: m3Easings.emphasizedDecelerate },
58
+ },
59
+ exit: {
60
+ scale: 0.6,
61
+ opacity: 0,
62
+ transition: { duration: m3Durations.short3, ease: m3Easings.emphasizedAccelerate },
63
+ },
64
+ } as const;
65
+
66
+ /**
67
+ * Generic chat composer on the M3 search-bar anatomy: a `surface-container-high` container with
68
+ * the extra-large (28 dp) shape, a `text-body-large` expanding textarea, an optional project
69
+ * pill, model selector chip, a standard mic icon button and a filled send / stop icon button.
70
+ */
71
+ export function ChatComposer({
72
+ draft,
73
+ onDraftChange,
74
+ onSend,
75
+ placeholder = "Ask something...",
76
+ heading,
77
+ isSending = false,
78
+ projectSelector,
79
+ modelSelector,
80
+ onOpenVoice,
81
+ toolbarExtra,
82
+ onStop,
83
+ className,
84
+ }: ChatComposerProps) {
85
+ const canSend = !isSending && draft.trim().length > 0;
86
+ const showStop = isSending && !!onStop;
87
+
88
+ return (
89
+ <>
90
+ {heading && (
91
+ <div className="mb-3 flex items-center gap-2 px-1 text-title-small text-on-surface-variant">
92
+ <Icon name="auto_awesome" size={18} filled className="text-primary" />
93
+ {heading}
94
+ </div>
95
+ )}
96
+
97
+ <div
98
+ className={clsx(
99
+ "m3-chat-composer rounded-extra-large bg-surface-container-high px-4 pt-3 pb-2 text-on-surface",
100
+ "transition-colors duration-200 ease-standard focus-within:bg-surface-container-highest",
101
+ className,
102
+ )}
103
+ >
104
+ <textarea
105
+ value={draft}
106
+ onChange={(event) => onDraftChange(event.target.value)}
107
+ onKeyDown={(event) => {
108
+ if (event.key === "Enter" && !event.shiftKey) {
109
+ event.preventDefault();
110
+ onSend();
111
+ }
112
+ }}
113
+ placeholder={placeholder}
114
+ aria-label={placeholder}
115
+ rows={2}
116
+ className="block w-full resize-none border-0 bg-transparent px-1 text-body-large text-on-surface caret-primary outline-none placeholder:text-on-surface-variant focus:outline-none focus:ring-0"
117
+ />
118
+
119
+ <div className="mt-2 flex items-center justify-between gap-2">
120
+ {projectSelector ? (
121
+ <div className="w-[210px]">
122
+ <Popover.Root
123
+ open={projectSelector.popover.open}
124
+ onOpenChange={projectSelector.popover.setOpen}
125
+ >
126
+ <Popover.Trigger asChild>
127
+ <SelectPill className="w-full" aria-haspopup="listbox">
128
+ {projectSelector.value}
129
+ </SelectPill>
130
+ </Popover.Trigger>
131
+ <Popover.Content
132
+ align="start"
133
+ sideOffset={8}
134
+ className="min-w-[210px] overflow-hidden rounded-extra-small! py-2"
135
+ >
136
+ <div
137
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 menu in a popover with icon rows; a native <select>/<datalist> cannot render it.
138
+ role="listbox"
139
+ aria-label="Projects"
140
+ >
141
+ {projectSelector.options.map((option) => {
142
+ const selected = option === projectSelector.value;
143
+ return (
144
+ <button
145
+ key={option}
146
+ type="button"
147
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 menu item with a check icon and state layer; a native <option> can only hold text.
148
+ role="option"
149
+ aria-selected={selected}
150
+ onClick={() => {
151
+ projectSelector.onChange(option);
152
+ projectSelector.popover.setOpen(false);
153
+ }}
154
+ className={clsx(
155
+ "flex h-12 w-full items-center gap-3 px-3 text-left text-label-large",
156
+ "transition-colors duration-200 ease-standard",
157
+ selected
158
+ ? "bg-secondary-container text-on-secondary-container"
159
+ : "text-on-surface hover:bg-on-surface/8",
160
+ focusRing,
161
+ "focus-visible:outline-offset-[-3px]",
162
+ )}
163
+ >
164
+ <span className="min-w-0 flex-1 truncate">{option}</span>
165
+ {selected && <Icon name="check" size={20} />}
166
+ </button>
167
+ );
168
+ })}
169
+ </div>
170
+ </Popover.Content>
171
+ </Popover.Root>
172
+ </div>
173
+ ) : (
174
+ <div />
175
+ )}
176
+
177
+ <motion.div layout className="flex items-center gap-1">
178
+ {modelSelector && (
179
+ <div className="w-[180px]">
180
+ <ModelSelector
181
+ models={modelSelector.options}
182
+ value={modelSelector.value}
183
+ onChange={modelSelector.onChange}
184
+ size="md"
185
+ />
186
+ </div>
187
+ )}
188
+
189
+ {toolbarExtra}
190
+
191
+ {onOpenVoice && (
192
+ <CircleButton
193
+ icon="mic"
194
+ variant="standard"
195
+ onClick={onOpenVoice}
196
+ disabled={isSending}
197
+ aria-label="Open Voice Input"
198
+ />
199
+ )}
200
+
201
+ <AnimatePresence initial={false} mode="popLayout">
202
+ {showStop ? (
203
+ <motion.button
204
+ key="stop"
205
+ type="button"
206
+ {...iconButtonEnter}
207
+ onClick={onStop}
208
+ aria-label="Stop"
209
+ className={clsx(
210
+ "flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-on-primary",
211
+ "transition-colors duration-200 ease-standard hover:bg-primary/92 active:bg-primary/90",
212
+ focusRing,
213
+ )}
214
+ >
215
+ <Icon name="stop" size={24} filled />
216
+ </motion.button>
217
+ ) : canSend ? (
218
+ <motion.button
219
+ key="send"
220
+ type="button"
221
+ {...iconButtonEnter}
222
+ onClick={onSend}
223
+ aria-label="Send"
224
+ className={clsx(
225
+ "flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-on-primary",
226
+ "transition-colors duration-200 ease-standard hover:bg-primary/92 active:bg-primary/90",
227
+ focusRing,
228
+ )}
229
+ >
230
+ <Icon name="arrow_upward" size={24} />
231
+ </motion.button>
232
+ ) : null}
233
+ </AnimatePresence>
234
+ </motion.div>
235
+ </div>
236
+ </div>
237
+ </>
238
+ );
239
+ }
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Benchmark: assets/ui/gemini-account-menu.png & gemini-composer-more-uploads.png
3
+ // Anchored account menu and composer actions menu matching Google Gemini Web UI
4
+
5
+ "use client";
6
+
7
+ import { Icon, Popover } from "@aphrody/m3-primitives";
8
+ import { clsx } from "clsx";
9
+ import type { ReactNode } from "react";
10
+
11
+ export interface MenuItem {
12
+ id: string;
13
+ label: string;
14
+ icon?: string;
15
+ shortcut?: string;
16
+ onClick?: () => void;
17
+ destructive?: boolean;
18
+ dividerAfter?: boolean;
19
+ }
20
+
21
+ export interface GeminiAccountMenuProps {
22
+ /** User name */
23
+ name: string;
24
+ /** User email */
25
+ email: string;
26
+ /** Avatar image URL or fallback initials */
27
+ avatar?: ReactNode;
28
+ /** Menu open state */
29
+ open: boolean;
30
+ /** Menu open state change */
31
+ onOpenChange: (open: boolean) => void;
32
+ /** Menu items */
33
+ items?: MenuItem[];
34
+ /** Trigger element */
35
+ children: ReactNode;
36
+ /** Additional container class */
37
+ className?: string;
38
+ }
39
+
40
+ const EMPTY_MENU_ITEMS: MenuItem[] = [];
41
+
42
+ /**
43
+ * Gemini Sidebar Footer Account Menu.
44
+ * Benchmark parity: `assets/ui/gemini-account-menu.png` (manifest item 2).
45
+ * Anchored to the sidebar footer avatar/profile card, displaying Google account
46
+ * details and navigation links with M3 typography, icons, and keyboard-reachable rows.
47
+ */
48
+ export function GeminiAccountMenu({
49
+ name,
50
+ email,
51
+ avatar,
52
+ open,
53
+ onOpenChange,
54
+ items = EMPTY_MENU_ITEMS,
55
+ children,
56
+ className,
57
+ }: GeminiAccountMenuProps) {
58
+ return (
59
+ <Popover.Root open={open} onOpenChange={onOpenChange}>
60
+ <Popover.Trigger asChild>{children}</Popover.Trigger>
61
+ <Popover.Content
62
+ align="start"
63
+ side="top"
64
+ sideOffset={8}
65
+ className={clsx(
66
+ "gemini-account-menu w-[300px] overflow-hidden rounded-[var(--gemini-corner-card,16px)]",
67
+ "border border-outline-variant/40 bg-surface-container p-2 text-on-surface shadow-md3-3",
68
+ className,
69
+ )}
70
+ >
71
+ {/* User Card Header */}
72
+ <div className="flex items-center gap-3 rounded-small bg-surface-container-high p-3">
73
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-container text-title-medium text-on-primary-container">
74
+ {avatar ?? name.charAt(0).toUpperCase()}
75
+ </div>
76
+ <div className="min-w-0 flex-1">
77
+ <div className="truncate text-body-medium font-medium text-on-surface">{name}</div>
78
+ <div className="truncate text-body-small text-on-surface-variant">{email}</div>
79
+ </div>
80
+ </div>
81
+
82
+ {/* Action Items List */}
83
+ <div className="mt-2 space-y-0.5" role="menu">
84
+ {items.map((item) => (
85
+ <div key={item.id}>
86
+ <button
87
+ type="button"
88
+ role="menuitem"
89
+ onClick={() => {
90
+ item.onClick?.();
91
+ onOpenChange(false);
92
+ }}
93
+ className={clsx(
94
+ "flex w-full items-center justify-between rounded-small px-3 py-2 text-body-medium transition-colors",
95
+ "hover:bg-on-surface/8 active:bg-on-surface/12",
96
+ item.destructive
97
+ ? "text-error hover:bg-error/8"
98
+ : "text-on-surface hover:text-on-surface",
99
+ )}
100
+ >
101
+ <div className="flex items-center gap-3">
102
+ {item.icon && (
103
+ <Icon
104
+ name={item.icon}
105
+ size={20}
106
+ className={item.destructive ? "text-error" : "text-on-surface-variant"}
107
+ />
108
+ )}
109
+ <span>{item.label}</span>
110
+ </div>
111
+ {item.shortcut && (
112
+ <span className="text-label-small text-on-surface-variant">{item.shortcut}</span>
113
+ )}
114
+ </button>
115
+ {item.dividerAfter && <div className="my-1 border-t border-outline-variant/30" />}
116
+ </div>
117
+ ))}
118
+ </div>
119
+ </Popover.Content>
120
+ </Popover.Root>
121
+ );
122
+ }
123
+
124
+ export interface ComposerMoreUploadsMenuProps {
125
+ /** Menu open state */
126
+ open: boolean;
127
+ /** Menu open state change */
128
+ onOpenChange: (open: boolean) => void;
129
+ /** Items in upload/add menu */
130
+ items: MenuItem[];
131
+ /** Submenu items (e.g., More uploads / Google Drive / Workspace) */
132
+ submenuItems?: MenuItem[];
133
+ /** Trigger element */
134
+ children: ReactNode;
135
+ }
136
+
137
+ /**
138
+ * Composer Add & More Uploads Menu.
139
+ * Benchmark parity: `assets/ui/gemini-composer-more-uploads.png` (manifest item 3).
140
+ * Anchored to the '+' trigger of the composer, grouping related upload actions
141
+ * with an optional adjacent secondary submenu.
142
+ */
143
+ export function ComposerMoreUploadsMenu({
144
+ open,
145
+ onOpenChange,
146
+ items,
147
+ submenuItems,
148
+ children,
149
+ }: ComposerMoreUploadsMenuProps) {
150
+ return (
151
+ <Popover.Root open={open} onOpenChange={onOpenChange}>
152
+ <Popover.Trigger asChild>{children}</Popover.Trigger>
153
+ <Popover.Content
154
+ align="start"
155
+ side="top"
156
+ sideOffset={10}
157
+ className="gemini-composer-uploads-menu flex gap-2 rounded-[var(--gemini-corner-card,16px)] border border-outline-variant/30 bg-surface-container p-1.5 shadow-md3-3"
158
+ >
159
+ {/* Primary Menu */}
160
+ <div className="w-[220px] space-y-0.5" role="menu">
161
+ {items.map((item) => (
162
+ <div key={item.id}>
163
+ <button
164
+ type="button"
165
+ role="menuitem"
166
+ onClick={() => {
167
+ item.onClick?.();
168
+ onOpenChange(false);
169
+ }}
170
+ className="flex w-full items-center gap-3 rounded-small px-3 py-2 text-body-medium text-on-surface transition-colors hover:bg-on-surface/8"
171
+ >
172
+ {item.icon && (
173
+ <Icon name={item.icon} size={20} className="text-on-surface-variant" />
174
+ )}
175
+ <span className="flex-1 text-left">{item.label}</span>
176
+ </button>
177
+ {item.dividerAfter && <div className="my-1 border-t border-outline-variant/30" />}
178
+ </div>
179
+ ))}
180
+ </div>
181
+
182
+ {/* Secondary Submenu if provided */}
183
+ {submenuItems && submenuItems.length > 0 && (
184
+ <div
185
+ className="w-[200px] border-l border-outline-variant/20 pl-2 space-y-0.5"
186
+ role="menu"
187
+ aria-label="More uploads"
188
+ >
189
+ <div className="px-2 py-1 text-label-small font-medium text-on-surface-variant uppercase tracking-wider">
190
+ Cloud & Storage
191
+ </div>
192
+ {submenuItems.map((sub) => (
193
+ <button
194
+ key={sub.id}
195
+ type="button"
196
+ role="menuitem"
197
+ onClick={() => {
198
+ sub.onClick?.();
199
+ onOpenChange(false);
200
+ }}
201
+ className="flex w-full items-center gap-3 rounded-small px-3 py-2 text-body-medium text-on-surface transition-colors hover:bg-on-surface/8"
202
+ >
203
+ {sub.icon && <Icon name={sub.icon} size={20} className="text-primary" />}
204
+ <span className="flex-1 text-left">{sub.label}</span>
205
+ </button>
206
+ ))}
207
+ </div>
208
+ )}
209
+ </Popover.Content>
210
+ </Popover.Root>
211
+ );
212
+ }
@@ -0,0 +1,147 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Benchmark: assets/ui/gemini-personal-context-edit.png
3
+ // Focused multiline context editor dialog for user preferences and saved agent memory
4
+
5
+ "use client";
6
+
7
+ import { Button, DialogClose, DialogContent, DialogRoot, Icon } from "@aphrody/m3-primitives";
8
+ import { clsx } from "clsx";
9
+ import { useState } from "react";
10
+
11
+ export interface GeminiPersonalContextEditProps {
12
+ /** Controls open state of the dialog */
13
+ open: boolean;
14
+ /** Open state change callback */
15
+ onOpenChange: (open: boolean) => void;
16
+ /** Current context text */
17
+ initialContext?: string;
18
+ /** Title of the dialog */
19
+ title?: string;
20
+ /** Subtitle/description of context rules */
21
+ description?: string;
22
+ /** Save callback */
23
+ onSave: (context: string) => void | Promise<void>;
24
+ /** Clear/delete context callback */
25
+ onClear?: () => void;
26
+ /** Additional container classes */
27
+ className?: string;
28
+ }
29
+
30
+ /**
31
+ * Gemini Personal Context & User Memory Editor Dialog.
32
+ * Benchmark parity: `assets/ui/gemini-personal-context-edit.png` (manifest item 4).
33
+ * Features:
34
+ * - Centered dialog over translucent backdrop blur.
35
+ * - Focused multiline context editor with visible focus outline.
36
+ * - Save button is disabled when context is empty or unchanged.
37
+ * - Contextual instructions for how agent uses personal context.
38
+ */
39
+ export function GeminiPersonalContextEdit({
40
+ open,
41
+ onOpenChange,
42
+ initialContext = "",
43
+ title = "Your personal context",
44
+ description = "Help Gemini understand your preferences, interests, and how you want it to respond. This information is saved and applied across your conversations.",
45
+ onSave,
46
+ onClear,
47
+ className,
48
+ }: GeminiPersonalContextEditProps) {
49
+ const [context, setContext] = useState(initialContext);
50
+ const [isSaving, setIsSaving] = useState(false);
51
+
52
+ const [previousInputs, setPreviousInputs] = useState({ initialContext, open });
53
+ // Reset the draft before committing a changed source or open state, while preserving the editor node and focus.
54
+ if (previousInputs.initialContext !== initialContext || previousInputs.open !== open) {
55
+ setPreviousInputs({ initialContext, open });
56
+ setContext(initialContext);
57
+ }
58
+
59
+ const hasChanged = context.trim() !== initialContext.trim();
60
+ const canSave = context.trim().length > 0 && hasChanged && !isSaving;
61
+
62
+ const handleSave = async () => {
63
+ if (!canSave) return;
64
+ setIsSaving(true);
65
+ try {
66
+ await onSave(context.trim());
67
+ onOpenChange(false);
68
+ } finally {
69
+ setIsSaving(false);
70
+ }
71
+ };
72
+
73
+ return (
74
+ <DialogRoot open={open} onOpenChange={onOpenChange}>
75
+ <DialogContent
76
+ className={clsx(
77
+ "gemini-context-edit-dialog w-full max-w-[620px] rounded-[var(--gemini-corner-card,16px)]",
78
+ "border border-outline-variant/30 bg-surface p-6 text-on-surface shadow-md3-3",
79
+ className,
80
+ )}
81
+ >
82
+ {/* Header */}
83
+ <div className="flex items-start justify-between gap-4">
84
+ <div className="space-y-1">
85
+ <h2 className="text-headline-small font-normal text-on-surface flex items-center gap-2">
86
+ <Icon name="psychology" size={24} className="text-primary" />
87
+ {title}
88
+ </h2>
89
+ <p className="text-body-medium text-on-surface-variant leading-relaxed">
90
+ {description}
91
+ </p>
92
+ </div>
93
+ <DialogClose asChild>
94
+ <button
95
+ type="button"
96
+ className="rounded-full p-1.5 text-on-surface-variant hover:bg-on-surface/8 transition-colors"
97
+ aria-label="Close"
98
+ >
99
+ <Icon name="close" size={20} />
100
+ </button>
101
+ </DialogClose>
102
+ </div>
103
+
104
+ {/* Text Area Input */}
105
+ <div className="mt-5 space-y-2">
106
+ <div className="relative rounded-medium border-2 border-primary/60 bg-surface-container-low transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20">
107
+ <textarea
108
+ value={context}
109
+ onChange={(e) => setContext(e.target.value)}
110
+ placeholder="e.g. Always respond in concise bullet points, write modern TypeScript with Bun, favor pure functions..."
111
+ rows={6}
112
+ aria-label="Personal context content"
113
+ className="block w-full resize-y border-0 bg-transparent p-3.5 text-body-medium text-on-surface caret-primary outline-none placeholder:text-on-surface-variant/60"
114
+ />
115
+ </div>
116
+ <div className="flex justify-between px-1 text-label-small text-on-surface-variant">
117
+ <span>{context.length} characters</span>
118
+ {initialContext.length > 0 && onClear && (
119
+ <button
120
+ type="button"
121
+ onClick={() => {
122
+ setContext("");
123
+ onClear();
124
+ }}
125
+ className="text-error hover:underline transition-all"
126
+ >
127
+ Clear saved context
128
+ </button>
129
+ )}
130
+ </div>
131
+ </div>
132
+
133
+ {/* Footer Actions */}
134
+ <div className="mt-6 flex items-center justify-end gap-3">
135
+ <DialogClose asChild>
136
+ <Button variant="text" disabled={isSaving}>
137
+ Cancel
138
+ </Button>
139
+ </DialogClose>
140
+ <Button variant="filled" onClick={handleSave} disabled={!canSave} className="px-6">
141
+ {isSaving ? "Saving..." : "Save"}
142
+ </Button>
143
+ </div>
144
+ </DialogContent>
145
+ </DialogRoot>
146
+ );
147
+ }