@artooi/ag-ui-web-component 0.6.0 → 0.7.0

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/README.md +140 -6
  3. package/dist/ag-ui-web-component.bundle.js +145 -47
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +16 -0
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +16 -0
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +672 -228
  12. package/dist/index.js.map +3 -3
  13. package/dist/tools/page_action_tools.d.ts +31 -0
  14. package/dist/tools/page_action_tools.d.ts.map +1 -0
  15. package/dist/ui/attachment_tray.d.ts +3 -0
  16. package/dist/ui/attachment_tray.d.ts.map +1 -1
  17. package/dist/ui/confirmation_card.d.ts +4 -1
  18. package/dist/ui/confirmation_card.d.ts.map +1 -1
  19. package/dist/ui/relative_time.d.ts +5 -3
  20. package/dist/ui/relative_time.d.ts.map +1 -1
  21. package/dist/ui/styles.d.ts +1 -1
  22. package/dist/ui/styles.d.ts.map +1 -1
  23. package/dist/ui/thread_drawer.d.ts +7 -1
  24. package/dist/ui/thread_drawer.d.ts.map +1 -1
  25. package/dist/ui/tool_call_card.d.ts +6 -2
  26. package/dist/ui/tool_call_card.d.ts.map +1 -1
  27. package/dist/ui/ui_strings.d.ts +126 -0
  28. package/dist/ui/ui_strings.d.ts.map +1 -0
  29. package/package.json +1 -1
  30. package/src/core/ag_ui_chat.ts +213 -41
  31. package/src/core/agui_client.ts +33 -2
  32. package/src/index.ts +7 -0
  33. package/src/tools/page_action_tools.ts +130 -0
  34. package/src/ui/attachment_tray.ts +13 -7
  35. package/src/ui/confirmation_card.ts +15 -5
  36. package/src/ui/relative_time.ts +15 -8
  37. package/src/ui/styles.ts +98 -0
  38. package/src/ui/thread_drawer.ts +53 -25
  39. package/src/ui/tool_call_card.ts +40 -17
  40. package/src/ui/ui_strings.ts +208 -0
  41. package/src/version.ts +1 -1
@@ -0,0 +1,130 @@
1
+ import { X_SUMMARY_KEY } from "../constants.js";
2
+ import { scrollIntoCenterView } from "../dom/animations.js";
3
+ import type { ClientTool } from "./client_tool_registry.js";
4
+
5
+ /**
6
+ * Resolve a page-action target string to a host-page element, or `null` when it
7
+ * matches nothing. The default (`document.querySelector`) treats the string as a
8
+ * CSS selector; a host with a page map overrides it to map its own element ids,
9
+ * the same way host packages wrap the DOM-driver primitives with environment-
10
+ * aware lookups.
11
+ */
12
+ export type ResolvePageTarget = (target: string) => HTMLElement | null;
13
+
14
+ /** The built-in page-action tool tokens, opted in via `data-page-actions`. */
15
+ export const PAGE_ACTIONS = {
16
+ SCROLL: "scroll",
17
+ DRAG: "drag",
18
+ } as const;
19
+
20
+ /**
21
+ * The opt-in page-action tools selected by `enabled` (a set of
22
+ * {@link PAGE_ACTIONS} tokens), bound to a {@link ResolvePageTarget}.
23
+ *
24
+ * - `scroll_to` — scroll a target (`top` / `bottom` / a resolver target) into
25
+ * view. Benign; no confirmation.
26
+ * - `drag_and_drop` — drag one element onto another, firing the standard HTML5
27
+ * drag sequence so the host page's own drop handler reacts. Not stamped
28
+ * destructive: a drag rearranges transient state and the durable change
29
+ * happens at the page's explicit commit. A host whose page persists *on drop*
30
+ * gates it with the element's `confirmPredicate`.
31
+ *
32
+ * Both report a clean, model-readable error when a target resolves to nothing,
33
+ * rather than throwing an opaque crash.
34
+ */
35
+ export function createPageActionTools(
36
+ enabled: ReadonlySet<string>,
37
+ resolveTarget: ResolvePageTarget,
38
+ ): ClientTool[] {
39
+ const tools: ClientTool[] = [];
40
+ if (enabled.has(PAGE_ACTIONS.SCROLL)) {
41
+ tools.push(scrollTool(resolveTarget));
42
+ }
43
+ if (enabled.has(PAGE_ACTIONS.DRAG)) {
44
+ tools.push(dragTool(resolveTarget));
45
+ }
46
+ return tools;
47
+ }
48
+
49
+ function scrollTool(resolveTarget: ResolvePageTarget): ClientTool {
50
+ return {
51
+ name: "scroll_to",
52
+ description:
53
+ "Scroll a target into view. `target` is `top`, `bottom`, or a CSS " +
54
+ "selector / page-map element id. Read-only: it changes nothing on the page.",
55
+ parameters: {
56
+ type: "object",
57
+ properties: { target: { type: "string" } },
58
+ required: ["target"],
59
+ [X_SUMMARY_KEY]: "Scroll into view",
60
+ },
61
+ handler: (args) => {
62
+ const target = String(args["target"] ?? "");
63
+ if (target === "top" || target === "bottom") {
64
+ const top = target === "top" ? 0 : document.body.scrollHeight;
65
+ window.scrollTo({ top, behavior: "smooth" });
66
+ return { scrolled: true, target };
67
+ }
68
+ const element = resolveTarget(target);
69
+ if (element === null) {
70
+ throw new Error(`no element matching "${target}"`);
71
+ }
72
+ scrollIntoCenterView(element);
73
+ return { scrolled: true, target };
74
+ },
75
+ };
76
+ }
77
+
78
+ function dragTool(resolveTarget: ResolvePageTarget): ClientTool {
79
+ return {
80
+ name: "drag_and_drop",
81
+ description:
82
+ "Drag the `from` element onto the `to` element (CSS selectors or page-map " +
83
+ "element ids), firing the page's native drag-and-drop. Use for reordering " +
84
+ "sortable lists. The page decides what the drop commits.",
85
+ parameters: {
86
+ type: "object",
87
+ properties: { from: { type: "string" }, to: { type: "string" } },
88
+ required: ["from", "to"],
89
+ [X_SUMMARY_KEY]: "Drag and drop",
90
+ },
91
+ handler: (args) => {
92
+ const fromTarget = String(args["from"] ?? "");
93
+ const toTarget = String(args["to"] ?? "");
94
+ const from = resolveTarget(fromTarget);
95
+ if (from === null) {
96
+ throw new Error(`no element matching "${fromTarget}"`);
97
+ }
98
+ const to = resolveTarget(toTarget);
99
+ if (to === null) {
100
+ throw new Error(`no element matching "${toTarget}"`);
101
+ }
102
+ dispatchDragSequence(from, to);
103
+ return { dragged: true, from: fromTarget, to: toTarget };
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Fire the standard HTML5 drag sequence — `dragstart` on the source, then
110
+ * `dragenter` / `dragover` / `drop` on the target, then `dragend` on the source —
111
+ * sharing one {@link DataTransfer}. Dispatched as typed, bubbling events (the
112
+ * `dataTransfer` is attached explicitly so a drop handler reads it in every
113
+ * environment).
114
+ */
115
+ function dispatchDragSequence(from: HTMLElement, to: HTMLElement): void {
116
+ const dataTransfer = new DataTransfer();
117
+ fire(from, "dragstart", dataTransfer);
118
+ fire(to, "dragenter", dataTransfer);
119
+ fire(to, "dragover", dataTransfer);
120
+ fire(to, "drop", dataTransfer);
121
+ fire(from, "dragend", dataTransfer);
122
+ }
123
+
124
+ function fire(target: HTMLElement, type: string, dataTransfer: DataTransfer): void {
125
+ const event = new Event(type, { bubbles: true, cancelable: true }) as Event & {
126
+ dataTransfer: DataTransfer;
127
+ };
128
+ event.dataTransfer = dataTransfer;
129
+ target.dispatchEvent(event);
130
+ }
@@ -3,6 +3,7 @@ import { ATTACHMENT_STATUS } from "../constants.js";
3
3
  import type { AttachmentRef } from "../core/attachment.js";
4
4
  import type { UploadHandler } from "../core/upload_attachment.js";
5
5
  import { formatBytes, iconFor } from "./attachment_chips.js";
6
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
6
7
 
7
8
  /** Status of a pending tray chip. */
8
9
  type AttachmentStatus = (typeof ATTACHMENT_STATUS)[keyof typeof ATTACHMENT_STATUS];
@@ -17,6 +18,8 @@ export interface AttachmentTrayConfig {
17
18
  readonly accept: string;
18
19
  /** Fired when the set of attachments changes (add / settle / remove). */
19
20
  readonly onChange?: () => void;
21
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
22
+ readonly strings?: UiStrings;
20
23
  }
21
24
 
22
25
  /** One pending file in the tray, from pick to ready/error. */
@@ -44,12 +47,15 @@ export class AttachmentTray {
44
47
  readonly element: HTMLDivElement;
45
48
 
46
49
  readonly #config: AttachmentTrayConfig;
50
+ readonly #strings: UiStrings;
47
51
  #items: TrayItem[] = [];
48
52
 
49
53
  constructor(config: AttachmentTrayConfig) {
50
54
  this.#config = config;
55
+ this.#strings = config.strings ?? DEFAULT_UI_STRINGS;
51
56
  this.element = document.createElement("div");
52
57
  this.element.className = "attachment-tray";
58
+ this.element.setAttribute("part", "attachment-tray");
53
59
  this.element.hidden = true;
54
60
  }
55
61
 
@@ -113,10 +119,10 @@ export class AttachmentTray {
113
119
  /** The size/type rejection reason for a file, or `null` when accepted. */
114
120
  #reject(file: File): string | null {
115
121
  if (this.#config.maxBytes > 0 && file.size > this.#config.maxBytes) {
116
- return `Too large (max ${formatBytes(this.#config.maxBytes)})`;
122
+ return this.#strings.tooLarge.replace("{size}", formatBytes(this.#config.maxBytes));
117
123
  }
118
124
  if (!accepts(this.#config.accept, file)) {
119
- return "File type not allowed";
125
+ return this.#strings.fileTypeNotAllowed;
120
126
  }
121
127
  return null;
122
128
  }
@@ -137,7 +143,7 @@ export class AttachmentTray {
137
143
  })
138
144
  .catch((error: unknown) => {
139
145
  item.status = ATTACHMENT_STATUS.ERROR;
140
- item.error = error instanceof Error ? error.message : "upload failed";
146
+ item.error = error instanceof Error ? error.message : this.#strings.uploadFailed;
141
147
  })
142
148
  .finally(() => {
143
149
  this.#render();
@@ -194,8 +200,8 @@ export class AttachmentTray {
194
200
  const retry = document.createElement("button");
195
201
  retry.type = "button";
196
202
  retry.className = "attachment-chip-retry";
197
- retry.title = "Retry";
198
- retry.setAttribute("aria-label", "Retry upload");
203
+ retry.title = this.#strings.retry;
204
+ retry.setAttribute("aria-label", this.#strings.retryUpload);
199
205
  retry.textContent = "↻";
200
206
  retry.addEventListener("click", () => this.#upload(item));
201
207
  chip.appendChild(retry);
@@ -204,8 +210,8 @@ export class AttachmentTray {
204
210
  const remove = document.createElement("button");
205
211
  remove.type = "button";
206
212
  remove.className = "attachment-chip-remove";
207
- remove.title = "Remove";
208
- remove.setAttribute("aria-label", "Remove attachment");
213
+ remove.title = this.#strings.remove;
214
+ remove.setAttribute("aria-label", this.#strings.removeAttachment);
209
215
  remove.textContent = "✕";
210
216
  remove.addEventListener("click", () => this.#remove(item));
211
217
  chip.appendChild(remove);
@@ -1,10 +1,12 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
1
3
  /** What the inline confirmation card displays. */
2
4
  export interface ConfirmationRequest {
3
5
  toolName: string;
4
6
  args: Record<string, unknown>;
5
7
  /**
6
8
  * Human-readable prompt (from the tool's `x-confirm` metadata), e.g.
7
- * "Activate this project?". Falls back to a generic `Run "<tool>"?`.
9
+ * "Activate this project?". Falls back to the generic `confirmRun` template.
8
10
  */
9
11
  message?: string;
10
12
  }
@@ -14,6 +16,7 @@ function actionButton(modifier: string, label: string): HTMLButtonElement {
14
16
  const button = document.createElement("button");
15
17
  button.type = "button";
16
18
  button.className = `confirm-btn confirm-btn--${modifier}`;
19
+ button.setAttribute("part", `confirm-button confirm-${modifier}`);
17
20
  button.textContent = label;
18
21
  return button;
19
22
  }
@@ -26,6 +29,8 @@ export interface ConfirmationOptions {
26
29
  * pending confirmation when the user cancels the whole run.
27
30
  */
28
31
  signal?: AbortSignal;
32
+ /** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
33
+ strings?: UiStrings;
29
34
  }
30
35
 
31
36
  /**
@@ -43,26 +48,31 @@ export function requestConfirmation(
43
48
  request: ConfirmationRequest,
44
49
  options: ConfirmationOptions = {},
45
50
  ): Promise<boolean> {
51
+ const strings = options.strings ?? DEFAULT_UI_STRINGS;
46
52
  return new Promise<boolean>((resolve) => {
47
53
  const card = document.createElement("div");
48
54
  card.className = "confirm";
55
+ card.setAttribute("part", "confirm");
49
56
  card.setAttribute("data-tool-name", request.toolName);
50
57
  card.setAttribute("role", "group");
51
- card.setAttribute("aria-label", "Confirm action");
58
+ card.setAttribute("aria-label", strings.confirmAction);
52
59
 
53
60
  const body = document.createElement("div");
54
61
  body.className = "confirm-body";
55
- body.textContent = request.message ?? `Run “${request.toolName}”?`;
62
+ body.setAttribute("part", "confirm-body");
63
+ body.textContent = request.message ?? strings.confirmRun.replace("{tool}", request.toolName);
56
64
 
57
65
  const args = document.createElement("pre");
58
66
  args.className = "confirm-args";
67
+ args.setAttribute("part", "confirm-args");
59
68
  args.textContent = JSON.stringify(request.args, null, 2);
60
69
 
61
70
  const actions = document.createElement("div");
62
71
  actions.className = "confirm-actions";
72
+ actions.setAttribute("part", "confirm-actions");
63
73
 
64
- const cancel = actionButton("cancel", "Cancel");
65
- const confirm = actionButton("confirm", "Confirm");
74
+ const cancel = actionButton("cancel", strings.cancel);
75
+ const confirm = actionButton("confirm", strings.confirm);
66
76
 
67
77
  let settled = false;
68
78
  const close = (accepted: boolean): void => {
@@ -1,28 +1,35 @@
1
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
2
+
1
3
  /**
2
4
  * A compact relative timestamp for a thread row — e.g. `"just now"`, `"5m ago"`,
3
5
  * `"3h ago"`, `"2d ago"`, `"4w ago"`.
4
6
  *
5
7
  * `now` is injectable so callers (and tests) can pin the reference point; it
6
8
  * defaults to the current time. A timestamp in the future (clock skew) reads as
7
- * `"just now"`. Kept locale-independent on purpose so the rendered label is
8
- * stable across environments.
9
+ * `"just now"`. The unit words come from {@link UiStrings} (the `{n}` token is
10
+ * filled in here) so a localized host translates them; the bucketing stays
11
+ * integer-rounded and locale-neutral.
9
12
  */
10
- export function relativeTime(timestamp: number, now: number = Date.now()): string {
13
+ export function relativeTime(
14
+ timestamp: number,
15
+ now: number = Date.now(),
16
+ strings: UiStrings = DEFAULT_UI_STRINGS,
17
+ ): string {
11
18
  const seconds = Math.round((now - timestamp) / 1000);
12
19
  if (seconds < 60) {
13
- return "just now";
20
+ return strings.justNow;
14
21
  }
15
22
  const minutes = Math.round(seconds / 60);
16
23
  if (minutes < 60) {
17
- return `${minutes}m ago`;
24
+ return strings.minutesAgo.replace("{n}", String(minutes));
18
25
  }
19
26
  const hours = Math.round(minutes / 60);
20
27
  if (hours < 24) {
21
- return `${hours}h ago`;
28
+ return strings.hoursAgo.replace("{n}", String(hours));
22
29
  }
23
30
  const days = Math.round(hours / 24);
24
31
  if (days < 7) {
25
- return `${days}d ago`;
32
+ return strings.daysAgo.replace("{n}", String(days));
26
33
  }
27
- return `${Math.round(days / 7)}w ago`;
34
+ return strings.weeksAgo.replace("{n}", String(Math.round(days / 7)));
28
35
  }
package/src/ui/styles.ts CHANGED
@@ -133,6 +133,71 @@ export const STYLES = `
133
133
  --ag-ui-radius: 0;
134
134
  }
135
135
 
136
+ /* Sidebar (CUST-3): a full-height docked panel that slides open/closed and
137
+ collapses to a slim icon rail (not the floating launcher). Docked right by
138
+ default; data-side="left" docks it left. Overlay by default — set
139
+ --ag-ui-position: static (and place this element in your own layout) for a
140
+ host-managed push instead. */
141
+ :host([placement="sidebar"]) {
142
+ --ag-ui-inset: 0 0 0 auto;
143
+ --ag-ui-width: 420px;
144
+ --ag-ui-height: 100vh;
145
+ --ag-ui-max-height: 100vh;
146
+ --ag-ui-radius: 0;
147
+ --ag-ui-rail-width: 52px;
148
+ transition: width 0.28s ease;
149
+ }
150
+
151
+ :host([placement="sidebar"][data-side="left"]) {
152
+ --ag-ui-inset: 0 auto 0 0;
153
+ }
154
+
155
+ :host([placement="sidebar"]) .chat {
156
+ transition: transform 0.28s ease;
157
+ }
158
+
159
+ /* Collapsed sidebar: shrink the host to the rail width, hide the panel, and
160
+ reveal the rail. Higher specificity than the generic collapse rules, so it
161
+ wins regardless of source order. */
162
+ :host([placement="sidebar"][collapsed]) {
163
+ width: var(--ag-ui-rail-width);
164
+ height: 100vh;
165
+ max-height: 100vh;
166
+ bottom: 0;
167
+ }
168
+
169
+ :host([placement="sidebar"][collapsed]) .chat {
170
+ display: none;
171
+ }
172
+
173
+ /* The rail is a sibling of the panel (so it survives the panel being hidden);
174
+ shown only for a collapsed sidebar. */
175
+ .rail {
176
+ display: none;
177
+ border: none;
178
+ font: inherit;
179
+ }
180
+
181
+ :host([placement="sidebar"][collapsed]) .rail {
182
+ display: flex;
183
+ position: absolute;
184
+ inset: 0;
185
+ align-items: flex-start;
186
+ justify-content: center;
187
+ padding-top: 16px;
188
+ border: 1px solid var(--ag-ui-border);
189
+ background: var(--ag-ui-header-bg);
190
+ color: var(--ag-ui-header-fg);
191
+ cursor: pointer;
192
+ }
193
+
194
+ @media (prefers-reduced-motion: reduce) {
195
+ :host([placement="sidebar"]),
196
+ :host([placement="sidebar"]) .chat {
197
+ transition: none;
198
+ }
199
+ }
200
+
136
201
  /* Embedded: drop the floating chrome and the high z-index stacking context so
137
202
  the widget lives in the host's own layout (fixes overlay/z-index clashes). */
138
203
  :host([placement="embedded"]) {
@@ -170,12 +235,33 @@ export const STYLES = `
170
235
  }
171
236
 
172
237
  .header-title {
238
+ flex: 1;
239
+ min-width: 0;
173
240
  font-weight: 600;
174
241
  overflow: hidden;
175
242
  text-overflow: ellipsis;
176
243
  white-space: nowrap;
177
244
  }
178
245
 
246
+ /* Header / launcher icon holder (CUST-2): a slot, with a data-icon-url <img>
247
+ fallback, sized via --ag-ui-icon-size. */
248
+ .icon-holder {
249
+ display: inline-flex;
250
+ align-items: center;
251
+ justify-content: center;
252
+ flex: none;
253
+ width: var(--ag-ui-icon-size, 22px);
254
+ height: var(--ag-ui-icon-size, 22px);
255
+ line-height: 1;
256
+ }
257
+
258
+ .icon-img {
259
+ width: 100%;
260
+ height: 100%;
261
+ object-fit: contain;
262
+ border-radius: var(--ag-ui-icon-radius, 4px);
263
+ }
264
+
179
265
  .header-controls {
180
266
  display: flex;
181
267
  gap: 2px;
@@ -231,6 +317,18 @@ export const STYLES = `
231
317
  gap: var(--ag-ui-space);
232
318
  }
233
319
 
320
+ /* Empty-state region (CUST-1 slot): centred while it's the only thing in the
321
+ list, hidden as soon as a message, card, or pending indicator renders. */
322
+ .empty {
323
+ margin: auto;
324
+ text-align: center;
325
+ color: var(--ag-ui-muted);
326
+ }
327
+
328
+ .empty[hidden] {
329
+ display: none;
330
+ }
331
+
234
332
  .message {
235
333
  max-width: 80%;
236
334
  padding: var(--ag-ui-msg-pad);
@@ -1,5 +1,6 @@
1
1
  import type { ThreadMeta } from "../core/conversation_store.js";
2
2
  import { relativeTime } from "./relative_time.js";
3
+ import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
3
4
 
4
5
  /** Actions the host ({@link AgUiChat}) wires to the drawer's rows. */
5
6
  export interface ThreadDrawerCallbacks {
@@ -20,52 +21,76 @@ export interface ThreadDrawerCallbacks {
20
21
  * {@link element}, toggles it, feeds rows via {@link setThreads}, and acts on
21
22
  * the callbacks. The drawer is a *view*: it does not mutate the store; after a
22
23
  * callback the host updates the store and calls {@link setThreads} to refresh.
24
+ *
25
+ * All visible text comes from {@link UiStrings}; {@link setStrings} re-localizes
26
+ * a drawer the host built before its strings resolved.
23
27
  */
24
28
  export class ThreadDrawer {
25
29
  /** The drawer root (backdrop + panel). Append to the chat shell; hidden until opened. */
26
30
  readonly element: HTMLDivElement;
27
31
 
28
32
  readonly #callbacks: ThreadDrawerCallbacks;
33
+ readonly #panel: HTMLDivElement;
34
+ readonly #heading: HTMLSpanElement;
35
+ readonly #newButton: HTMLButtonElement;
29
36
  readonly #list: HTMLDivElement;
37
+ #strings: UiStrings;
30
38
  #threads: readonly ThreadMeta[] = [];
31
39
  #activeId = "";
32
40
 
33
- constructor(callbacks: ThreadDrawerCallbacks) {
41
+ constructor(callbacks: ThreadDrawerCallbacks, strings: UiStrings = DEFAULT_UI_STRINGS) {
34
42
  this.#callbacks = callbacks;
43
+ this.#strings = strings;
35
44
 
36
45
  this.element = document.createElement("div");
37
46
  this.element.className = "drawer";
47
+ this.element.setAttribute("part", "drawer");
38
48
  this.element.hidden = true;
39
49
 
40
50
  const backdrop = document.createElement("div");
41
51
  backdrop.className = "drawer-backdrop";
52
+ backdrop.setAttribute("part", "drawer-backdrop");
42
53
  backdrop.addEventListener("click", () => this.close());
43
54
 
44
- const panel = document.createElement("div");
45
- panel.className = "drawer-panel";
46
- panel.setAttribute("role", "dialog");
47
- panel.setAttribute("aria-label", "Chat history");
55
+ this.#panel = document.createElement("div");
56
+ this.#panel.className = "drawer-panel";
57
+ this.#panel.setAttribute("part", "drawer-panel");
58
+ this.#panel.setAttribute("role", "dialog");
59
+ this.#panel.setAttribute("aria-label", strings.chatHistory);
48
60
 
49
61
  const header = document.createElement("div");
50
62
  header.className = "drawer-header";
51
- const heading = document.createElement("span");
52
- heading.className = "drawer-title";
53
- heading.textContent = "Chats";
54
- const newButton = document.createElement("button");
55
- newButton.type = "button";
56
- newButton.className = "drawer-new";
57
- newButton.textContent = "New chat";
58
- newButton.addEventListener("click", () => {
63
+ header.setAttribute("part", "drawer-header");
64
+ this.#heading = document.createElement("span");
65
+ this.#heading.className = "drawer-title";
66
+ this.#heading.setAttribute("part", "drawer-title");
67
+ this.#heading.textContent = strings.chats;
68
+ this.#newButton = document.createElement("button");
69
+ this.#newButton.type = "button";
70
+ this.#newButton.className = "drawer-new";
71
+ this.#newButton.setAttribute("part", "drawer-new");
72
+ this.#newButton.textContent = strings.newChat;
73
+ this.#newButton.addEventListener("click", () => {
59
74
  this.close();
60
75
  this.#callbacks.onNew();
61
76
  });
62
- header.append(heading, newButton);
77
+ header.append(this.#heading, this.#newButton);
63
78
 
64
79
  this.#list = document.createElement("div");
65
80
  this.#list.className = "drawer-list";
81
+ this.#list.setAttribute("part", "drawer-list");
66
82
 
67
- panel.append(header, this.#list);
68
- this.element.append(backdrop, panel);
83
+ this.#panel.append(header, this.#list);
84
+ this.element.append(backdrop, this.#panel);
85
+ }
86
+
87
+ /** Re-localize the drawer's chrome and rows (the host calls this on connect). */
88
+ setStrings(strings: UiStrings): void {
89
+ this.#strings = strings;
90
+ this.#panel.setAttribute("aria-label", strings.chatHistory);
91
+ this.#heading.textContent = strings.chats;
92
+ this.#newButton.textContent = strings.newChat;
93
+ this.#renderList();
69
94
  }
70
95
 
71
96
  isOpen(): boolean {
@@ -96,7 +121,8 @@ export class ThreadDrawer {
96
121
  if (this.#threads.length === 0) {
97
122
  const empty = document.createElement("div");
98
123
  empty.className = "drawer-empty";
99
- empty.textContent = "No conversations yet.";
124
+ empty.setAttribute("part", "drawer-empty");
125
+ empty.textContent = this.#strings.noConversations;
100
126
  this.#list.appendChild(empty);
101
127
  return;
102
128
  }
@@ -108,6 +134,7 @@ export class ThreadDrawer {
108
134
  #renderRow(meta: ThreadMeta): HTMLDivElement {
109
135
  const row = document.createElement("div");
110
136
  row.className = "drawer-row";
137
+ row.setAttribute("part", "drawer-row");
111
138
  if (meta.threadId === this.#activeId) {
112
139
  row.classList.add("drawer-row--active");
113
140
  }
@@ -115,12 +142,13 @@ export class ThreadDrawer {
115
142
  const select = document.createElement("button");
116
143
  select.type = "button";
117
144
  select.className = "drawer-row-select";
145
+ select.setAttribute("part", "drawer-row-select");
118
146
  const title = document.createElement("span");
119
147
  title.className = "drawer-row-title";
120
148
  title.textContent = meta.title;
121
149
  const time = document.createElement("span");
122
150
  time.className = "drawer-row-time";
123
- time.textContent = relativeTime(meta.updatedAt);
151
+ time.textContent = relativeTime(meta.updatedAt, undefined, this.#strings);
124
152
  const preview = document.createElement("span");
125
153
  preview.className = "drawer-row-preview";
126
154
  preview.textContent = meta.preview;
@@ -133,16 +161,16 @@ export class ThreadDrawer {
133
161
  const rename = document.createElement("button");
134
162
  rename.type = "button";
135
163
  rename.className = "drawer-row-rename";
136
- rename.title = "Rename";
137
- rename.setAttribute("aria-label", "Rename conversation");
164
+ rename.title = this.#strings.rename;
165
+ rename.setAttribute("aria-label", this.#strings.renameConversation);
138
166
  rename.textContent = "✎";
139
167
  rename.addEventListener("click", () => this.#startRename(row, meta));
140
168
 
141
169
  const remove = document.createElement("button");
142
170
  remove.type = "button";
143
171
  remove.className = "drawer-row-delete";
144
- remove.title = "Delete";
145
- remove.setAttribute("aria-label", "Delete conversation");
172
+ remove.title = this.#strings.delete;
173
+ remove.setAttribute("aria-label", this.#strings.deleteConversation);
146
174
  remove.textContent = "🗑";
147
175
  remove.addEventListener("click", () => this.#confirmDelete(row, meta));
148
176
 
@@ -183,16 +211,16 @@ export class ThreadDrawer {
183
211
  confirm.className = "drawer-confirm";
184
212
  const label = document.createElement("span");
185
213
  label.className = "drawer-confirm-label";
186
- label.textContent = "Delete?";
214
+ label.textContent = this.#strings.deletePrompt;
187
215
  const yes = document.createElement("button");
188
216
  yes.type = "button";
189
217
  yes.className = "drawer-confirm-yes";
190
- yes.textContent = "Delete";
218
+ yes.textContent = this.#strings.delete;
191
219
  yes.addEventListener("click", () => this.#callbacks.onDelete(meta.threadId));
192
220
  const no = document.createElement("button");
193
221
  no.type = "button";
194
222
  no.className = "drawer-confirm-no";
195
- no.textContent = "Cancel";
223
+ no.textContent = this.#strings.cancel;
196
224
  no.addEventListener("click", () => this.#renderList());
197
225
  confirm.append(label, yes, no);
198
226
  row.replaceChildren(confirm);