@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,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
|
+
}
|