@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.
- package/CHANGELOG.md +44 -1
- package/README.md +140 -6
- package/dist/ag-ui-web-component.bundle.js +145 -47
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +16 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +16 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +672 -228
- package/dist/index.js.map +3 -3
- package/dist/tools/page_action_tools.d.ts +31 -0
- package/dist/tools/page_action_tools.d.ts.map +1 -0
- package/dist/ui/attachment_tray.d.ts +3 -0
- package/dist/ui/attachment_tray.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +4 -1
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/relative_time.d.ts +5 -3
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts +7 -1
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +6 -2
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +126 -0
- package/dist/ui/ui_strings.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +213 -41
- package/src/core/agui_client.ts +33 -2
- package/src/index.ts +7 -0
- package/src/tools/page_action_tools.ts +130 -0
- package/src/ui/attachment_tray.ts +13 -7
- package/src/ui/confirmation_card.ts +15 -5
- package/src/ui/relative_time.ts +15 -8
- package/src/ui/styles.ts +98 -0
- package/src/ui/thread_drawer.ts +53 -25
- package/src/ui/tool_call_card.ts +40 -17
- package/src/ui/ui_strings.ts +208 -0
- 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
|
|
122
|
+
return this.#strings.tooLarge.replace("{size}", formatBytes(this.#config.maxBytes));
|
|
117
123
|
}
|
|
118
124
|
if (!accepts(this.#config.accept, file)) {
|
|
119
|
-
return
|
|
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 :
|
|
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 =
|
|
198
|
-
retry.setAttribute("aria-label",
|
|
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 =
|
|
208
|
-
remove.setAttribute("aria-label",
|
|
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
|
|
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",
|
|
58
|
+
card.setAttribute("aria-label", strings.confirmAction);
|
|
52
59
|
|
|
53
60
|
const body = document.createElement("div");
|
|
54
61
|
body.className = "confirm-body";
|
|
55
|
-
body.
|
|
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",
|
|
65
|
-
const confirm = actionButton("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 => {
|
package/src/ui/relative_time.ts
CHANGED
|
@@ -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"`.
|
|
8
|
-
*
|
|
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(
|
|
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
|
|
20
|
+
return strings.justNow;
|
|
14
21
|
}
|
|
15
22
|
const minutes = Math.round(seconds / 60);
|
|
16
23
|
if (minutes < 60) {
|
|
17
|
-
return
|
|
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
|
|
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
|
|
32
|
+
return strings.daysAgo.replace("{n}", String(days));
|
|
26
33
|
}
|
|
27
|
-
return
|
|
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);
|
package/src/ui/thread_drawer.ts
CHANGED
|
@@ -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
|
-
|
|
45
|
-
panel.className = "drawer-panel";
|
|
46
|
-
panel.setAttribute("
|
|
47
|
-
panel.setAttribute("
|
|
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
|
-
|
|
52
|
-
heading
|
|
53
|
-
heading.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
newButton
|
|
57
|
-
newButton.
|
|
58
|
-
newButton.
|
|
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.
|
|
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 =
|
|
137
|
-
rename.setAttribute("aria-label",
|
|
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 =
|
|
145
|
-
remove.setAttribute("aria-label",
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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);
|