@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,161 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * @aphrody/m3-ai - McpAppCard
4
+ *
5
+ * Material Design 3 Interactive MCP App Container:
6
+ * - Renders generative UI resources produced by MCP tools (SEP-1724 / mcp-ui).
7
+ * - Hosts interactive guest web applications in an isolated sandbox iframe.
8
+ * - Passes reactive M3 theme and color scheme context to the guest app.
9
+ * - Supports expandable full-bleed or embedded card layouts.
10
+ */
11
+
12
+ import React, { useState, useRef, useMemo } from "react";
13
+ import { clsx } from "clsx";
14
+ import { AppRenderer, type SandboxConfig, type McpUiHostContext } from "@aphrody/mcp-ui";
15
+
16
+ export interface McpAppCardProps {
17
+ toolName: string;
18
+ title?: string;
19
+ html?: string;
20
+ toolResourceUri?: string;
21
+ toolInput?: Record<string, unknown>;
22
+ toolResult?: any;
23
+ sandboxUrl?: string;
24
+ isExpandedDefault?: boolean;
25
+ className?: string;
26
+ onAction?: (action: string, payload?: unknown) => void;
27
+ }
28
+
29
+ export const McpAppCard: React.FC<McpAppCardProps> = ({
30
+ toolName,
31
+ title,
32
+ html,
33
+ toolResourceUri,
34
+ toolInput,
35
+ toolResult,
36
+ sandboxUrl,
37
+ isExpandedDefault = false,
38
+ className,
39
+ onAction: _onAction,
40
+ }) => {
41
+ const [isExpanded, setIsExpanded] = useState(isExpandedDefault);
42
+ const [hasError, setHasError] = useState<string | null>(null);
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+
45
+ // Host context with Material Design 3 tokens
46
+ const hostContext: McpUiHostContext = useMemo(() => {
47
+ const isDark =
48
+ typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
49
+ return {
50
+ theme: isDark ? "dark" : "light",
51
+ locale: typeof navigator !== "undefined" ? navigator.language : "en-US",
52
+ displayMode: isExpanded ? "fullscreen" : "inline",
53
+ };
54
+ }, [isExpanded]);
55
+
56
+ // Fallback default sandbox proxy URL
57
+ const resolvedSandbox: SandboxConfig = useMemo(() => {
58
+ const url = sandboxUrl ? new URL(sandboxUrl) : new URL("https://mcpui.dev/sandbox_proxy.html");
59
+ return {
60
+ url,
61
+ permissions: "allow-scripts allow-forms allow-same-origin",
62
+ };
63
+ }, [sandboxUrl]);
64
+
65
+ return (
66
+ <div
67
+ ref={containerRef}
68
+ className={clsx(
69
+ "flex flex-col rounded-3xl border transition-all duration-300 overflow-hidden",
70
+ "bg-[var(--md-sys-color-surface-container-low,#f7f2fa)]",
71
+ "border-[var(--md-sys-color-outline-variant,#cac4d0)]",
72
+ "text-[var(--md-sys-color-on-surface,#1d1b20)]",
73
+ isExpanded ? "fixed inset-4 z-50 shadow-2xl" : "relative w-full shadow-sm",
74
+ className,
75
+ )}
76
+ >
77
+ {/* App Card Header */}
78
+ <div className="flex items-center justify-between px-4 py-3 border-b border-[var(--md-sys-color-outline-variant,#cac4d0)]/40 bg-[var(--md-sys-color-surface-container,#f3edf7)]/60">
79
+ <div className="flex items-center gap-2.5">
80
+ <div className="flex h-7 w-7 items-center justify-center rounded-xl bg-[var(--md-sys-color-primary-container,#eaddff)] text-[var(--md-sys-color-on-primary-container,#21005d)]">
81
+ <span className="material-symbols-outlined text-base">widgets</span>
82
+ </div>
83
+
84
+ <div className="flex flex-col">
85
+ <div className="flex items-center gap-2">
86
+ <span className="text-xs font-semibold tracking-tight">{title || toolName}</span>
87
+ <span className="rounded-full bg-[var(--md-sys-color-surface-container-high,#ece6f0)] px-2 py-0.2 text-[10px] font-mono text-[var(--md-sys-color-on-surface-variant,#49454f)]">
88
+ mcp-ui
89
+ </span>
90
+ </div>
91
+ {toolResourceUri && (
92
+ <span className="text-[10px] font-mono text-[var(--md-sys-color-outline,#79747e)]">
93
+ {toolResourceUri}
94
+ </span>
95
+ )}
96
+ </div>
97
+ </div>
98
+
99
+ {/* Action icons */}
100
+ <div className="flex items-center gap-1">
101
+ <div className="flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-400">
102
+ <span className="material-symbols-outlined text-xs">verified_user</span>
103
+ <span>Sandboxed</span>
104
+ </div>
105
+
106
+ <button
107
+ type="button"
108
+ onClick={() => setIsExpanded(!isExpanded)}
109
+ title={isExpanded ? "Collapse" : "Expand Fullscreen"}
110
+ className="flex h-7 w-7 items-center justify-center rounded-lg text-[var(--md-sys-color-on-surface-variant,#49454f)] hover:bg-[var(--md-sys-color-surface-container-high,#ece6f0)] transition-colors"
111
+ >
112
+ <span className="material-symbols-outlined text-base">
113
+ {isExpanded ? "close_fullscreen" : "open_in_full"}
114
+ </span>
115
+ </button>
116
+ </div>
117
+ </div>
118
+
119
+ {/* App Frame Content */}
120
+ <div
121
+ className={clsx(
122
+ "relative w-full overflow-hidden transition-all",
123
+ isExpanded ? "flex-1 min-h-[500px]" : "h-96",
124
+ )}
125
+ >
126
+ {hasError ? (
127
+ <div className="flex h-full flex-col items-center justify-center p-6 text-center text-xs text-[var(--md-sys-color-error,#b3261e)]">
128
+ <span className="material-symbols-outlined text-3xl mb-1">error</span>
129
+ <span className="font-semibold">MCP App Render Error</span>
130
+ <p className="mt-1 font-mono text-[11px] opacity-80">{hasError}</p>
131
+ </div>
132
+ ) : html ? (
133
+ <AppRenderer
134
+ toolName={toolName}
135
+ html={html}
136
+ sandbox={resolvedSandbox}
137
+ toolInput={toolInput}
138
+ toolResult={toolResult}
139
+ hostContext={hostContext}
140
+ onError={(err) => setHasError(err.message)}
141
+ />
142
+ ) : (
143
+ <div className="flex h-full flex-col items-center justify-center p-6 text-xs text-[var(--md-sys-color-outline,#79747e)]">
144
+ <span className="material-symbols-outlined text-2xl animate-spin mb-1 text-[var(--md-sys-color-primary,#6750a4)]">
145
+ progress_activity
146
+ </span>
147
+ <span>Awaiting MCP UI Resource ({toolName})...</span>
148
+ </div>
149
+ )}
150
+ </div>
151
+
152
+ {/* Footer info */}
153
+ {toolInput && (
154
+ <div className="flex items-center justify-between px-4 py-2 border-t border-[var(--md-sys-color-outline-variant,#cac4d0)]/30 bg-[var(--md-sys-color-surface-container-lowest,#ffffff)] text-[10px] text-[var(--md-sys-color-outline,#79747e)] font-mono">
155
+ <span className="truncate">input: {JSON.stringify(toolInput)}</span>
156
+ <span className="shrink-0 ml-2">M3 UI Bridge</span>
157
+ </div>
158
+ )}
159
+ </div>
160
+ );
161
+ };
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: Apache-2.0 AND MIT
2
+ // Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/MessageBubble.tsx), MIT, rebuilt on Material Design 3.
3
+
4
+ "use client";
5
+
6
+ import { CircleButton } from "@aphrody/m3-primitives";
7
+ import { clsx } from "clsx";
8
+
9
+ import { Markdown } from "./Markdown";
10
+
11
+ export interface MessageBubbleProps {
12
+ content: string;
13
+ isUser: boolean;
14
+ isStreaming?: boolean;
15
+ onCopy?: (content: string) => void;
16
+ onDislike?: (content: string) => void;
17
+ onFork?: () => void;
18
+ variant?: "default" | "codex";
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * One chat turn. The user turn is an M3 bubble, the assistant turn is plain markdown
24
+ * on the surface, as in M3 / Codex conversations.
25
+ */
26
+ export function MessageBubble({
27
+ content,
28
+ isUser,
29
+ isStreaming = false,
30
+ onCopy,
31
+ onDislike,
32
+ onFork,
33
+ variant = "default",
34
+ className,
35
+ }: MessageBubbleProps) {
36
+ const isCodex = variant === "codex";
37
+
38
+ return (
39
+ <div
40
+ className={clsx("group flex flex-col py-1.5 w-full", isUser ? "items-end" : "items-start", className)}
41
+ >
42
+ <div
43
+ className={clsx(
44
+ "min-w-0",
45
+ isUser
46
+ ? isCodex
47
+ ? "max-w-[75%] rounded-[28px] bg-surface-container-high px-5 py-3 text-body-large text-on-surface shadow-xs"
48
+ : "max-w-[80%] rounded-large rounded-br-extra-small bg-secondary-container px-4 py-2.5 text-body-large text-on-secondary-container"
49
+ : "w-full max-w-full bg-transparent text-body-large text-on-surface",
50
+ )}
51
+ aria-busy={isStreaming || undefined}
52
+ >
53
+ {isUser ? (
54
+ <div className="whitespace-pre-wrap break-words">{content}</div>
55
+ ) : (
56
+ <Markdown content={content} size="large" className="break-words font-sans" />
57
+ )}
58
+ </div>
59
+ {!isUser && (onCopy || onDislike || onFork) ? (
60
+ <div className="mt-1.5 flex items-center gap-1 opacity-0 transition-opacity duration-200 ease-standard group-focus-within:opacity-100 group-hover:opacity-100">
61
+ {onCopy && (
62
+ <CircleButton
63
+ icon="content_copy"
64
+ variant="standard"
65
+ size="sm"
66
+ onClick={() => onCopy(content)}
67
+ aria-label={isStreaming ? "Copy Streaming Message" : "Copy Message"}
68
+ title={isStreaming ? "Copy streaming message" : "Copier"}
69
+ />
70
+ )}
71
+ {onDislike && (
72
+ <CircleButton
73
+ icon="thumb_down"
74
+ variant="standard"
75
+ size="sm"
76
+ onClick={() => onDislike(content)}
77
+ aria-label="Dislike"
78
+ title="Je n'aime pas"
79
+ />
80
+ )}
81
+ {onFork && (
82
+ <CircleButton
83
+ icon="call_split"
84
+ variant="standard"
85
+ size="sm"
86
+ onClick={onFork}
87
+ aria-label="Fork"
88
+ title="Bifurquer à partir de ce tour"
89
+ />
90
+ )}
91
+ </div>
92
+ ) : null}
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,300 @@
1
+ // SPDX-License-Identifier: Apache-2.0 AND MIT
2
+ // Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/ModelSelector.tsx), MIT, rebuilt on Material Design 3.
3
+
4
+ "use client";
5
+
6
+ import {
7
+ Icon,
8
+ Popover,
9
+ usePopover,
10
+ SelectPill,
11
+ type SelectPillProps,
12
+ } from "@aphrody/m3-primitives";
13
+ import { clsx } from "clsx";
14
+ import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
15
+
16
+ import { focusRing } from "./internal";
17
+ import type { ModelOption } from "./types";
18
+
19
+ export interface ModelSelectorProps {
20
+ /** Available models */
21
+ models: ModelOption[];
22
+ /** Currently selected model ID */
23
+ value: string;
24
+ /** Called when a model is selected */
25
+ onChange: (value: string) => void;
26
+ /** Placeholder when nothing selected */
27
+ placeholder?: string;
28
+ /** Search placeholder */
29
+ searchPlaceholder?: string;
30
+ /** SelectPill variant */
31
+ variant?: SelectPillProps["variant"];
32
+ /** SelectPill size */
33
+ size?: SelectPillProps["size"];
34
+ /** Controlled popover (optional) */
35
+ popover?: ReturnType<typeof usePopover>;
36
+ /** Additional trigger className */
37
+ className?: string;
38
+ /** Disabled state */
39
+ disabled?: boolean;
40
+ }
41
+
42
+ /** Group models by provider, keeping the order of first appearance. */
43
+ export function groupModelsByProvider(
44
+ models: ModelOption[],
45
+ search = "",
46
+ ): Record<string, ModelOption[]> {
47
+ const q = search.trim().toLowerCase();
48
+ const filtered = q
49
+ ? models.filter((m) => m.name.toLowerCase().includes(q) || m.provider.toLowerCase().includes(q))
50
+ : models;
51
+
52
+ return filtered.reduce(
53
+ (acc, model) => {
54
+ if (!acc[model.provider]) acc[model.provider] = [];
55
+ acc[model.provider].push(model);
56
+ return acc;
57
+ },
58
+ Object.create(null) as Record<string, ModelOption[]>,
59
+ );
60
+ }
61
+
62
+ export interface ModelOptionListProps {
63
+ /** Models grouped by provider (see {@link groupModelsByProvider}). */
64
+ grouped: Record<string, ModelOption[]>;
65
+ /** Currently selected model ID */
66
+ value: string;
67
+ /** Called when a model is picked */
68
+ onSelect: (value: string) => void;
69
+ className?: string;
70
+ }
71
+
72
+ /**
73
+ * The M3 menu list of the model selector: provider section headers (`text-title-small`),
74
+ * 48 dp two-line items (`text-label-large` + `text-body-small` supporting text), the selected
75
+ * item on `secondary-container`, capabilities as small outlined chips.
76
+ */
77
+ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOptionListProps) {
78
+ const providers = Object.entries(grouped);
79
+ const models = providers.flatMap(([, providerModels]) => providerModels);
80
+ const [focusedId, setFocusedId] = useState(value);
81
+ const focusId = models.some((model) => model.id === focusedId)
82
+ ? focusedId
83
+ : (models.find((model) => model.id === value)?.id ?? models[0]?.id);
84
+ return (
85
+ <div
86
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 menu whose options are rich rows (headers, supporting text, chips); a native <select>/<datalist> cannot render them.
87
+ role="listbox"
88
+ aria-label="Models"
89
+ tabIndex={-1}
90
+ onKeyDown={(event) => {
91
+ if (event.defaultPrevented) return;
92
+ const options = Array.from(
93
+ event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'),
94
+ );
95
+ const index = options.indexOf(event.target as HTMLButtonElement);
96
+ if (index < 0) return;
97
+ let next: number;
98
+ switch (event.key) {
99
+ case "ArrowDown":
100
+ next = (index + 1) % options.length;
101
+ break;
102
+ case "ArrowUp":
103
+ next = (index - 1 + options.length) % options.length;
104
+ break;
105
+ case "Home":
106
+ next = 0;
107
+ break;
108
+ case "End":
109
+ next = options.length - 1;
110
+ break;
111
+ default:
112
+ return;
113
+ }
114
+ event.preventDefault();
115
+ options[next]!.focus();
116
+ }}
117
+ className={clsx("flex flex-col", className)}
118
+ >
119
+ {providers.map(([provider, providerModels], groupIndex) => (
120
+ <div
121
+ key={provider}
122
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- provider section of the custom listbox with a visible M3 header row; <optgroup> only exists inside a native <select>.
123
+ role="group"
124
+ aria-label={provider}
125
+ className={clsx(groupIndex > 0 && "mt-1 border-t border-outline-variant pt-1")}
126
+ >
127
+ <div className="px-3 pt-2 pb-1 text-title-small text-on-surface-variant">{provider}</div>
128
+ {providerModels.map((model) => {
129
+ const selected = model.id === value;
130
+ return (
131
+ <button
132
+ key={model.id}
133
+ type="button"
134
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 two-line menu item (label, supporting text, capability chips, check icon); a native <option> can only hold text.
135
+ role="option"
136
+ aria-selected={selected}
137
+ tabIndex={model.id === focusId ? 0 : -1}
138
+ onFocus={() => setFocusedId(model.id)}
139
+ onClick={() => onSelect(model.id)}
140
+ className={clsx(
141
+ "flex min-h-12 w-full items-center gap-3 px-3 py-2 text-left",
142
+ "transition-colors duration-200 ease-standard",
143
+ selected
144
+ ? "bg-secondary-container text-on-secondary-container"
145
+ : "text-on-surface hover:bg-on-surface/8 active:bg-on-surface/10",
146
+ focusRing,
147
+ "focus-visible:outline-offset-[-3px]",
148
+ )}
149
+ >
150
+ <div className="min-w-0 flex-1">
151
+ <div className="truncate text-label-large">{model.name}</div>
152
+ {model.context_window || model.capabilities?.length ? (
153
+ <div className="mt-0.5 flex flex-wrap items-center gap-1">
154
+ {model.context_window ? (
155
+ <span
156
+ className={clsx(
157
+ "text-body-small",
158
+ selected ? "text-on-secondary-container" : "text-on-surface-variant",
159
+ )}
160
+ >
161
+ {model.context_window.toLocaleString()} tokens
162
+ </span>
163
+ ) : null}
164
+ {model.capabilities?.map((capability) => (
165
+ <span
166
+ key={capability}
167
+ className={clsx(
168
+ "inline-flex h-5 items-center rounded-small border border-outline-variant px-1.5 text-label-small",
169
+ selected ? "text-on-secondary-container" : "text-on-surface-variant",
170
+ )}
171
+ >
172
+ {capability}
173
+ </span>
174
+ ))}
175
+ </div>
176
+ ) : null}
177
+ </div>
178
+ {selected && <Icon name="check" size={20} className="shrink-0" />}
179
+ </button>
180
+ );
181
+ })}
182
+ </div>
183
+ ))}
184
+ {providers.length === 0 && (
185
+ <div className="px-3 py-3 text-center text-body-small text-on-surface-variant">
186
+ No models found
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ }
192
+
193
+ /**
194
+ * The menu's search field. It mounts with the popover content and takes focus then (replaces
195
+ * `autoFocus`), so typing filters the list as soon as the menu opens.
196
+ */
197
+ function ModelSearchField({
198
+ value,
199
+ onChange,
200
+ placeholder,
201
+ }: {
202
+ value: string;
203
+ onChange: (value: string) => void;
204
+ placeholder: string;
205
+ }) {
206
+ const inputRef = useRef<HTMLInputElement>(null);
207
+
208
+ useEffect(() => {
209
+ inputRef.current?.focus();
210
+ }, []);
211
+
212
+ return (
213
+ <div className="px-2 pb-2">
214
+ <label className="flex h-10 items-center gap-2 rounded-full bg-surface-container-high px-3 text-on-surface-variant focus-within:bg-surface-container-highest">
215
+ <Icon name="search" size={20} className="shrink-0" />
216
+ <input
217
+ ref={inputRef}
218
+ value={value}
219
+ onChange={(event) => onChange(event.target.value)}
220
+ placeholder={placeholder}
221
+ aria-label={placeholder}
222
+ className="min-w-0 flex-1 bg-transparent text-body-large text-on-surface outline-none placeholder:text-on-surface-variant"
223
+ />
224
+ </label>
225
+ </div>
226
+ );
227
+ }
228
+
229
+ export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
230
+ (
231
+ {
232
+ models,
233
+ value,
234
+ onChange,
235
+ placeholder = "Select model...",
236
+ searchPlaceholder = "Search models...",
237
+ variant,
238
+ size,
239
+ popover: externalPopover,
240
+ className,
241
+ disabled,
242
+ },
243
+ ref,
244
+ ) => {
245
+ const internalPopover = usePopover();
246
+ const popover = externalPopover ?? internalPopover;
247
+ const [search, setSearch] = useState("");
248
+
249
+ const selectedModel = models.find((m) => m.id === value);
250
+
251
+ const grouped = useMemo(() => groupModelsByProvider(models, search), [models, search]);
252
+
253
+ return (
254
+ <Popover.Root
255
+ open={popover.open}
256
+ onOpenChange={(open) => {
257
+ popover.setOpen(open);
258
+ if (!open) setSearch("");
259
+ }}
260
+ >
261
+ <Popover.Trigger asChild>
262
+ <SelectPill
263
+ ref={ref}
264
+ variant={variant}
265
+ size={size}
266
+ disabled={disabled}
267
+ aria-haspopup="listbox"
268
+ className={clsx("w-full", className)}
269
+ >
270
+ {selectedModel?.name ?? placeholder}
271
+ </SelectPill>
272
+ </Popover.Trigger>
273
+ <Popover.Content
274
+ align="end"
275
+ sideOffset={8}
276
+ style={{ width: 280 }}
277
+ className="overflow-hidden rounded-extra-small! py-2"
278
+ >
279
+ {/* Search */}
280
+ <ModelSearchField value={search} onChange={setSearch} placeholder={searchPlaceholder} />
281
+
282
+ {/* Grouped results */}
283
+ <div className="max-h-[320px] overflow-y-auto">
284
+ <ModelOptionList
285
+ grouped={grouped}
286
+ value={value}
287
+ onSelect={(id) => {
288
+ onChange(id);
289
+ popover.setOpen(false);
290
+ setSearch("");
291
+ }}
292
+ />
293
+ </div>
294
+ </Popover.Content>
295
+ </Popover.Root>
296
+ );
297
+ },
298
+ );
299
+
300
+ ModelSelector.displayName = "ModelSelector";
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: Apache-2.0 AND MIT
2
+ // Derived from SpaceUI (packages/aphrody-spaceui/packages/ai/src/TaskCreateForm.tsx), MIT, rebuilt on Material Design 3.
3
+
4
+ import { Button, Input, Select, SelectOption, TextArea } from "@aphrody/m3-primitives";
5
+ import { clsx } from "clsx";
6
+ import { useCallback, useEffect, useId, useRef, useState } from "react";
7
+
8
+ import type { TaskPriority } from "./types";
9
+ import { TASK_PRIORITY_LABEL } from "./types";
10
+
11
+ export interface TaskCreateFormData {
12
+ title: string;
13
+ description: string;
14
+ priority: TaskPriority;
15
+ }
16
+
17
+ export interface TaskCreateFormProps {
18
+ onSubmit: (data: TaskCreateFormData) => void;
19
+ onCancel?: () => void;
20
+ defaultPriority?: TaskPriority;
21
+ isSubmitting?: boolean;
22
+ className?: string;
23
+ }
24
+
25
+ const PRIORITIES: TaskPriority[] = ["critical", "high", "medium", "low"];
26
+
27
+ /**
28
+ * Inline task creation on an M3 surface: title and description text fields, a priority select,
29
+ * and M3 actions (filled `Create`, text `Cancel`).
30
+ */
31
+ export function TaskCreateForm({
32
+ onSubmit,
33
+ onCancel,
34
+ defaultPriority = "medium",
35
+ isSubmitting,
36
+ className,
37
+ }: TaskCreateFormProps) {
38
+ const [title, setTitle] = useState("");
39
+ const [description, setDescription] = useState("");
40
+ const [priority, setPriority] = useState<TaskPriority>(defaultPriority);
41
+ const titleInputId = useId();
42
+ const descriptionInputId = useId();
43
+ const titleInputRef = useRef<HTMLInputElement>(null);
44
+
45
+ // Focus the title when the form mounts (replaces `autoFocus`).
46
+ useEffect(() => {
47
+ titleInputRef.current?.focus();
48
+ }, []);
49
+
50
+ const canSubmit = title.trim().length > 0 && !isSubmitting;
51
+
52
+ const handleSubmit = useCallback(() => {
53
+ if (!canSubmit) return;
54
+ onSubmit({ title: title.trim(), description: description.trim(), priority });
55
+ setTitle("");
56
+ setDescription("");
57
+ setPriority(defaultPriority);
58
+ }, [canSubmit, onSubmit, title, description, priority, defaultPriority]);
59
+
60
+ return (
61
+ <div
62
+ className={clsx(
63
+ "flex flex-col gap-3 rounded-medium bg-surface-container-low p-4 text-on-surface",
64
+ className,
65
+ )}
66
+ >
67
+ <div className="flex items-center gap-2">
68
+ <label htmlFor={titleInputId} className="sr-only">
69
+ Task Title
70
+ </label>
71
+ <Input
72
+ ref={titleInputRef}
73
+ id={titleInputId}
74
+ type="text"
75
+ size="lg"
76
+ value={title}
77
+ onChange={(e) => setTitle(e.target.value)}
78
+ onKeyDown={(e) => {
79
+ if (e.key === "Enter") handleSubmit();
80
+ if (e.key === "Escape") onCancel?.();
81
+ }}
82
+ placeholder="Task title..."
83
+ className="min-w-0 flex-1"
84
+ disabled={isSubmitting}
85
+ />
86
+ <Select
87
+ value={priority}
88
+ size="lg"
89
+ onChange={(value) => setPriority(value as TaskPriority)}
90
+ disabled={isSubmitting}
91
+ >
92
+ {PRIORITIES.map((p) => (
93
+ <SelectOption key={p} value={p}>
94
+ {TASK_PRIORITY_LABEL[p]}
95
+ </SelectOption>
96
+ ))}
97
+ </Select>
98
+ </div>
99
+ <label htmlFor={descriptionInputId} className="sr-only">
100
+ Description
101
+ </label>
102
+ <TextArea
103
+ id={descriptionInputId}
104
+ value={description}
105
+ onChange={(e) => setDescription(e.target.value)}
106
+ placeholder="Description (optional)"
107
+ rows={2}
108
+ className="w-full resize-none"
109
+ disabled={isSubmitting}
110
+ />
111
+ <div className="flex items-center justify-end gap-2">
112
+ {onCancel && (
113
+ <Button variant="text" size="lg" onClick={onCancel} disabled={isSubmitting}>
114
+ Cancel
115
+ </Button>
116
+ )}
117
+ <Button variant="filled" size="lg" disabled={!canSubmit} onClick={handleSubmit}>
118
+ Create
119
+ </Button>
120
+ </div>
121
+ </div>
122
+ );
123
+ }