@aphrody/m3-ai 3.3.1 → 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.
- package/README.md +7 -7
- package/dist/A2APeerInspector.d.ts +41 -0
- package/dist/A2APeerInspector.d.ts.map +1 -0
- package/dist/A2ATaskTimeline.d.ts +28 -0
- package/dist/A2ATaskTimeline.d.ts.map +1 -0
- package/dist/AgUiEventStream.d.ts +87 -0
- package/dist/AgUiEventStream.d.ts.map +1 -0
- package/dist/GeminiAccountMenu.d.ts +55 -0
- package/dist/GeminiAccountMenu.d.ts.map +1 -0
- package/dist/GeminiPersonalContextEdit.d.ts +29 -0
- package/dist/GeminiPersonalContextEdit.d.ts.map +1 -0
- package/dist/GoogleSpotlightBar.d.ts +32 -0
- package/dist/GoogleSpotlightBar.d.ts.map +1 -0
- package/dist/Markdown.d.ts +2 -1
- package/dist/Markdown.d.ts.map +1 -1
- package/dist/McpAppCard.d.ts +24 -0
- package/dist/McpAppCard.d.ts.map +1 -0
- package/dist/ModelSelector.d.ts.map +1 -1
- package/dist/TaskRow.d.ts.map +1 -1
- package/dist/ToolCall.d.ts.map +1 -1
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3629 -1950
- package/dist/index.js.map +32 -1
- package/package.json +17 -20
- package/src/A2APeerInspector.tsx +378 -0
- package/src/A2ATaskTimeline.tsx +218 -0
- package/src/AgUiEventStream.ts +488 -0
- package/src/ChatComposer.tsx +239 -0
- package/src/GeminiAccountMenu.tsx +212 -0
- package/src/GeminiPersonalContextEdit.tsx +147 -0
- package/src/GoogleSpotlightBar.tsx +165 -0
- package/src/InlineBranchCard.tsx +97 -0
- package/src/InlineWorkerCard.tsx +182 -0
- package/src/Markdown.tsx +701 -0
- package/src/McpAppCard.tsx +161 -0
- package/src/MessageBubble.tsx +95 -0
- package/src/ModelSelector.tsx +300 -0
- package/src/TaskCreateForm.tsx +123 -0
- package/src/TaskDetail.tsx +224 -0
- package/src/TaskList.tsx +136 -0
- package/src/TaskPriorityIcon.tsx +39 -0
- package/src/TaskRow.tsx +177 -0
- package/src/TaskStatusIcon.tsx +39 -0
- package/src/ToolCall.tsx +639 -0
- package/src/index.ts +107 -0
- package/src/internal.tsx +107 -0
- 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
|
+
}
|