@aphrody/m3-ai 3.3.2 → 3.3.5
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 +29 -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 +370 -0
- package/src/A2ATaskTimeline.tsx +218 -0
- package/src/AgUiEventStream.ts +492 -0
- package/src/ChatComposer.tsx +261 -0
- package/src/ConversationList.tsx +207 -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 +769 -0
- package/src/McpAppCard.tsx +161 -0
- package/src/MemoryPanel.tsx +176 -0
- package/src/MessageBubble.tsx +143 -0
- package/src/ModelSelector.tsx +319 -0
- package/src/ModelUnavailable.tsx +116 -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 +644 -0
- package/src/chatIcons.tsx +105 -0
- package/src/chatLabels.ts +243 -0
- package/src/citations.tsx +286 -0
- package/src/index.ts +173 -0
- package/src/internal.tsx +107 -0
- package/src/openaiChatStream.ts +400 -0
- package/src/types.ts +212 -0
- package/src/useOpenAIChat.ts +126 -0
|
@@ -0,0 +1,319 @@
|
|
|
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 { Popover, usePopover, SelectPill, type SelectPillProps } from "@aphrody/m3-primitives";
|
|
7
|
+
import { clsx } from "clsx";
|
|
8
|
+
import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
|
|
10
|
+
import { type ChatLabels, formatLabel, resolveChatLabels } from "./chatLabels";
|
|
11
|
+
import { ChatIcon } from "./chatIcons";
|
|
12
|
+
import { focusRing } from "./internal";
|
|
13
|
+
import type { ModelOption } from "./types";
|
|
14
|
+
|
|
15
|
+
export interface ModelSelectorProps {
|
|
16
|
+
/** Available models */
|
|
17
|
+
models: ModelOption[];
|
|
18
|
+
/** Currently selected model ID */
|
|
19
|
+
value: string;
|
|
20
|
+
/** Called when a model is selected */
|
|
21
|
+
onChange: (value: string) => void;
|
|
22
|
+
/** Placeholder when nothing selected */
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
/** Search placeholder */
|
|
25
|
+
searchPlaceholder?: string;
|
|
26
|
+
/** SelectPill variant */
|
|
27
|
+
variant?: SelectPillProps["variant"];
|
|
28
|
+
/** SelectPill size */
|
|
29
|
+
size?: SelectPillProps["size"];
|
|
30
|
+
/** Controlled popover (optional) */
|
|
31
|
+
popover?: ReturnType<typeof usePopover>;
|
|
32
|
+
/** Additional trigger className */
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Disabled state */
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** Visible strings; `placeholder` / `searchPlaceholder` win over them. English defaults. */
|
|
37
|
+
labels?: Partial<ChatLabels>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Group models by provider, keeping the order of first appearance. */
|
|
41
|
+
export function groupModelsByProvider(
|
|
42
|
+
models: ModelOption[],
|
|
43
|
+
search = "",
|
|
44
|
+
): Record<string, ModelOption[]> {
|
|
45
|
+
const q = search.trim().toLowerCase();
|
|
46
|
+
const filtered = q
|
|
47
|
+
? models.filter((m) => m.name.toLowerCase().includes(q) || m.provider.toLowerCase().includes(q))
|
|
48
|
+
: models;
|
|
49
|
+
|
|
50
|
+
return filtered.reduce(
|
|
51
|
+
(acc, model) => {
|
|
52
|
+
if (!acc[model.provider]) acc[model.provider] = [];
|
|
53
|
+
acc[model.provider].push(model);
|
|
54
|
+
return acc;
|
|
55
|
+
},
|
|
56
|
+
Object.create(null) as Record<string, ModelOption[]>,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ModelOptionListProps {
|
|
61
|
+
/** Models grouped by provider (see {@link groupModelsByProvider}). */
|
|
62
|
+
grouped: Record<string, ModelOption[]>;
|
|
63
|
+
/** Currently selected model ID */
|
|
64
|
+
value: string;
|
|
65
|
+
/** Called when a model is picked */
|
|
66
|
+
onSelect: (value: string) => void;
|
|
67
|
+
className?: string;
|
|
68
|
+
labels?: Partial<ChatLabels>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The M3 menu list of the model selector: provider section headers (`text-title-small`),
|
|
73
|
+
* 48 dp two-line items (`text-label-large` + `text-body-small` supporting text), the selected
|
|
74
|
+
* item on `secondary-container`, capabilities as small outlined chips.
|
|
75
|
+
*/
|
|
76
|
+
export function ModelOptionList({
|
|
77
|
+
grouped,
|
|
78
|
+
value,
|
|
79
|
+
onSelect,
|
|
80
|
+
className,
|
|
81
|
+
labels,
|
|
82
|
+
}: ModelOptionListProps) {
|
|
83
|
+
const l = resolveChatLabels(labels);
|
|
84
|
+
const providers = Object.entries(grouped);
|
|
85
|
+
const models = providers.flatMap(([, providerModels]) => providerModels);
|
|
86
|
+
const [focusedId, setFocusedId] = useState(value);
|
|
87
|
+
const focusId = models.some((model) => model.id === focusedId)
|
|
88
|
+
? focusedId
|
|
89
|
+
: (models.find((model) => model.id === value)?.id ?? models[0]?.id);
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
// 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.
|
|
93
|
+
role="listbox"
|
|
94
|
+
aria-label={l.models}
|
|
95
|
+
tabIndex={-1}
|
|
96
|
+
onKeyDown={(event) => {
|
|
97
|
+
if (event.defaultPrevented) return;
|
|
98
|
+
const options = Array.from(
|
|
99
|
+
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="option"]'),
|
|
100
|
+
);
|
|
101
|
+
const index = options.indexOf(event.target as HTMLButtonElement);
|
|
102
|
+
if (index < 0) return;
|
|
103
|
+
let next: number;
|
|
104
|
+
switch (event.key) {
|
|
105
|
+
case "ArrowDown":
|
|
106
|
+
next = (index + 1) % options.length;
|
|
107
|
+
break;
|
|
108
|
+
case "ArrowUp":
|
|
109
|
+
next = (index - 1 + options.length) % options.length;
|
|
110
|
+
break;
|
|
111
|
+
case "Home":
|
|
112
|
+
next = 0;
|
|
113
|
+
break;
|
|
114
|
+
case "End":
|
|
115
|
+
next = options.length - 1;
|
|
116
|
+
break;
|
|
117
|
+
default:
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
options[next]!.focus();
|
|
122
|
+
}}
|
|
123
|
+
className={clsx("flex flex-col", className)}
|
|
124
|
+
>
|
|
125
|
+
{providers.map(([provider, providerModels], groupIndex) => (
|
|
126
|
+
<div
|
|
127
|
+
key={provider}
|
|
128
|
+
// 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>.
|
|
129
|
+
role="group"
|
|
130
|
+
aria-label={provider}
|
|
131
|
+
className={clsx(groupIndex > 0 && "mt-1 border-t border-outline-variant pt-1")}
|
|
132
|
+
>
|
|
133
|
+
<div className="px-3 pt-2 pb-1 text-title-small text-on-surface-variant">{provider}</div>
|
|
134
|
+
{providerModels.map((model) => {
|
|
135
|
+
const selected = model.id === value;
|
|
136
|
+
return (
|
|
137
|
+
<button
|
|
138
|
+
key={model.id}
|
|
139
|
+
type="button"
|
|
140
|
+
// 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.
|
|
141
|
+
role="option"
|
|
142
|
+
aria-selected={selected}
|
|
143
|
+
tabIndex={model.id === focusId ? 0 : -1}
|
|
144
|
+
onFocus={() => setFocusedId(model.id)}
|
|
145
|
+
onClick={() => onSelect(model.id)}
|
|
146
|
+
className={clsx(
|
|
147
|
+
"flex min-h-12 w-full items-center gap-3 px-3 py-2 text-left",
|
|
148
|
+
"transition-colors duration-200 ease-standard",
|
|
149
|
+
selected
|
|
150
|
+
? "bg-secondary-container text-on-secondary-container"
|
|
151
|
+
: "text-on-surface hover:bg-on-surface/8 active:bg-on-surface/10",
|
|
152
|
+
focusRing,
|
|
153
|
+
"focus-visible:outline-offset-[-3px]",
|
|
154
|
+
)}
|
|
155
|
+
>
|
|
156
|
+
<div className="min-w-0 flex-1">
|
|
157
|
+
<div className="truncate text-label-large">{model.name}</div>
|
|
158
|
+
{model.context_window || model.capabilities?.length ? (
|
|
159
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-1">
|
|
160
|
+
{model.context_window ? (
|
|
161
|
+
<span
|
|
162
|
+
className={clsx(
|
|
163
|
+
"text-body-small",
|
|
164
|
+
selected ? "text-on-secondary-container" : "text-on-surface-variant",
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
{formatLabel(l.contextTokens, {
|
|
168
|
+
count: model.context_window.toLocaleString(),
|
|
169
|
+
})}
|
|
170
|
+
</span>
|
|
171
|
+
) : null}
|
|
172
|
+
{model.capabilities?.map((capability) => (
|
|
173
|
+
<span
|
|
174
|
+
key={capability}
|
|
175
|
+
className={clsx(
|
|
176
|
+
"inline-flex h-5 items-center rounded-small border border-outline-variant px-1.5 text-label-small",
|
|
177
|
+
selected ? "text-on-secondary-container" : "text-on-surface-variant",
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
{capability}
|
|
181
|
+
</span>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
) : null}
|
|
185
|
+
</div>
|
|
186
|
+
{selected && <ChatIcon name="check" size={20} />}
|
|
187
|
+
</button>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
</div>
|
|
191
|
+
))}
|
|
192
|
+
{providers.length === 0 && (
|
|
193
|
+
<div className="px-3 py-3 text-center text-body-small text-on-surface-variant">
|
|
194
|
+
{l.noModels}
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The menu's search field. It mounts with the popover content and takes focus then (replaces
|
|
203
|
+
* `autoFocus`), so typing filters the list as soon as the menu opens.
|
|
204
|
+
*/
|
|
205
|
+
function ModelSearchField({
|
|
206
|
+
value,
|
|
207
|
+
onChange,
|
|
208
|
+
placeholder,
|
|
209
|
+
}: {
|
|
210
|
+
value: string;
|
|
211
|
+
onChange: (value: string) => void;
|
|
212
|
+
placeholder: string;
|
|
213
|
+
}) {
|
|
214
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
215
|
+
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
inputRef.current?.focus();
|
|
218
|
+
}, []);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="px-2 pb-2">
|
|
222
|
+
<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">
|
|
223
|
+
<ChatIcon name="search" size={20} />
|
|
224
|
+
<input
|
|
225
|
+
ref={inputRef}
|
|
226
|
+
value={value}
|
|
227
|
+
onChange={(event) => onChange(event.target.value)}
|
|
228
|
+
placeholder={placeholder}
|
|
229
|
+
aria-label={placeholder}
|
|
230
|
+
className="min-w-0 flex-1 bg-transparent text-body-large text-on-surface outline-none placeholder:text-on-surface-variant"
|
|
231
|
+
/>
|
|
232
|
+
</label>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
|
|
238
|
+
(
|
|
239
|
+
{
|
|
240
|
+
models,
|
|
241
|
+
value,
|
|
242
|
+
onChange,
|
|
243
|
+
placeholder,
|
|
244
|
+
searchPlaceholder,
|
|
245
|
+
variant,
|
|
246
|
+
size,
|
|
247
|
+
popover: externalPopover,
|
|
248
|
+
className,
|
|
249
|
+
disabled,
|
|
250
|
+
labels,
|
|
251
|
+
},
|
|
252
|
+
ref,
|
|
253
|
+
) => {
|
|
254
|
+
const l = resolveChatLabels(labels);
|
|
255
|
+
const internalPopover = usePopover();
|
|
256
|
+
const popover = externalPopover ?? internalPopover;
|
|
257
|
+
const [search, setSearch] = useState("");
|
|
258
|
+
|
|
259
|
+
const selectedModel = models.find((m) => m.id === value);
|
|
260
|
+
|
|
261
|
+
const grouped = useMemo(() => groupModelsByProvider(models, search), [models, search]);
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<Popover.Root
|
|
265
|
+
open={popover.open}
|
|
266
|
+
onOpenChange={(open) => {
|
|
267
|
+
popover.setOpen(open);
|
|
268
|
+
if (!open) setSearch("");
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<Popover.Trigger asChild>
|
|
272
|
+
<SelectPill
|
|
273
|
+
ref={ref}
|
|
274
|
+
variant={variant}
|
|
275
|
+
size={size}
|
|
276
|
+
disabled={disabled}
|
|
277
|
+
aria-haspopup="listbox"
|
|
278
|
+
hideCaret
|
|
279
|
+
className={clsx("w-full", className)}
|
|
280
|
+
>
|
|
281
|
+
<span className="min-w-0 flex-1 truncate">
|
|
282
|
+
{selectedModel?.name ?? placeholder ?? l.selectModel}
|
|
283
|
+
</span>
|
|
284
|
+
<ChatIcon name="expand_more" size={18} />
|
|
285
|
+
</SelectPill>
|
|
286
|
+
</Popover.Trigger>
|
|
287
|
+
<Popover.Content
|
|
288
|
+
align="end"
|
|
289
|
+
sideOffset={8}
|
|
290
|
+
style={{ width: 280 }}
|
|
291
|
+
className="overflow-hidden rounded-extra-small! py-2"
|
|
292
|
+
>
|
|
293
|
+
{/* Search */}
|
|
294
|
+
<ModelSearchField
|
|
295
|
+
value={search}
|
|
296
|
+
onChange={setSearch}
|
|
297
|
+
placeholder={searchPlaceholder ?? l.searchModels}
|
|
298
|
+
/>
|
|
299
|
+
|
|
300
|
+
{/* Grouped results */}
|
|
301
|
+
<div className="max-h-[320px] overflow-y-auto">
|
|
302
|
+
<ModelOptionList
|
|
303
|
+
grouped={grouped}
|
|
304
|
+
value={value}
|
|
305
|
+
labels={labels}
|
|
306
|
+
onSelect={(id) => {
|
|
307
|
+
onChange(id);
|
|
308
|
+
popover.setOpen(false);
|
|
309
|
+
setSearch("");
|
|
310
|
+
}}
|
|
311
|
+
/>
|
|
312
|
+
</div>
|
|
313
|
+
</Popover.Content>
|
|
314
|
+
</Popover.Root>
|
|
315
|
+
);
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
ModelSelector.displayName = "ModelSelector";
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// "Model unavailable" state of a local chat: downloading, loading, or down, with an optional
|
|
3
|
+
// determinate M3 linear progress indicator and a retry action.
|
|
4
|
+
|
|
5
|
+
"use client";
|
|
6
|
+
|
|
7
|
+
import { clsx } from "clsx";
|
|
8
|
+
|
|
9
|
+
import { type ChatLabels, formatLabel, resolveChatLabels } from "./chatLabels";
|
|
10
|
+
import { ChatIcon } from "./chatIcons";
|
|
11
|
+
import { CircularIndicator, focusRing } from "./internal";
|
|
12
|
+
|
|
13
|
+
export type ModelAvailabilityPhase = "downloading" | "loading" | "unavailable";
|
|
14
|
+
|
|
15
|
+
export interface ModelUnavailableProps {
|
|
16
|
+
phase?: ModelAvailabilityPhase;
|
|
17
|
+
/** Why the model cannot answer (backend message, already localized by the caller). */
|
|
18
|
+
reason?: string;
|
|
19
|
+
/** Download or load progress, 0..1; omit for an indeterminate state. */
|
|
20
|
+
progress?: number | null;
|
|
21
|
+
onRetry?: () => void;
|
|
22
|
+
labels?: Partial<ChatLabels>;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Clamp a progress fraction to 0..1; non-finite values give `null` (indeterminate). */
|
|
27
|
+
export function clampProgress(progress: number | null | undefined): number | null {
|
|
28
|
+
if (progress === null || progress === undefined || !Number.isFinite(progress)) return null;
|
|
29
|
+
return Math.min(1, Math.max(0, progress));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ModelUnavailable({
|
|
33
|
+
phase = "unavailable",
|
|
34
|
+
reason,
|
|
35
|
+
progress,
|
|
36
|
+
onRetry,
|
|
37
|
+
labels,
|
|
38
|
+
className,
|
|
39
|
+
}: ModelUnavailableProps) {
|
|
40
|
+
const l = resolveChatLabels(labels);
|
|
41
|
+
const value = clampProgress(progress);
|
|
42
|
+
const busy = phase !== "unavailable";
|
|
43
|
+
const title =
|
|
44
|
+
phase === "downloading"
|
|
45
|
+
? l.modelDownloading
|
|
46
|
+
: phase === "loading"
|
|
47
|
+
? l.modelLoading
|
|
48
|
+
: l.modelUnavailable;
|
|
49
|
+
const percent = value === null ? null : Math.round(value * 100);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- live region wrapping block content (title, progress, action); <output> only holds phrasing content.
|
|
54
|
+
role="status"
|
|
55
|
+
aria-live="polite"
|
|
56
|
+
aria-busy={busy || undefined}
|
|
57
|
+
className={clsx(
|
|
58
|
+
"m3-model-unavailable flex flex-col gap-3 rounded-large px-4 py-4",
|
|
59
|
+
busy
|
|
60
|
+
? "bg-surface-container-high text-on-surface"
|
|
61
|
+
: "bg-error-container text-on-error-container",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<div className="flex items-start gap-3">
|
|
66
|
+
{busy ? (
|
|
67
|
+
<CircularIndicator size={24} label={title} className="mt-0.5" />
|
|
68
|
+
) : (
|
|
69
|
+
<ChatIcon name="cloud_off" size={24} className="mt-0.5" />
|
|
70
|
+
)}
|
|
71
|
+
<div className="min-w-0 flex-1">
|
|
72
|
+
<p className="m-0 text-title-medium">{title}</p>
|
|
73
|
+
{reason ? <p className="m-0 mt-1 text-body-medium opacity-90">{reason}</p> : null}
|
|
74
|
+
</div>
|
|
75
|
+
{percent !== null ? (
|
|
76
|
+
<span className="shrink-0 text-label-large">
|
|
77
|
+
{formatLabel(l.progressPercent, { percent })}
|
|
78
|
+
</span>
|
|
79
|
+
) : null}
|
|
80
|
+
</div>
|
|
81
|
+
{busy && value !== null ? (
|
|
82
|
+
<div
|
|
83
|
+
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- M3 linear determinate indicator with a track gap; a native <progress> cannot be styled to the spec.
|
|
84
|
+
role="progressbar"
|
|
85
|
+
aria-label={title}
|
|
86
|
+
aria-valuemin={0}
|
|
87
|
+
aria-valuemax={100}
|
|
88
|
+
aria-valuenow={percent ?? undefined}
|
|
89
|
+
className="flex h-1 w-full items-center gap-1"
|
|
90
|
+
>
|
|
91
|
+
<span
|
|
92
|
+
className="h-1 rounded-full bg-primary transition-[width] duration-300 ease-standard"
|
|
93
|
+
style={{ width: `${(value * 100).toFixed(1)}%` }}
|
|
94
|
+
/>
|
|
95
|
+
<span className="h-1 flex-1 rounded-full bg-secondary-container" />
|
|
96
|
+
</div>
|
|
97
|
+
) : null}
|
|
98
|
+
{onRetry && !busy ? (
|
|
99
|
+
<div className="flex justify-end">
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={onRetry}
|
|
103
|
+
className={clsx(
|
|
104
|
+
"inline-flex h-10 items-center gap-2 rounded-full bg-error px-6 text-label-large text-on-error",
|
|
105
|
+
"transition-colors duration-200 ease-standard hover:bg-error/92",
|
|
106
|
+
focusRing,
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<ChatIcon name="refresh" size={18} />
|
|
110
|
+
<span>{l.retry}</span>
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|