@aphrody/m3-ai 3.3.4 → 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 +22 -0
- package/package.json +4 -4
- package/src/A2APeerInspector.tsx +1 -9
- package/src/A2ATaskTimeline.tsx +1 -1
- package/src/AgUiEventStream.ts +9 -5
- package/src/ChatComposer.tsx +41 -19
- package/src/ConversationList.tsx +207 -0
- package/src/Markdown.tsx +89 -21
- package/src/MemoryPanel.tsx +176 -0
- package/src/MessageBubble.tsx +60 -12
- package/src/ModelSelector.tsx +36 -17
- package/src/ModelUnavailable.tsx +116 -0
- package/src/TaskRow.tsx +36 -36
- package/src/ToolCall.tsx +7 -2
- package/src/chatIcons.tsx +105 -0
- package/src/chatLabels.ts +243 -0
- package/src/citations.tsx +286 -0
- package/src/index.ts +72 -6
- package/src/openaiChatStream.ts +400 -0
- package/src/useOpenAIChat.ts +126 -0
package/README.md
CHANGED
|
@@ -83,6 +83,28 @@ Helpers from `types.ts`: `pairTranscriptSteps`, `tryParseJson`, `isErrorResult`,
|
|
|
83
83
|
`TASK_STATUS_ORDER`, `TASK_STATUS_LABEL`, `TASK_PRIORITY_LABEL`, and the transcript, task and
|
|
84
84
|
model types.
|
|
85
85
|
|
|
86
|
+
## Local chat
|
|
87
|
+
|
|
88
|
+
Everything a chat over an OpenAI-compatible server (llama-server, vLLM, the Aphrody gateway)
|
|
89
|
+
needs, with no visible string hard-coded:
|
|
90
|
+
|
|
91
|
+
| Export | Role |
|
|
92
|
+
| ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| `ChatLabels`, `chatLabelsFr` / `chatLabelsEn` / `chatLabelsJa`, `resolveChatLabels`, `formatLabel` | One dictionary for every visible string and aria-label. Every chat component takes `labels?: Partial<ChatLabels>`; missing keys fall back on English. |
|
|
94
|
+
| `streamChatCompletion`, `createSseParser`, `parseChatCompletionChunk`, `chatReducer`, `ChatStreamError` | Pure streaming client of `POST <baseUrl>/chat/completions` (`stream: true`): SSE framing, content / `reasoning_content` / tool-call deltas, `finish_reason`, top-level `citations` and `refusal`. Network failures and 404/502/503/504 are `unavailable`. |
|
|
95
|
+
| `useOpenAIChat({ baseUrl, model, fetch?, headers?, initialMessages?, preamble?, body? })` | React hook: `{ messages, send, stop, status, error, reset, setMessages }`, `status` is `idle`, `streaming`, `error` or `unavailable`. |
|
|
96
|
+
| `ConversationList` | Navigation-drawer list: new conversation, optional search, rename / delete callbacks, empty state, localized dates. |
|
|
97
|
+
| `ModelUnavailable` | Downloading / loading / unavailable state with a determinate linear indicator and a retry button. |
|
|
98
|
+
| `CitedMarkdown`, `CitationChip`, `CitationList`, `RefusalNotice` | Grounded answers: `[n]` markers as chips, the numbered sources, the "not established in the sources" notice. `MessageBubble` takes `citations` and `refusal` directly. |
|
|
99
|
+
| `MemoryPanel` | Saved memories with per-item deletion and a confirmed "clear all" (`confirmClear` injects your own dialog). |
|
|
100
|
+
| `ChatIcon` | The chat icons as inline Material Symbols SVG: no icon font is needed by the chat components. |
|
|
101
|
+
|
|
102
|
+
`Markdown` (and `MessageBubble`, `CitedMarkdown`) open external links with
|
|
103
|
+
`target="_blank" rel="noopener noreferrer"` and take `onLinkClick(href, event)` (call
|
|
104
|
+
`event.preventDefault()` to open the system browser from a Tauri webview) or `renderLink`.
|
|
105
|
+
`ChatComposer` takes `disabled` (the model is unavailable: input, send, voice and selectors are
|
|
106
|
+
inert), distinct from `isSending`.
|
|
107
|
+
|
|
86
108
|
## Tests
|
|
87
109
|
|
|
88
110
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aphrody/m3-ai",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.5",
|
|
4
4
|
"description": "Material Design 3 agent-interaction components (tool calls, markdown, chat composer, workers, tasks): the SpaceUI (@spacedrive/ai) set rebuilt on @aphrody/m3-primitives.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"material-3",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@aphrody/ag-ui": "1.0.1",
|
|
43
|
-
"@aphrody/m3-motion": "3.3.
|
|
44
|
-
"@aphrody/m3-primitives": "3.3.
|
|
43
|
+
"@aphrody/m3-motion": "3.3.5",
|
|
44
|
+
"@aphrody/m3-primitives": "3.3.5",
|
|
45
45
|
"@aphrody/mcp-ui": "7.1.1-aphrody.1",
|
|
46
46
|
"clsx": "^2.1.1",
|
|
47
47
|
"motion": "^13.4.4",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"remark-gfm": "^4.0.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@aphrody/m3-config": "3.3.
|
|
53
|
+
"@aphrody/m3-config": "3.3.5"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"react": ">=18.0.0",
|
package/src/A2APeerInspector.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @aphrody/m3-ai - A2APeerInspector
|
|
4
4
|
*
|
|
5
5
|
* Material Design 3 Agent-to-Agent (A2A 1.0) Fleet Inspector:
|
|
6
|
-
* - Visualizes connected peer agents (codex, claude, agycli,
|
|
6
|
+
* - Visualizes connected peer agents (codex, claude, agycli, nie, ocr, rag, ...).
|
|
7
7
|
* - Displays AgentCard interface, capabilities, and active tasks.
|
|
8
8
|
* - Live inspection of .coord mailbox envelopes (ask, fact, ack, ping, error).
|
|
9
9
|
* - File lock & resource claims view (.coord/claims).
|
|
@@ -72,14 +72,6 @@ const DEFAULT_PEERS: AgentPeer[] = [
|
|
|
72
72
|
version: "3.7.0",
|
|
73
73
|
skills: ["design-tokens", "docs-audit", "review"],
|
|
74
74
|
},
|
|
75
|
-
{
|
|
76
|
-
id: "shenron",
|
|
77
|
-
name: "Shenron Daemon",
|
|
78
|
-
role: "Database & RAG Pipeline Gate",
|
|
79
|
-
status: "idle",
|
|
80
|
-
version: "2.1.0",
|
|
81
|
-
skills: ["rag-index", "manga-publish", "embeddings"],
|
|
82
|
-
},
|
|
83
75
|
];
|
|
84
76
|
|
|
85
77
|
const KIND_COLORS: Record<EnvelopeKind, { bg: string; text: string; border: string }> = {
|
package/src/A2ATaskTimeline.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @aphrody/m3-ai - A2ATaskTimeline
|
|
4
4
|
*
|
|
5
5
|
* Material Design 3 Agent-to-Agent Delegation Timeline:
|
|
6
|
-
* - Chronological trace of task handoffs between agents (e.g., user -> agycli -> codex -> claude ->
|
|
6
|
+
* - Chronological trace of task handoffs between agents (e.g., user -> agycli -> codex -> claude -> rag).
|
|
7
7
|
* - Displays delegation phase, envelope kind, file claims, and execution status.
|
|
8
8
|
*/
|
|
9
9
|
|
package/src/AgUiEventStream.ts
CHANGED
|
@@ -163,14 +163,18 @@ export class AgUiEventAccumulator {
|
|
|
163
163
|
|
|
164
164
|
case EventType.TOOL_CALL_RESULT: {
|
|
165
165
|
const tcResult = event as ToolCallResultEvent;
|
|
166
|
-
const resultContent =
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
const resultContent =
|
|
167
|
+
typeof tcResult.content === "string"
|
|
168
|
+
? tcResult.content
|
|
169
|
+
: JSON.stringify(tcResult.content);
|
|
169
170
|
|
|
170
171
|
// Check if tool result contains A2UI operations
|
|
171
172
|
this.inspectA2uiOperations(tcResult.content);
|
|
172
173
|
|
|
173
|
-
const toolName =
|
|
174
|
+
const toolName =
|
|
175
|
+
this.toolCallNames.get(tcResult.toolCallId) ||
|
|
176
|
+
(tcResult.metadata?.toolName as string) ||
|
|
177
|
+
"tool";
|
|
174
178
|
this.steps.push({
|
|
175
179
|
type: "tool_result",
|
|
176
180
|
call_id: tcResult.toolCallId,
|
|
@@ -437,7 +441,7 @@ export function agUiEventToCoordEnvelope(
|
|
|
437
441
|
id: val.id || id,
|
|
438
442
|
from: val.from || from,
|
|
439
443
|
to: val.to || to,
|
|
440
|
-
kind:
|
|
444
|
+
kind: kind as EnvelopeKind,
|
|
441
445
|
topic: val.topic || "a2a-coordination",
|
|
442
446
|
body: val.body || "",
|
|
443
447
|
re: val.re,
|
package/src/ChatComposer.tsx
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
"use client";
|
|
5
5
|
|
|
6
|
-
import { CircleButton,
|
|
6
|
+
import { CircleButton, Popover, SelectPill, usePopover } from "@aphrody/m3-primitives";
|
|
7
7
|
import { m3Durations, m3Easings } from "@aphrody/m3-motion";
|
|
8
8
|
import { clsx } from "clsx";
|
|
9
9
|
import { AnimatePresence, motion } from "motion/react";
|
|
10
10
|
import { type ReactNode } from "react";
|
|
11
11
|
|
|
12
|
+
import { type ChatLabels, resolveChatLabels } from "./chatLabels";
|
|
13
|
+
import { ChatIcon } from "./chatIcons";
|
|
12
14
|
import { focusRing } from "./internal";
|
|
13
15
|
import { ModelSelector } from "./ModelSelector";
|
|
14
16
|
import type { ModelOption } from "./types";
|
|
@@ -26,6 +28,11 @@ export interface ChatComposerProps {
|
|
|
26
28
|
heading?: ReactNode;
|
|
27
29
|
/** Disable the composer — draft can still change, but send/voice are blocked */
|
|
28
30
|
isSending?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Disable the whole composer (e.g. the model is unavailable): the textarea, send, voice and
|
|
33
|
+
* the selectors are all inert. Distinct from `isSending`, which keeps the draft editable.
|
|
34
|
+
*/
|
|
35
|
+
disabled?: boolean;
|
|
29
36
|
/** Project selector config — omit to hide */
|
|
30
37
|
projectSelector?: {
|
|
31
38
|
value: string;
|
|
@@ -47,6 +54,8 @@ export interface ChatComposerProps {
|
|
|
47
54
|
onStop?: () => void;
|
|
48
55
|
/** Additional className on the composer container */
|
|
49
56
|
className?: string;
|
|
57
|
+
/** Visible strings (placeholder, aria-labels); English defaults. `placeholder` wins over it. */
|
|
58
|
+
labels?: Partial<ChatLabels>;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
const iconButtonEnter = {
|
|
@@ -72,24 +81,28 @@ export function ChatComposer({
|
|
|
72
81
|
draft,
|
|
73
82
|
onDraftChange,
|
|
74
83
|
onSend,
|
|
75
|
-
placeholder
|
|
84
|
+
placeholder,
|
|
76
85
|
heading,
|
|
77
86
|
isSending = false,
|
|
87
|
+
disabled = false,
|
|
78
88
|
projectSelector,
|
|
79
89
|
modelSelector,
|
|
80
90
|
onOpenVoice,
|
|
81
91
|
toolbarExtra,
|
|
82
92
|
onStop,
|
|
83
93
|
className,
|
|
94
|
+
labels,
|
|
84
95
|
}: ChatComposerProps) {
|
|
85
|
-
const
|
|
86
|
-
const
|
|
96
|
+
const l = resolveChatLabels(labels);
|
|
97
|
+
const hint = placeholder ?? l.composerPlaceholder;
|
|
98
|
+
const canSend = !disabled && !isSending && draft.trim().length > 0;
|
|
99
|
+
const showStop = !disabled && isSending && !!onStop;
|
|
87
100
|
|
|
88
101
|
return (
|
|
89
102
|
<>
|
|
90
103
|
{heading && (
|
|
91
104
|
<div className="mb-3 flex items-center gap-2 px-1 text-title-small text-on-surface-variant">
|
|
92
|
-
<
|
|
105
|
+
<ChatIcon name="auto_awesome_filled" size={18} className="text-primary" />
|
|
93
106
|
{heading}
|
|
94
107
|
</div>
|
|
95
108
|
)}
|
|
@@ -107,12 +120,13 @@ export function ChatComposer({
|
|
|
107
120
|
onKeyDown={(event) => {
|
|
108
121
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
109
122
|
event.preventDefault();
|
|
110
|
-
onSend();
|
|
123
|
+
if (canSend) onSend();
|
|
111
124
|
}
|
|
112
125
|
}}
|
|
113
|
-
placeholder={
|
|
114
|
-
aria-label={
|
|
126
|
+
placeholder={hint}
|
|
127
|
+
aria-label={hint}
|
|
115
128
|
rows={2}
|
|
129
|
+
disabled={disabled}
|
|
116
130
|
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
131
|
/>
|
|
118
132
|
|
|
@@ -124,8 +138,14 @@ export function ChatComposer({
|
|
|
124
138
|
onOpenChange={projectSelector.popover.setOpen}
|
|
125
139
|
>
|
|
126
140
|
<Popover.Trigger asChild>
|
|
127
|
-
<SelectPill
|
|
128
|
-
|
|
141
|
+
<SelectPill
|
|
142
|
+
className="w-full"
|
|
143
|
+
aria-haspopup="listbox"
|
|
144
|
+
disabled={disabled}
|
|
145
|
+
hideCaret
|
|
146
|
+
>
|
|
147
|
+
<span className="min-w-0 flex-1 truncate">{projectSelector.value}</span>
|
|
148
|
+
<ChatIcon name="expand_more" size={18} />
|
|
129
149
|
</SelectPill>
|
|
130
150
|
</Popover.Trigger>
|
|
131
151
|
<Popover.Content
|
|
@@ -136,7 +156,7 @@ export function ChatComposer({
|
|
|
136
156
|
<div
|
|
137
157
|
// 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
158
|
role="listbox"
|
|
139
|
-
aria-label=
|
|
159
|
+
aria-label={l.projects}
|
|
140
160
|
>
|
|
141
161
|
{projectSelector.options.map((option) => {
|
|
142
162
|
const selected = option === projectSelector.value;
|
|
@@ -162,7 +182,7 @@ export function ChatComposer({
|
|
|
162
182
|
)}
|
|
163
183
|
>
|
|
164
184
|
<span className="min-w-0 flex-1 truncate">{option}</span>
|
|
165
|
-
{selected && <
|
|
185
|
+
{selected && <ChatIcon name="check" size={20} />}
|
|
166
186
|
</button>
|
|
167
187
|
);
|
|
168
188
|
})}
|
|
@@ -182,6 +202,8 @@ export function ChatComposer({
|
|
|
182
202
|
value={modelSelector.value}
|
|
183
203
|
onChange={modelSelector.onChange}
|
|
184
204
|
size="md"
|
|
205
|
+
disabled={disabled}
|
|
206
|
+
labels={labels}
|
|
185
207
|
/>
|
|
186
208
|
</div>
|
|
187
209
|
)}
|
|
@@ -190,11 +212,11 @@ export function ChatComposer({
|
|
|
190
212
|
|
|
191
213
|
{onOpenVoice && (
|
|
192
214
|
<CircleButton
|
|
193
|
-
icon="mic"
|
|
215
|
+
icon={<ChatIcon name="mic" size={20} />}
|
|
194
216
|
variant="standard"
|
|
195
217
|
onClick={onOpenVoice}
|
|
196
|
-
disabled={isSending}
|
|
197
|
-
aria-label=
|
|
218
|
+
disabled={isSending || disabled}
|
|
219
|
+
aria-label={l.openVoice}
|
|
198
220
|
/>
|
|
199
221
|
)}
|
|
200
222
|
|
|
@@ -205,14 +227,14 @@ export function ChatComposer({
|
|
|
205
227
|
type="button"
|
|
206
228
|
{...iconButtonEnter}
|
|
207
229
|
onClick={onStop}
|
|
208
|
-
aria-label=
|
|
230
|
+
aria-label={l.stop}
|
|
209
231
|
className={clsx(
|
|
210
232
|
"flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-on-primary",
|
|
211
233
|
"transition-colors duration-200 ease-standard hover:bg-primary/92 active:bg-primary/90",
|
|
212
234
|
focusRing,
|
|
213
235
|
)}
|
|
214
236
|
>
|
|
215
|
-
<
|
|
237
|
+
<ChatIcon name="stop_filled" size={24} />
|
|
216
238
|
</motion.button>
|
|
217
239
|
) : canSend ? (
|
|
218
240
|
<motion.button
|
|
@@ -220,14 +242,14 @@ export function ChatComposer({
|
|
|
220
242
|
type="button"
|
|
221
243
|
{...iconButtonEnter}
|
|
222
244
|
onClick={onSend}
|
|
223
|
-
aria-label=
|
|
245
|
+
aria-label={l.send}
|
|
224
246
|
className={clsx(
|
|
225
247
|
"flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-on-primary",
|
|
226
248
|
"transition-colors duration-200 ease-standard hover:bg-primary/92 active:bg-primary/90",
|
|
227
249
|
focusRing,
|
|
228
250
|
)}
|
|
229
251
|
>
|
|
230
|
-
<
|
|
252
|
+
<ChatIcon name="arrow_upward" size={24} />
|
|
231
253
|
</motion.button>
|
|
232
254
|
) : null}
|
|
233
255
|
</AnimatePresence>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Conversation list on the M3 navigation-drawer anatomy: a `surface-container-low` sheet, a
|
|
3
|
+
// "new conversation" FAB-like tonal button, optional search, and 56 dp items whose active one
|
|
4
|
+
// takes the `secondary-container` indicator.
|
|
5
|
+
|
|
6
|
+
"use client";
|
|
7
|
+
|
|
8
|
+
import { CircleButton } from "@aphrody/m3-primitives";
|
|
9
|
+
import { clsx } from "clsx";
|
|
10
|
+
import { useMemo, useState } from "react";
|
|
11
|
+
|
|
12
|
+
import { type ChatLabels, resolveChatLabels } from "./chatLabels";
|
|
13
|
+
import { ChatIcon } from "./chatIcons";
|
|
14
|
+
import { CircularIndicator, focusRing } from "./internal";
|
|
15
|
+
|
|
16
|
+
export interface ConversationSummary {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
/** ISO string, epoch milliseconds or Date. */
|
|
20
|
+
updatedAt: string | number | Date;
|
|
21
|
+
preview?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ConversationListProps {
|
|
25
|
+
conversations: readonly ConversationSummary[];
|
|
26
|
+
selectedId?: string | null;
|
|
27
|
+
onSelect: (id: string) => void;
|
|
28
|
+
/** Shows the "new conversation" button. */
|
|
29
|
+
onNew?: () => void;
|
|
30
|
+
/** Shows a rename icon button on each item. */
|
|
31
|
+
onRename?: (id: string) => void;
|
|
32
|
+
/** Shows a delete icon button on each item. */
|
|
33
|
+
onDelete?: (id: string) => void;
|
|
34
|
+
/** Shows a search field that filters by title and preview. */
|
|
35
|
+
searchable?: boolean;
|
|
36
|
+
loading?: boolean;
|
|
37
|
+
labels?: Partial<ChatLabels>;
|
|
38
|
+
/** BCP 47 locale of the dates (`fr`, `en`, `ja`). */
|
|
39
|
+
locale?: string;
|
|
40
|
+
/** Overrides the date formatting. */
|
|
41
|
+
formatDate?: (updatedAt: Date) => string;
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toDate(value: string | number | Date): Date {
|
|
46
|
+
return value instanceof Date ? value : new Date(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isoDate(value: string | number | Date): string | undefined {
|
|
50
|
+
const date = toDate(value);
|
|
51
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Filter conversations by a case-insensitive query on title and preview. */
|
|
55
|
+
export function filterConversations(
|
|
56
|
+
conversations: readonly ConversationSummary[],
|
|
57
|
+
query: string,
|
|
58
|
+
): ConversationSummary[] {
|
|
59
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
60
|
+
if (!needle) return [...conversations];
|
|
61
|
+
return conversations.filter((conversation) =>
|
|
62
|
+
`${conversation.title}\n${conversation.preview ?? ""}`.toLocaleLowerCase().includes(needle),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ConversationList({
|
|
67
|
+
conversations,
|
|
68
|
+
selectedId,
|
|
69
|
+
onSelect,
|
|
70
|
+
onNew,
|
|
71
|
+
onRename,
|
|
72
|
+
onDelete,
|
|
73
|
+
searchable = false,
|
|
74
|
+
loading = false,
|
|
75
|
+
labels,
|
|
76
|
+
locale,
|
|
77
|
+
formatDate,
|
|
78
|
+
className,
|
|
79
|
+
}: ConversationListProps) {
|
|
80
|
+
const l = resolveChatLabels(labels);
|
|
81
|
+
const [query, setQuery] = useState("");
|
|
82
|
+
const visible = useMemo(() => filterConversations(conversations, query), [conversations, query]);
|
|
83
|
+
const dateFormat = useMemo(
|
|
84
|
+
() => new Intl.DateTimeFormat(locale, { dateStyle: "short" }),
|
|
85
|
+
[locale],
|
|
86
|
+
);
|
|
87
|
+
const format = (value: string | number | Date) => {
|
|
88
|
+
const date = toDate(value);
|
|
89
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
90
|
+
return formatDate ? formatDate(date) : dateFormat.format(date);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<nav
|
|
95
|
+
aria-label={l.conversations}
|
|
96
|
+
className={clsx(
|
|
97
|
+
"m3-conversation-list flex h-full min-h-0 flex-col gap-2 bg-surface-container-low px-3 py-3 text-on-surface",
|
|
98
|
+
className,
|
|
99
|
+
)}
|
|
100
|
+
>
|
|
101
|
+
<div className="flex items-center justify-between gap-2 px-1">
|
|
102
|
+
<h2 className="m-0 text-title-small text-on-surface-variant">{l.conversations}</h2>
|
|
103
|
+
</div>
|
|
104
|
+
{onNew ? (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={onNew}
|
|
108
|
+
className={clsx(
|
|
109
|
+
"flex h-14 items-center gap-3 rounded-large bg-primary-container px-4 text-label-large text-on-primary-container",
|
|
110
|
+
"transition-colors duration-200 ease-standard hover:bg-primary-container/92",
|
|
111
|
+
focusRing,
|
|
112
|
+
)}
|
|
113
|
+
>
|
|
114
|
+
<ChatIcon name="edit_square" size={24} />
|
|
115
|
+
<span>{l.newConversation}</span>
|
|
116
|
+
</button>
|
|
117
|
+
) : null}
|
|
118
|
+
{searchable ? (
|
|
119
|
+
<label className="flex h-12 items-center gap-3 rounded-extra-large bg-surface-container-high px-4 focus-within:bg-surface-container-highest">
|
|
120
|
+
<ChatIcon name="search" size={20} className="text-on-surface-variant" />
|
|
121
|
+
<input
|
|
122
|
+
type="search"
|
|
123
|
+
value={query}
|
|
124
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
125
|
+
placeholder={l.searchConversations}
|
|
126
|
+
aria-label={l.searchConversations}
|
|
127
|
+
className="min-w-0 flex-1 border-0 bg-transparent text-body-large text-on-surface caret-primary outline-none placeholder:text-on-surface-variant"
|
|
128
|
+
/>
|
|
129
|
+
</label>
|
|
130
|
+
) : null}
|
|
131
|
+
{loading ? (
|
|
132
|
+
<div className="flex justify-center py-6">
|
|
133
|
+
<CircularIndicator size={24} label={l.loading} />
|
|
134
|
+
</div>
|
|
135
|
+
) : visible.length === 0 ? (
|
|
136
|
+
<p className="m-0 px-4 py-6 text-center text-body-medium text-on-surface-variant">
|
|
137
|
+
{query.trim() ? l.noSearchResults : l.noConversations}
|
|
138
|
+
</p>
|
|
139
|
+
) : (
|
|
140
|
+
<ul className="m-0 flex min-h-0 flex-1 list-none flex-col gap-0.5 overflow-y-auto p-0">
|
|
141
|
+
{visible.map((conversation) => {
|
|
142
|
+
const active = conversation.id === selectedId;
|
|
143
|
+
const title = conversation.title.trim() || l.untitledConversation;
|
|
144
|
+
return (
|
|
145
|
+
<li key={conversation.id} className="group relative">
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() => onSelect(conversation.id)}
|
|
149
|
+
aria-current={active ? "page" : undefined}
|
|
150
|
+
className={clsx(
|
|
151
|
+
"flex min-h-14 w-full flex-col justify-center rounded-extra-large px-4 py-2 text-left",
|
|
152
|
+
"transition-colors duration-200 ease-standard",
|
|
153
|
+
active
|
|
154
|
+
? "bg-secondary-container text-on-secondary-container"
|
|
155
|
+
: "text-on-surface-variant hover:bg-on-surface/8",
|
|
156
|
+
(onRename || onDelete) && "pr-24",
|
|
157
|
+
focusRing,
|
|
158
|
+
"focus-visible:outline-offset-[-3px]",
|
|
159
|
+
)}
|
|
160
|
+
>
|
|
161
|
+
<span className="flex w-full items-baseline gap-2">
|
|
162
|
+
<span className="min-w-0 flex-1 truncate text-label-large">{title}</span>
|
|
163
|
+
<time
|
|
164
|
+
className="shrink-0 text-label-small opacity-80"
|
|
165
|
+
dateTime={isoDate(conversation.updatedAt)}
|
|
166
|
+
>
|
|
167
|
+
{format(conversation.updatedAt)}
|
|
168
|
+
</time>
|
|
169
|
+
</span>
|
|
170
|
+
{conversation.preview ? (
|
|
171
|
+
<span className="mt-0.5 w-full truncate text-body-small opacity-80">
|
|
172
|
+
{conversation.preview}
|
|
173
|
+
</span>
|
|
174
|
+
) : null}
|
|
175
|
+
</button>
|
|
176
|
+
{onRename || onDelete ? (
|
|
177
|
+
<span className="absolute top-1/2 right-2 flex -translate-y-1/2 gap-0.5 opacity-0 transition-opacity duration-200 ease-standard group-focus-within:opacity-100 group-hover:opacity-100">
|
|
178
|
+
{onRename ? (
|
|
179
|
+
<CircleButton
|
|
180
|
+
icon={<ChatIcon name="edit" size={18} />}
|
|
181
|
+
variant="standard"
|
|
182
|
+
size="sm"
|
|
183
|
+
onClick={() => onRename(conversation.id)}
|
|
184
|
+
aria-label={`${l.renameConversation}: ${title}`}
|
|
185
|
+
title={l.renameConversation}
|
|
186
|
+
/>
|
|
187
|
+
) : null}
|
|
188
|
+
{onDelete ? (
|
|
189
|
+
<CircleButton
|
|
190
|
+
icon={<ChatIcon name="delete" size={18} />}
|
|
191
|
+
variant="standard"
|
|
192
|
+
size="sm"
|
|
193
|
+
onClick={() => onDelete(conversation.id)}
|
|
194
|
+
aria-label={`${l.deleteConversation}: ${title}`}
|
|
195
|
+
title={l.deleteConversation}
|
|
196
|
+
/>
|
|
197
|
+
) : null}
|
|
198
|
+
</span>
|
|
199
|
+
) : null}
|
|
200
|
+
</li>
|
|
201
|
+
);
|
|
202
|
+
})}
|
|
203
|
+
</ul>
|
|
204
|
+
)}
|
|
205
|
+
</nav>
|
|
206
|
+
);
|
|
207
|
+
}
|