@aphrody/m3-ai 3.3.4 → 3.3.6

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/src/Markdown.tsx CHANGED
@@ -3,13 +3,15 @@
3
3
 
4
4
  "use client";
5
5
 
6
- import { Icon } from "@aphrody/m3-primitives";
7
6
  import { clsx } from "clsx";
8
7
  import { forwardRef, isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
9
8
  import ReactMarkdown, { type Components } from "react-markdown";
10
9
  import rehypeRaw from "rehype-raw";
11
10
  import remarkGfm from "remark-gfm";
12
11
 
12
+ import { type ChatLabels, resolveChatLabels } from "./chatLabels";
13
+ import { ChatIcon } from "./chatIcons";
14
+
13
15
  type MarkdownSize = "small" | "medium" | "large";
14
16
 
15
17
  interface MarkdownProps {
@@ -22,8 +24,41 @@ interface MarkdownProps {
22
24
  * trusted content.
23
25
  */
24
26
  allowHtml?: boolean;
27
+ /** Element overrides merged over the M3 renderers (e.g. citation links). */
28
+ components?: Components;
29
+ /**
30
+ * Called on every link activation. Call `event.preventDefault()` to handle it yourself (e.g.
31
+ * open the system browser from a Tauri webview).
32
+ */
33
+ onLinkClick?: MarkdownLinkHandler;
34
+ /** Replaces the link element entirely. */
35
+ renderLink?: MarkdownLinkRenderer;
36
+ /** Visible strings of the code blocks; English defaults. */
37
+ labels?: Partial<ChatLabels>;
25
38
  }
26
39
 
40
+ /** Link activation callback of {@link Markdown}. */
41
+ export type MarkdownLinkHandler = (
42
+ href: string,
43
+ event: React.MouseEvent<HTMLAnchorElement>,
44
+ ) => void;
45
+
46
+ /** Custom link renderer of {@link Markdown}. */
47
+ export type MarkdownLinkRenderer = (link: {
48
+ href: string;
49
+ children: React.ReactNode;
50
+ external: boolean;
51
+ }) => React.ReactNode;
52
+
53
+ /** Whether `href` leaves the page: an absolute URL with a scheme, or protocol-relative. */
54
+ export function isExternalHref(href: string | undefined): boolean {
55
+ if (!href) return false;
56
+ return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//");
57
+ }
58
+
59
+ /** Safe defaults of an external link: a new browsing context without opener or referrer. */
60
+ export const externalLinkProps = { target: "_blank", rel: "noopener noreferrer" } as const;
61
+
27
62
  const bodyClass: Record<MarkdownSize, string> = {
28
63
  small: "text-body-small",
29
64
  medium: "text-body-medium",
@@ -123,6 +158,8 @@ export interface CodeBlockProps extends Omit<React.HTMLAttributes<HTMLDivElement
123
158
  language?: string;
124
159
  onCopy?: (code: string) => void;
125
160
  children?: ReactNode;
161
+ /** `copy`, `copyCode`, `copied`; English defaults. */
162
+ labels?: Partial<ChatLabels>;
126
163
  }
127
164
 
128
165
  export function CodeBlock({
@@ -131,8 +168,10 @@ export function CodeBlock({
131
168
  onCopy,
132
169
  className,
133
170
  children,
171
+ labels,
134
172
  ...restProps
135
173
  }: CodeBlockProps) {
174
+ const l = resolveChatLabels(labels);
136
175
  // Strip node passed by ReactMarkdown to avoid rendering node="[object Object]" in DOM
137
176
  const { node: _node, ...props } = restProps as Record<string, any>;
138
177
 
@@ -202,15 +241,15 @@ export function CodeBlock({
202
241
  : "text-on-surface-variant hover:bg-on-surface/8 hover:text-on-surface active:bg-on-surface/12",
203
242
  "focus-visible:outline-2 focus-visible:outline-primary",
204
243
  )}
205
- aria-label={copied ? "Copied" : "Copy code"}
206
- title={copied ? "Copied!" : "Copy code"}
244
+ aria-label={copied ? l.copied : l.copyCode}
245
+ title={copied ? l.copied : l.copyCode}
207
246
  >
208
- <Icon
247
+ <ChatIcon
209
248
  name={copied ? "check" : "content_copy"}
210
249
  size={16}
211
250
  className={copied ? "text-success" : undefined}
212
251
  />
213
- <span>{copied ? "Copied!" : "Copy"}</span>
252
+ <span>{copied ? l.copied : l.copy}</span>
214
253
  </button>
215
254
  </div>
216
255
  <pre className="overflow-x-auto p-3 font-code text-body-small text-on-surface bg-surface-container-highest [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-[length:inherit]">
@@ -508,7 +547,13 @@ export function remarkMathPlugin() {
508
547
  // Element overrides mapping markdown onto M3 typescale and color roles
509
548
  // ---------------------------------------------------------------------------
510
549
 
511
- function m3Components(size: MarkdownSize): Components {
550
+ interface LinkOptions {
551
+ onLinkClick?: MarkdownLinkHandler;
552
+ renderLink?: MarkdownLinkRenderer;
553
+ labels?: Partial<ChatLabels>;
554
+ }
555
+
556
+ function m3Components(size: MarkdownSize, link: LinkOptions = {}): Components {
512
557
  const body = bodyClass[size];
513
558
  return {
514
559
  h1: ({ node: _node, className, children, ...props }) => (
@@ -562,18 +607,28 @@ function m3Components(size: MarkdownSize): Components {
562
607
  p: ({ node: _node, className, ...props }) => (
563
608
  <p className={clsx("my-2 first:mt-0 last:mb-0", body, className)} {...props} />
564
609
  ),
565
- a: ({ node: _node, className, children, ...props }) => (
566
- <a
567
- className={clsx(
568
- "text-primary underline underline-offset-2 hover:decoration-2",
569
- "rounded-extra-small outline-none focus-visible:outline-3 focus-visible:outline-secondary focus-visible:outline-offset-2",
570
- className,
571
- )}
572
- {...props}
573
- >
574
- {children}
575
- </a>
576
- ),
610
+ a: ({ node: _node, className, children, href, onClick, ...props }) => {
611
+ const external = isExternalHref(href);
612
+ if (link.renderLink) return <>{link.renderLink({ href: href ?? "", children, external })}</>;
613
+ return (
614
+ <a
615
+ className={clsx(
616
+ "text-primary underline underline-offset-2 hover:decoration-2",
617
+ "rounded-extra-small outline-none focus-visible:outline-3 focus-visible:outline-secondary focus-visible:outline-offset-2",
618
+ className,
619
+ )}
620
+ href={href}
621
+ {...(external ? externalLinkProps : {})}
622
+ onClick={(event) => {
623
+ onClick?.(event);
624
+ if (href) link.onLinkClick?.(href, event);
625
+ }}
626
+ {...props}
627
+ >
628
+ {children}
629
+ </a>
630
+ );
631
+ },
577
632
  strong: ({ node: _node, className, ...props }) => (
578
633
  <strong className={clsx("font-semibold text-on-surface", className)} {...props} />
579
634
  ),
@@ -611,7 +666,7 @@ function m3Components(size: MarkdownSize): Components {
611
666
  />
612
667
  ),
613
668
  pre: ({ node: _node, children, className, style }) => (
614
- <CodeBlock className={className} style={style}>
669
+ <CodeBlock className={className} style={style} labels={link.labels}>
615
670
  {children}
616
671
  </CodeBlock>
617
672
  ),
@@ -677,7 +732,20 @@ function m3Components(size: MarkdownSize): Components {
677
732
  }
678
733
 
679
734
  const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
680
- ({ content, className, size = "medium", allowHtml = false }, ref) => {
735
+ (
736
+ {
737
+ content,
738
+ className,
739
+ size = "medium",
740
+ allowHtml = false,
741
+ components,
742
+ onLinkClick,
743
+ renderLink,
744
+ labels,
745
+ },
746
+ ref,
747
+ ) => {
748
+ const base = m3Components(size, { onLinkClick, renderLink, labels });
681
749
  return (
682
750
  <div
683
751
  ref={ref}
@@ -686,7 +754,7 @@ const Markdown = forwardRef<HTMLDivElement, MarkdownProps>(
686
754
  <ReactMarkdown
687
755
  remarkPlugins={[remarkGfm, remarkMathPlugin]}
688
756
  rehypePlugins={allowHtml ? [rehypeRaw] : undefined}
689
- components={m3Components(size)}
757
+ components={components ? { ...base, ...components } : base}
690
758
  >
691
759
  {content}
692
760
  </ReactMarkdown>
@@ -0,0 +1,176 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Memory panel: the agent's saved memories as an M3 list, with per-item deletion and a
3
+ // "clear all" action guarded by an inline confirmation (or an injected one).
4
+
5
+ "use client";
6
+
7
+ import { CircleButton } from "@aphrody/m3-primitives";
8
+ import { clsx } from "clsx";
9
+ import { useMemo, useState } from "react";
10
+
11
+ import { type ChatLabels, resolveChatLabels } from "./chatLabels";
12
+ import { ChatIcon } from "./chatIcons";
13
+ import { CircularIndicator, focusRing } from "./internal";
14
+
15
+ export interface MemoryItem {
16
+ id: string;
17
+ content: string;
18
+ tags?: string[];
19
+ /** ISO string, epoch milliseconds or Date. */
20
+ createdAt: string | number | Date;
21
+ }
22
+
23
+ export interface MemoryPanelProps {
24
+ memories: readonly MemoryItem[];
25
+ loading?: boolean;
26
+ onDelete?: (id: string) => void;
27
+ /** Shows "clear all". */
28
+ onClear?: () => void;
29
+ /**
30
+ * Replaces the built-in inline confirmation of "clear all" (e.g. an app dialog):
31
+ * resolve `true` to clear.
32
+ */
33
+ confirmClear?: () => boolean | Promise<boolean>;
34
+ labels?: Partial<ChatLabels>;
35
+ locale?: string;
36
+ formatDate?: (createdAt: Date) => string;
37
+ className?: string;
38
+ }
39
+
40
+ const textButton = clsx(
41
+ "inline-flex h-10 items-center rounded-full px-3 text-label-large",
42
+ "transition-colors duration-200 ease-standard",
43
+ focusRing,
44
+ );
45
+
46
+ export function MemoryPanel({
47
+ memories,
48
+ loading = false,
49
+ onDelete,
50
+ onClear,
51
+ confirmClear,
52
+ labels,
53
+ locale,
54
+ formatDate,
55
+ className,
56
+ }: MemoryPanelProps) {
57
+ const l = resolveChatLabels(labels);
58
+ const [confirming, setConfirming] = useState(false);
59
+ const dateFormat = useMemo(
60
+ () => new Intl.DateTimeFormat(locale, { dateStyle: "medium" }),
61
+ [locale],
62
+ );
63
+ const format = (value: string | number | Date) => {
64
+ const date = value instanceof Date ? value : new Date(value);
65
+ if (Number.isNaN(date.getTime())) return "";
66
+ return formatDate ? formatDate(date) : dateFormat.format(date);
67
+ };
68
+
69
+ const requestClear = async () => {
70
+ if (!onClear) return;
71
+ if (confirmClear) {
72
+ if (await confirmClear()) onClear();
73
+ return;
74
+ }
75
+ setConfirming(true);
76
+ };
77
+
78
+ return (
79
+ <section
80
+ aria-label={l.memory}
81
+ aria-busy={loading || undefined}
82
+ className={clsx(
83
+ "m3-memory-panel flex min-h-0 flex-col gap-2 rounded-large bg-surface-container-low py-3 text-on-surface",
84
+ className,
85
+ )}
86
+ >
87
+ <header className="flex items-center justify-between gap-2 px-4">
88
+ <h2 className="m-0 text-title-medium">{l.memory}</h2>
89
+ {onClear && memories.length > 0 && !confirming ? (
90
+ <button
91
+ type="button"
92
+ onClick={() => void requestClear()}
93
+ className={clsx(textButton, "text-error hover:bg-error/8")}
94
+ >
95
+ {l.clearMemory}
96
+ </button>
97
+ ) : null}
98
+ </header>
99
+ {confirming ? (
100
+ <div
101
+ role="alertdialog"
102
+ aria-label={l.clearMemory}
103
+ className="mx-4 flex flex-col gap-2 rounded-medium bg-error-container px-4 py-3 text-on-error-container"
104
+ >
105
+ <p className="m-0 text-body-medium">{l.clearMemoryConfirm}</p>
106
+ <div className="flex justify-end gap-2">
107
+ <button
108
+ type="button"
109
+ onClick={() => setConfirming(false)}
110
+ className={clsx(textButton, "hover:bg-on-error-container/8")}
111
+ >
112
+ {l.cancel}
113
+ </button>
114
+ <button
115
+ type="button"
116
+ onClick={() => {
117
+ setConfirming(false);
118
+ onClear?.();
119
+ }}
120
+ className={clsx(textButton, "bg-error px-4 text-on-error hover:bg-error/92")}
121
+ >
122
+ {l.confirm}
123
+ </button>
124
+ </div>
125
+ </div>
126
+ ) : null}
127
+ {loading ? (
128
+ <div className="flex justify-center py-6">
129
+ <CircularIndicator size={24} label={l.memoryLoading} />
130
+ </div>
131
+ ) : memories.length === 0 ? (
132
+ <p className="m-0 px-4 py-6 text-center text-body-medium text-on-surface-variant">
133
+ {l.memoryEmpty}
134
+ </p>
135
+ ) : (
136
+ <ul className="m-0 flex min-h-0 list-none flex-col overflow-y-auto p-0">
137
+ {memories.map((memory) => (
138
+ <li
139
+ key={memory.id}
140
+ className="group flex items-start gap-3 px-4 py-2 transition-colors duration-200 ease-standard hover:bg-on-surface/8"
141
+ >
142
+ <div className="min-w-0 flex-1">
143
+ <p className="m-0 text-body-medium break-words whitespace-pre-wrap text-on-surface">
144
+ {memory.content}
145
+ </p>
146
+ <div className="mt-1 flex flex-wrap items-center gap-1.5">
147
+ <time className="text-label-small text-on-surface-variant">
148
+ {format(memory.createdAt)}
149
+ </time>
150
+ {memory.tags?.map((tag) => (
151
+ <span
152
+ key={tag}
153
+ className="inline-flex h-6 items-center rounded-small border border-outline-variant px-2 text-label-small text-on-surface-variant"
154
+ >
155
+ {tag}
156
+ </span>
157
+ ))}
158
+ </div>
159
+ </div>
160
+ {onDelete ? (
161
+ <CircleButton
162
+ icon={<ChatIcon name="delete" size={18} />}
163
+ variant="standard"
164
+ size="sm"
165
+ onClick={() => onDelete(memory.id)}
166
+ aria-label={l.deleteMemory}
167
+ title={l.deleteMemory}
168
+ />
169
+ ) : null}
170
+ </li>
171
+ ))}
172
+ </ul>
173
+ )}
174
+ </section>
175
+ );
176
+ }
@@ -6,7 +6,10 @@
6
6
  import { CircleButton } from "@aphrody/m3-primitives";
7
7
  import { clsx } from "clsx";
8
8
 
9
- import { Markdown } from "./Markdown";
9
+ import { type ChatLabels, resolveChatLabels } from "./chatLabels";
10
+ import { type Citation, CitationList, CitedMarkdown, RefusalNotice } from "./citations";
11
+ import { ChatIcon } from "./chatIcons";
12
+ import { Markdown, type MarkdownLinkHandler } from "./Markdown";
10
13
 
11
14
  export interface MessageBubbleProps {
12
15
  content: string;
@@ -17,6 +20,15 @@ export interface MessageBubbleProps {
17
20
  onFork?: () => void;
18
21
  variant?: "default" | "codex";
19
22
  className?: string;
23
+ /** Visible strings (aria-labels, titles); English defaults. */
24
+ labels?: Partial<ChatLabels>;
25
+ /** Grounded answer: `[n]` markers become citation chips and the sources are listed below. */
26
+ citations?: readonly Citation[];
27
+ /** The backend refused to answer: a refusal notice replaces an empty answer. */
28
+ refusal?: boolean;
29
+ onCitationSelect?: (citation: Citation) => void;
30
+ /** Link activation in the answer (e.g. open the system browser from Tauri). */
31
+ onLinkClick?: MarkdownLinkHandler;
20
32
  }
21
33
 
22
34
  /**
@@ -32,12 +44,23 @@ export function MessageBubble({
32
44
  onFork,
33
45
  variant = "default",
34
46
  className,
47
+ labels,
48
+ citations,
49
+ refusal = false,
50
+ onCitationSelect,
51
+ onLinkClick,
35
52
  }: MessageBubbleProps) {
36
53
  const isCodex = variant === "codex";
54
+ const l = resolveChatLabels(labels);
55
+ const cited = !isUser && citations && citations.length > 0 ? citations : null;
37
56
 
38
57
  return (
39
58
  <div
40
- className={clsx("group flex flex-col py-1.5 w-full", isUser ? "items-end" : "items-start", className)}
59
+ className={clsx(
60
+ "group flex flex-col py-1.5 w-full",
61
+ isUser ? "items-end" : "items-start",
62
+ className,
63
+ )}
41
64
  >
42
65
  <div
43
66
  className={clsx(
@@ -53,39 +76,64 @@ export function MessageBubble({
53
76
  {isUser ? (
54
77
  <div className="whitespace-pre-wrap break-words">{content}</div>
55
78
  ) : (
56
- <Markdown content={content} size="large" className="break-words font-sans" />
79
+ <>
80
+ {refusal && !content.trim() ? (
81
+ <RefusalNotice labels={labels} />
82
+ ) : cited ? (
83
+ <CitedMarkdown
84
+ content={content}
85
+ citations={cited}
86
+ labels={labels}
87
+ onCitationSelect={onCitationSelect}
88
+ onLinkClick={onLinkClick}
89
+ className="break-words font-sans"
90
+ />
91
+ ) : (
92
+ <Markdown
93
+ content={content}
94
+ size="large"
95
+ labels={labels}
96
+ onLinkClick={onLinkClick}
97
+ className="break-words font-sans"
98
+ />
99
+ )}
100
+ {refusal && content.trim() ? <RefusalNotice labels={labels} className="mt-2" /> : null}
101
+ {cited && !isStreaming ? (
102
+ <CitationList citations={cited} labels={labels} onSelect={onCitationSelect} />
103
+ ) : null}
104
+ </>
57
105
  )}
58
106
  </div>
59
107
  {!isUser && (onCopy || onDislike || onFork) ? (
60
108
  <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
109
  {onCopy && (
62
110
  <CircleButton
63
- icon="content_copy"
111
+ icon={<ChatIcon name="content_copy" size={18} />}
64
112
  variant="standard"
65
113
  size="sm"
66
114
  onClick={() => onCopy(content)}
67
- aria-label={isStreaming ? "Copy Streaming Message" : "Copy Message"}
68
- title={isStreaming ? "Copy streaming message" : "Copier"}
115
+ aria-label={isStreaming ? l.copyStreamingMessage : l.copyMessage}
116
+ title={isStreaming ? l.copyStreamingMessage : l.copyMessage}
69
117
  />
70
118
  )}
71
119
  {onDislike && (
72
120
  <CircleButton
73
- icon="thumb_down"
121
+ icon={<ChatIcon name="thumb_down" size={18} />}
74
122
  variant="standard"
75
123
  size="sm"
76
124
  onClick={() => onDislike(content)}
77
- aria-label="Dislike"
78
- title="Je n'aime pas"
125
+ aria-label={l.dislike}
126
+ title={l.dislike}
79
127
  />
80
128
  )}
81
129
  {onFork && (
82
130
  <CircleButton
83
- icon="call_split"
131
+ icon={<ChatIcon name="call_split" size={18} />}
84
132
  variant="standard"
85
133
  size="sm"
86
134
  onClick={onFork}
87
- aria-label="Fork"
88
- title="Bifurquer à partir de ce tour"
135
+ aria-label={l.fork}
136
+ title={l.fork}
89
137
  />
90
138
  )}
91
139
  </div>
@@ -3,16 +3,12 @@
3
3
 
4
4
  "use client";
5
5
 
6
- import {
7
- Icon,
8
- Popover,
9
- usePopover,
10
- SelectPill,
11
- type SelectPillProps,
12
- } from "@aphrody/m3-primitives";
6
+ import { Popover, usePopover, SelectPill, type SelectPillProps } from "@aphrody/m3-primitives";
13
7
  import { clsx } from "clsx";
14
8
  import { forwardRef, useEffect, useMemo, useRef, useState } from "react";
15
9
 
10
+ import { type ChatLabels, formatLabel, resolveChatLabels } from "./chatLabels";
11
+ import { ChatIcon } from "./chatIcons";
16
12
  import { focusRing } from "./internal";
17
13
  import type { ModelOption } from "./types";
18
14
 
@@ -37,6 +33,8 @@ export interface ModelSelectorProps {
37
33
  className?: string;
38
34
  /** Disabled state */
39
35
  disabled?: boolean;
36
+ /** Visible strings; `placeholder` / `searchPlaceholder` win over them. English defaults. */
37
+ labels?: Partial<ChatLabels>;
40
38
  }
41
39
 
42
40
  /** Group models by provider, keeping the order of first appearance. */
@@ -67,6 +65,7 @@ export interface ModelOptionListProps {
67
65
  /** Called when a model is picked */
68
66
  onSelect: (value: string) => void;
69
67
  className?: string;
68
+ labels?: Partial<ChatLabels>;
70
69
  }
71
70
 
72
71
  /**
@@ -74,7 +73,14 @@ export interface ModelOptionListProps {
74
73
  * 48 dp two-line items (`text-label-large` + `text-body-small` supporting text), the selected
75
74
  * item on `secondary-container`, capabilities as small outlined chips.
76
75
  */
77
- export function ModelOptionList({ grouped, value, onSelect, className }: ModelOptionListProps) {
76
+ export function ModelOptionList({
77
+ grouped,
78
+ value,
79
+ onSelect,
80
+ className,
81
+ labels,
82
+ }: ModelOptionListProps) {
83
+ const l = resolveChatLabels(labels);
78
84
  const providers = Object.entries(grouped);
79
85
  const models = providers.flatMap(([, providerModels]) => providerModels);
80
86
  const [focusedId, setFocusedId] = useState(value);
@@ -85,7 +91,7 @@ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOp
85
91
  <div
86
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.
87
93
  role="listbox"
88
- aria-label="Models"
94
+ aria-label={l.models}
89
95
  tabIndex={-1}
90
96
  onKeyDown={(event) => {
91
97
  if (event.defaultPrevented) return;
@@ -158,7 +164,9 @@ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOp
158
164
  selected ? "text-on-secondary-container" : "text-on-surface-variant",
159
165
  )}
160
166
  >
161
- {model.context_window.toLocaleString()} tokens
167
+ {formatLabel(l.contextTokens, {
168
+ count: model.context_window.toLocaleString(),
169
+ })}
162
170
  </span>
163
171
  ) : null}
164
172
  {model.capabilities?.map((capability) => (
@@ -175,7 +183,7 @@ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOp
175
183
  </div>
176
184
  ) : null}
177
185
  </div>
178
- {selected && <Icon name="check" size={20} className="shrink-0" />}
186
+ {selected && <ChatIcon name="check" size={20} />}
179
187
  </button>
180
188
  );
181
189
  })}
@@ -183,7 +191,7 @@ export function ModelOptionList({ grouped, value, onSelect, className }: ModelOp
183
191
  ))}
184
192
  {providers.length === 0 && (
185
193
  <div className="px-3 py-3 text-center text-body-small text-on-surface-variant">
186
- No models found
194
+ {l.noModels}
187
195
  </div>
188
196
  )}
189
197
  </div>
@@ -212,7 +220,7 @@ function ModelSearchField({
212
220
  return (
213
221
  <div className="px-2 pb-2">
214
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">
215
- <Icon name="search" size={20} className="shrink-0" />
223
+ <ChatIcon name="search" size={20} />
216
224
  <input
217
225
  ref={inputRef}
218
226
  value={value}
@@ -232,16 +240,18 @@ export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
232
240
  models,
233
241
  value,
234
242
  onChange,
235
- placeholder = "Select model...",
236
- searchPlaceholder = "Search models...",
243
+ placeholder,
244
+ searchPlaceholder,
237
245
  variant,
238
246
  size,
239
247
  popover: externalPopover,
240
248
  className,
241
249
  disabled,
250
+ labels,
242
251
  },
243
252
  ref,
244
253
  ) => {
254
+ const l = resolveChatLabels(labels);
245
255
  const internalPopover = usePopover();
246
256
  const popover = externalPopover ?? internalPopover;
247
257
  const [search, setSearch] = useState("");
@@ -265,9 +275,13 @@ export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
265
275
  size={size}
266
276
  disabled={disabled}
267
277
  aria-haspopup="listbox"
278
+ hideCaret
268
279
  className={clsx("w-full", className)}
269
280
  >
270
- {selectedModel?.name ?? placeholder}
281
+ <span className="min-w-0 flex-1 truncate">
282
+ {selectedModel?.name ?? placeholder ?? l.selectModel}
283
+ </span>
284
+ <ChatIcon name="expand_more" size={18} />
271
285
  </SelectPill>
272
286
  </Popover.Trigger>
273
287
  <Popover.Content
@@ -277,13 +291,18 @@ export const ModelSelector = forwardRef<HTMLButtonElement, ModelSelectorProps>(
277
291
  className="overflow-hidden rounded-extra-small! py-2"
278
292
  >
279
293
  {/* Search */}
280
- <ModelSearchField value={search} onChange={setSearch} placeholder={searchPlaceholder} />
294
+ <ModelSearchField
295
+ value={search}
296
+ onChange={setSearch}
297
+ placeholder={searchPlaceholder ?? l.searchModels}
298
+ />
281
299
 
282
300
  {/* Grouped results */}
283
301
  <div className="max-h-[320px] overflow-y-auto">
284
302
  <ModelOptionList
285
303
  grouped={grouped}
286
304
  value={value}
305
+ labels={labels}
287
306
  onSelect={(id) => {
288
307
  onChange(id);
289
308
  popover.setOpen(false);