@artooi/ag-ui-web-component 0.3.1 → 0.5.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 +59 -1
- package/README.md +25 -1
- package/dist/ag-ui-web-component.bundle.js +251 -54
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +18 -2
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +37 -8
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +35 -0
- package/dist/core/remote_conversation_store.d.ts.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +740 -13
- package/dist/index.js.map +4 -4
- package/dist/ui/confirmation_card.d.ts +10 -1
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/relative_time.d.ts +11 -0
- package/dist/ui/relative_time.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts +33 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +163 -8
- package/src/core/agui_client.ts +63 -3
- package/src/core/conversation_store.ts +148 -9
- package/src/core/remote_conversation_store.ts +147 -0
- package/src/index.ts +7 -1
- package/src/ui/confirmation_card.ts +22 -0
- package/src/ui/relative_time.ts +28 -0
- package/src/ui/styles.ts +197 -0
- package/src/ui/thread_drawer.ts +200 -0
- package/src/version.ts +1 -1
|
@@ -12,6 +12,22 @@ export interface NavigationCheckpoint {
|
|
|
12
12
|
readonly toolCallId: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Lightweight metadata for one conversation — the thread-drawer row shape.
|
|
17
|
+
*
|
|
18
|
+
* Returned by {@link ClientConversationStore.listThreads} so the drawer can
|
|
19
|
+
* render a list without loading message bodies. `title` defaults to a
|
|
20
|
+
* truncation of the first user message (until an explicit rename); `preview`
|
|
21
|
+
* is a one-line excerpt of the latest message; `updatedAt` is epoch ms of the
|
|
22
|
+
* last change, used to order the list.
|
|
23
|
+
*/
|
|
24
|
+
export interface ThreadMeta {
|
|
25
|
+
readonly threadId: string;
|
|
26
|
+
readonly title: string;
|
|
27
|
+
readonly updatedAt: number;
|
|
28
|
+
readonly preview: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
/**
|
|
16
32
|
* Client-side persistence seam for the conversation and a pending-navigation
|
|
17
33
|
* checkpoint, keyed by `thread_id`.
|
|
@@ -19,36 +35,61 @@ export interface NavigationCheckpoint {
|
|
|
19
35
|
* The default {@link SessionStorageStore} keeps everything per-tab in
|
|
20
36
|
* `sessionStorage`, so the chat survives the full page reloads of a
|
|
21
37
|
* multi-page app. A host may inject a server-backed store instead (e.g. one
|
|
22
|
-
* that rehydrates from a history endpoint); `loadMessages`
|
|
23
|
-
* async-friendly. The checkpoint methods stay synchronous — the
|
|
24
|
-
* tiny local hint a server store can derive from history and no-op.
|
|
38
|
+
* that rehydrates from a history endpoint); `loadMessages` and `listThreads`
|
|
39
|
+
* are therefore async-friendly. The checkpoint methods stay synchronous — the
|
|
40
|
+
* marker is a tiny local hint a server store can derive from history and no-op.
|
|
41
|
+
*
|
|
42
|
+
* Thread enumeration (`listThreads` / `setActiveThread` / `renameThread`) backs
|
|
43
|
+
* the chat-history drawer; "delete a thread" reuses {@link clear} and "new
|
|
44
|
+
* chat" reuses {@link threadId} after clearing the active thread.
|
|
25
45
|
*/
|
|
26
46
|
export interface ClientConversationStore {
|
|
27
|
-
/**
|
|
47
|
+
/** The active conversation id, generated and persisted on first read. */
|
|
28
48
|
threadId(): string;
|
|
29
49
|
/** Load the persisted message history, or `null` when none exists. */
|
|
30
50
|
loadMessages(threadId: string): Promise<readonly Message[] | null>;
|
|
31
|
-
/** Persist the message history. */
|
|
51
|
+
/** Persist the message history (and refresh the thread's drawer metadata). */
|
|
32
52
|
saveMessages(threadId: string, messages: readonly Message[]): void;
|
|
33
53
|
/** Load the pending-navigation checkpoint, or `null` when none is set. */
|
|
34
54
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null;
|
|
35
55
|
/** Set the pending-navigation checkpoint, or clear it when given `null`. */
|
|
36
56
|
saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void;
|
|
37
|
-
/** Forget the conversation and checkpoint (
|
|
57
|
+
/** Forget the conversation and checkpoint (a "delete thread" / "new chat"). */
|
|
38
58
|
clear(threadId: string): void;
|
|
59
|
+
/** The user's threads as drawer metadata (no message bodies), newest first. */
|
|
60
|
+
listThreads(): Promise<readonly ThreadMeta[]>;
|
|
61
|
+
/** Make `threadId` the active conversation (the drawer selecting a row). */
|
|
62
|
+
setActiveThread(threadId: string): void;
|
|
63
|
+
/** Set a thread's display title (the drawer renaming a row). */
|
|
64
|
+
renameThread(threadId: string, title: string): void;
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
const THREAD_KEY = "ag-ui-chat:thread";
|
|
68
|
+
const THREADS_KEY = "ag-ui-chat:threads";
|
|
42
69
|
const MESSAGES_PREFIX = "ag-ui-chat:messages:";
|
|
43
70
|
const CHECKPOINT_PREFIX = "ag-ui-chat:checkpoint:";
|
|
44
71
|
|
|
72
|
+
const TITLE_LIMIT = 60;
|
|
73
|
+
const PREVIEW_LIMIT = 100;
|
|
74
|
+
const DEFAULT_TITLE = "New conversation";
|
|
75
|
+
|
|
76
|
+
/** The drawer-index entry; `titleCustom` (private) freezes a renamed title. */
|
|
77
|
+
interface StoredThread {
|
|
78
|
+
threadId: string;
|
|
79
|
+
title: string;
|
|
80
|
+
titleCustom: boolean;
|
|
81
|
+
preview: string;
|
|
82
|
+
updatedAt: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
45
85
|
/**
|
|
46
86
|
* Default {@link ClientConversationStore}: per-tab `sessionStorage`.
|
|
47
87
|
*
|
|
48
88
|
* Survives full page reloads and same-tab navigation, clears on tab close —
|
|
49
89
|
* the right scope for an embedded agent's conversation in a multi-page app.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
90
|
+
* Tracks multiple threads per tab: the active id lives under one key, the
|
|
91
|
+
* message history / checkpoint are namespaced by id, and a small index feeds
|
|
92
|
+
* the drawer so it works with no server.
|
|
52
93
|
*/
|
|
53
94
|
export class SessionStorageStore implements ClientConversationStore {
|
|
54
95
|
threadId(): string {
|
|
@@ -67,6 +108,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
67
108
|
|
|
68
109
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
69
110
|
sessionStorage.setItem(MESSAGES_PREFIX + threadId, JSON.stringify(messages));
|
|
111
|
+
this.#touchThread(threadId, messages);
|
|
70
112
|
}
|
|
71
113
|
|
|
72
114
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
|
@@ -85,7 +127,71 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
85
127
|
clear(threadId: string): void {
|
|
86
128
|
sessionStorage.removeItem(MESSAGES_PREFIX + threadId);
|
|
87
129
|
sessionStorage.removeItem(CHECKPOINT_PREFIX + threadId);
|
|
88
|
-
|
|
130
|
+
this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
|
|
131
|
+
// Only drop the active pointer when the active thread itself is cleared, so
|
|
132
|
+
// the next `threadId()` mints a fresh one. Deleting another thread from the
|
|
133
|
+
// drawer must not disturb the conversation on screen.
|
|
134
|
+
if (sessionStorage.getItem(THREAD_KEY) === threadId) {
|
|
135
|
+
sessionStorage.removeItem(THREAD_KEY);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
listThreads(): Promise<readonly ThreadMeta[]> {
|
|
140
|
+
const metas = this.#readThreads()
|
|
141
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
142
|
+
.map(({ threadId, title, updatedAt, preview }) => ({ threadId, title, updatedAt, preview }));
|
|
143
|
+
return Promise.resolve(metas);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setActiveThread(threadId: string): void {
|
|
147
|
+
sessionStorage.setItem(THREAD_KEY, threadId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
renameThread(threadId: string, title: string): void {
|
|
151
|
+
const threads = this.#readThreads();
|
|
152
|
+
const entry = threads.find((thread) => thread.threadId === threadId);
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
entry.title = title;
|
|
157
|
+
entry.titleCustom = true;
|
|
158
|
+
this.#writeThreads(threads);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Add or refresh a thread's drawer metadata from its latest messages. */
|
|
162
|
+
#touchThread(threadId: string, messages: readonly Message[]): void {
|
|
163
|
+
const threads = this.#readThreads();
|
|
164
|
+
const entry = threads.find((thread) => thread.threadId === threadId);
|
|
165
|
+
const preview = derivePreview(messages);
|
|
166
|
+
const updatedAt = Date.now();
|
|
167
|
+
if (entry === undefined) {
|
|
168
|
+
threads.push({
|
|
169
|
+
threadId,
|
|
170
|
+
title: deriveTitle(messages),
|
|
171
|
+
titleCustom: false,
|
|
172
|
+
preview,
|
|
173
|
+
updatedAt,
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
entry.preview = preview;
|
|
177
|
+
entry.updatedAt = updatedAt;
|
|
178
|
+
if (!entry.titleCustom) {
|
|
179
|
+
entry.title = deriveTitle(messages);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
this.#writeThreads(threads);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#readThreads(): StoredThread[] {
|
|
186
|
+
return this.#readJson<StoredThread[]>(THREADS_KEY) ?? [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#writeThreads(threads: readonly StoredThread[]): void {
|
|
190
|
+
if (threads.length === 0) {
|
|
191
|
+
sessionStorage.removeItem(THREADS_KEY);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
sessionStorage.setItem(THREADS_KEY, JSON.stringify(threads));
|
|
89
195
|
}
|
|
90
196
|
|
|
91
197
|
/** Parse a stored JSON value, returning `null` when absent or corrupt. */
|
|
@@ -101,3 +207,36 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
101
207
|
}
|
|
102
208
|
}
|
|
103
209
|
}
|
|
210
|
+
|
|
211
|
+
/** The thread title: the first user message, collapsed + truncated. */
|
|
212
|
+
function deriveTitle(messages: readonly Message[]): string {
|
|
213
|
+
for (const message of messages) {
|
|
214
|
+
if (message.role === "user") {
|
|
215
|
+
const text = cleanText(message.content);
|
|
216
|
+
if (text !== "") {
|
|
217
|
+
return truncate(text, TITLE_LIMIT);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return DEFAULT_TITLE;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A one-line preview: the latest message with text, collapsed + truncated. */
|
|
225
|
+
function derivePreview(messages: readonly Message[]): string {
|
|
226
|
+
for (const message of [...messages].reverse()) {
|
|
227
|
+
const text = cleanText(message.content);
|
|
228
|
+
if (text !== "") {
|
|
229
|
+
return truncate(text, PREVIEW_LIMIT);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return "";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Whitespace-collapsed message text, or `""` for non-string content. */
|
|
236
|
+
function cleanText(content: unknown): string {
|
|
237
|
+
return typeof content === "string" ? content.replace(/\s+/g, " ").trim() : "";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function truncate(text: string, limit: number): string {
|
|
241
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1).trimEnd()}…`;
|
|
242
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { Message } from "@ag-ui/core";
|
|
2
|
+
import {
|
|
3
|
+
type ClientConversationStore,
|
|
4
|
+
type NavigationCheckpoint,
|
|
5
|
+
SessionStorageStore,
|
|
6
|
+
type ThreadMeta,
|
|
7
|
+
} from "./conversation_store.js";
|
|
8
|
+
|
|
9
|
+
/** One row of the server thread index (django-ag-ui's `ThreadsView` wire shape). */
|
|
10
|
+
interface ServerThreadRow {
|
|
11
|
+
readonly thread_id: string;
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly updated_at: string | null;
|
|
14
|
+
readonly preview: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Live header source, read per request so rotated tokens / CSRF reach the server. */
|
|
18
|
+
type HeadersProvider = () => Record<string, string>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A {@link ClientConversationStore} backed by a server thread-index endpoint —
|
|
22
|
+
* django-ag-ui's owner-scoped `ThreadsView`, the URL passed to `<ag-ui-chat>`
|
|
23
|
+
* as `data-threads-url`:
|
|
24
|
+
*
|
|
25
|
+
* - `GET <url>` → list the user's threads (metadata only);
|
|
26
|
+
* - `GET <url><id>/` → that thread's messages;
|
|
27
|
+
* - `PATCH <url><id>/` → rename (`{ "title": … }`);
|
|
28
|
+
* - `DELETE <url><id>/` → delete.
|
|
29
|
+
*
|
|
30
|
+
* It wraps a local store (default {@link SessionStorageStore}) for the
|
|
31
|
+
* client-only concerns — the active thread id, the navigation checkpoint, and a
|
|
32
|
+
* message cache — and as the graceful fallback when a request fails. Rename and
|
|
33
|
+
* delete apply **optimistically** (a small local overlay) so the drawer
|
|
34
|
+
* reflects them at once, before the fire-and-forget server round-trip lands.
|
|
35
|
+
*/
|
|
36
|
+
export class RemoteConversationStore implements ClientConversationStore {
|
|
37
|
+
readonly #url: string;
|
|
38
|
+
readonly #headers: HeadersProvider;
|
|
39
|
+
readonly #local: ClientConversationStore;
|
|
40
|
+
readonly #dropped = new Set<string>();
|
|
41
|
+
readonly #renamed = new Map<string, string>();
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
url: string,
|
|
45
|
+
headers: HeadersProvider = () => ({}),
|
|
46
|
+
local: ClientConversationStore = new SessionStorageStore(),
|
|
47
|
+
) {
|
|
48
|
+
this.#url = url.endsWith("/") ? url : `${url}/`;
|
|
49
|
+
this.#headers = headers;
|
|
50
|
+
this.#local = local;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
threadId(): string {
|
|
54
|
+
return this.#local.threadId();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
setActiveThread(threadId: string): void {
|
|
58
|
+
this.#local.setActiveThread(threadId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
62
|
+
// The agent run persists server-side; keep a local cache for offline replay.
|
|
63
|
+
this.#local.saveMessages(threadId, messages);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
|
67
|
+
return this.#local.loadCheckpoint(threadId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void {
|
|
71
|
+
this.#local.saveCheckpoint(threadId, checkpoint);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
renameThread(threadId: string, title: string): void {
|
|
75
|
+
this.#local.renameThread(threadId, title);
|
|
76
|
+
this.#renamed.set(threadId, title);
|
|
77
|
+
void this.#mutate(threadId, "PATCH", { title });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clear(threadId: string): void {
|
|
81
|
+
this.#local.clear(threadId);
|
|
82
|
+
this.#dropped.add(threadId);
|
|
83
|
+
void this.#mutate(threadId, "DELETE");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async listThreads(): Promise<readonly ThreadMeta[]> {
|
|
87
|
+
const rows = await this.#fetchThreads();
|
|
88
|
+
if (rows === null) {
|
|
89
|
+
return this.#local.listThreads();
|
|
90
|
+
}
|
|
91
|
+
return rows.filter((row) => !this.#dropped.has(row.thread_id)).map((row) => this.#toMeta(row));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async loadMessages(threadId: string): Promise<readonly Message[] | null> {
|
|
95
|
+
const response = await this.#get(this.#url + encodeURIComponent(threadId) + "/");
|
|
96
|
+
if (response === null || !response.ok) {
|
|
97
|
+
return this.#local.loadMessages(threadId);
|
|
98
|
+
}
|
|
99
|
+
const body = (await response.json()) as { messages?: readonly Message[] };
|
|
100
|
+
return body.messages ?? null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async #fetchThreads(): Promise<readonly ServerThreadRow[] | null> {
|
|
104
|
+
const response = await this.#get(this.#url);
|
|
105
|
+
if (response === null || !response.ok) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const body = (await response.json()) as { threads?: readonly ServerThreadRow[] };
|
|
109
|
+
return body.threads ?? [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#toMeta(row: ServerThreadRow): ThreadMeta {
|
|
113
|
+
return {
|
|
114
|
+
threadId: row.thread_id,
|
|
115
|
+
title: this.#renamed.get(row.thread_id) ?? row.title,
|
|
116
|
+
updatedAt: row.updated_at === null ? 0 : Date.parse(row.updated_at),
|
|
117
|
+
preview: row.preview,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** GET that resolves to the `Response`, or `null` on a network error. */
|
|
122
|
+
async #get(url: string): Promise<Response | null> {
|
|
123
|
+
try {
|
|
124
|
+
return await fetch(url, { headers: this.#headers() });
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Fire a best-effort write to the thread endpoint; failures are tolerated. */
|
|
131
|
+
async #mutate(
|
|
132
|
+
threadId: string,
|
|
133
|
+
method: "PATCH" | "DELETE",
|
|
134
|
+
body?: { title: string },
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
const headers = this.#headers();
|
|
137
|
+
try {
|
|
138
|
+
await fetch(this.#url + encodeURIComponent(threadId) + "/", {
|
|
139
|
+
method,
|
|
140
|
+
headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
|
|
141
|
+
body: body === undefined ? null : JSON.stringify(body),
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
// Best-effort; the optimistic overlay keeps the drawer consistent.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export {
|
|
|
32
32
|
type ClientConversationStore,
|
|
33
33
|
type NavigationCheckpoint,
|
|
34
34
|
SessionStorageStore,
|
|
35
|
+
type ThreadMeta,
|
|
35
36
|
} from "./core/conversation_store.js";
|
|
36
37
|
export {
|
|
37
38
|
type AgentFactory,
|
|
@@ -39,6 +40,7 @@ export {
|
|
|
39
40
|
type HttpAgentOptions,
|
|
40
41
|
} from "./core/create_http_agent.js";
|
|
41
42
|
export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
|
|
43
|
+
export { RemoteConversationStore } from "./core/remote_conversation_store.js";
|
|
42
44
|
export {
|
|
43
45
|
type FlashOptions,
|
|
44
46
|
focusWithFlash,
|
|
@@ -79,7 +81,11 @@ export {
|
|
|
79
81
|
type RouteWithParams,
|
|
80
82
|
} from "./tools/route_map.js";
|
|
81
83
|
export { createStateHookTools, type StateHook } from "./tools/state_hook.js";
|
|
82
|
-
export {
|
|
84
|
+
export {
|
|
85
|
+
type ConfirmationOptions,
|
|
86
|
+
type ConfirmationRequest,
|
|
87
|
+
requestConfirmation,
|
|
88
|
+
} from "./ui/confirmation_card.js";
|
|
83
89
|
export { prettifyToolName } from "./ui/prettify_tool_name.js";
|
|
84
90
|
export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
|
|
85
91
|
export {
|
|
@@ -18,6 +18,16 @@ function actionButton(modifier: string, label: string): HTMLButtonElement {
|
|
|
18
18
|
return button;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Options for {@link requestConfirmation}. */
|
|
22
|
+
export interface ConfirmationOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Aborting this signal resolves the card as declined (buttons disabled,
|
|
25
|
+
* `data-resolved="declined"`) — the hook a Stop control uses to dismiss a
|
|
26
|
+
* pending confirmation when the user cancels the whole run.
|
|
27
|
+
*/
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
/**
|
|
22
32
|
* Append an inline confirmation card to ``host`` (the chat message list) and
|
|
23
33
|
* resolve when the user decides.
|
|
@@ -31,6 +41,7 @@ function actionButton(modifier: string, label: string): HTMLButtonElement {
|
|
|
31
41
|
export function requestConfirmation(
|
|
32
42
|
host: Node & ParentNode,
|
|
33
43
|
request: ConfirmationRequest,
|
|
44
|
+
options: ConfirmationOptions = {},
|
|
34
45
|
): Promise<boolean> {
|
|
35
46
|
return new Promise<boolean>((resolve) => {
|
|
36
47
|
const card = document.createElement("div");
|
|
@@ -53,7 +64,12 @@ export function requestConfirmation(
|
|
|
53
64
|
const cancel = actionButton("cancel", "Cancel");
|
|
54
65
|
const confirm = actionButton("confirm", "Confirm");
|
|
55
66
|
|
|
67
|
+
let settled = false;
|
|
56
68
|
const close = (accepted: boolean): void => {
|
|
69
|
+
if (settled) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
settled = true;
|
|
57
73
|
cancel.disabled = true;
|
|
58
74
|
confirm.disabled = true;
|
|
59
75
|
card.setAttribute("data-resolved", accepted ? "confirmed" : "declined");
|
|
@@ -62,10 +78,16 @@ export function requestConfirmation(
|
|
|
62
78
|
|
|
63
79
|
cancel.addEventListener("click", () => close(false));
|
|
64
80
|
confirm.addEventListener("click", () => close(true));
|
|
81
|
+
options.signal?.addEventListener("abort", () => close(false), { once: true });
|
|
65
82
|
|
|
66
83
|
actions.append(cancel, confirm);
|
|
67
84
|
card.append(body, args, actions);
|
|
68
85
|
host.appendChild(card);
|
|
86
|
+
if (options.signal?.aborted === true) {
|
|
87
|
+
// The run was cancelled before the card could ask; record the decline.
|
|
88
|
+
close(false);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
69
91
|
confirm.focus();
|
|
70
92
|
});
|
|
71
93
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A compact relative timestamp for a thread row — e.g. `"just now"`, `"5m ago"`,
|
|
3
|
+
* `"3h ago"`, `"2d ago"`, `"4w ago"`.
|
|
4
|
+
*
|
|
5
|
+
* `now` is injectable so callers (and tests) can pin the reference point; it
|
|
6
|
+
* 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
|
+
*/
|
|
10
|
+
export function relativeTime(timestamp: number, now: number = Date.now()): string {
|
|
11
|
+
const seconds = Math.round((now - timestamp) / 1000);
|
|
12
|
+
if (seconds < 60) {
|
|
13
|
+
return "just now";
|
|
14
|
+
}
|
|
15
|
+
const minutes = Math.round(seconds / 60);
|
|
16
|
+
if (minutes < 60) {
|
|
17
|
+
return `${minutes}m ago`;
|
|
18
|
+
}
|
|
19
|
+
const hours = Math.round(minutes / 60);
|
|
20
|
+
if (hours < 24) {
|
|
21
|
+
return `${hours}h ago`;
|
|
22
|
+
}
|
|
23
|
+
const days = Math.round(hours / 24);
|
|
24
|
+
if (days < 7) {
|
|
25
|
+
return `${days}d ago`;
|
|
26
|
+
}
|
|
27
|
+
return `${Math.round(days / 7)}w ago`;
|
|
28
|
+
}
|
package/src/ui/styles.ts
CHANGED
|
@@ -501,6 +501,19 @@ export const STYLES = `
|
|
|
501
501
|
cursor: default;
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
/* The composer button doubles as the Stop control while a run is in flight. */
|
|
505
|
+
.send[data-state="running"] {
|
|
506
|
+
background: var(--ag-ui-muted);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/* Muted "⏹ Stopped" line after a cancelled run — a note, not an error bubble. */
|
|
510
|
+
.stopped-note {
|
|
511
|
+
align-self: flex-start;
|
|
512
|
+
color: var(--ag-ui-muted);
|
|
513
|
+
font-size: 12px;
|
|
514
|
+
padding: 2px 4px;
|
|
515
|
+
}
|
|
516
|
+
|
|
504
517
|
/* Inline confirmation card — lives in the transcript, no focus-stealing overlay. */
|
|
505
518
|
.confirm {
|
|
506
519
|
align-self: stretch;
|
|
@@ -631,4 +644,188 @@ export const STYLES = `
|
|
|
631
644
|
font-size: 0.85em;
|
|
632
645
|
color: var(--ag-ui-danger);
|
|
633
646
|
}
|
|
647
|
+
|
|
648
|
+
/* Chat-history drawer — a slide-over within the chat panel. */
|
|
649
|
+
.drawer {
|
|
650
|
+
position: absolute;
|
|
651
|
+
inset: 0;
|
|
652
|
+
z-index: 5;
|
|
653
|
+
display: flex;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.drawer[hidden] {
|
|
657
|
+
display: none;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.drawer-backdrop {
|
|
661
|
+
position: absolute;
|
|
662
|
+
inset: 0;
|
|
663
|
+
background: rgba(20, 20, 50, 0.32);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.drawer-panel {
|
|
667
|
+
position: relative;
|
|
668
|
+
display: flex;
|
|
669
|
+
flex-direction: column;
|
|
670
|
+
width: min(300px, 85%);
|
|
671
|
+
height: 100%;
|
|
672
|
+
background: var(--ag-ui-bg);
|
|
673
|
+
border-right: 1px solid var(--ag-ui-border);
|
|
674
|
+
box-shadow: var(--ag-ui-shadow);
|
|
675
|
+
overflow: hidden;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
.drawer-header {
|
|
679
|
+
display: flex;
|
|
680
|
+
align-items: center;
|
|
681
|
+
justify-content: space-between;
|
|
682
|
+
gap: var(--ag-ui-space);
|
|
683
|
+
padding: var(--ag-ui-pad);
|
|
684
|
+
border-bottom: 1px solid var(--ag-ui-border);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.drawer-title {
|
|
688
|
+
font-weight: 600;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.drawer-new {
|
|
692
|
+
border: 1px solid var(--ag-ui-border);
|
|
693
|
+
border-radius: var(--ag-ui-radius);
|
|
694
|
+
background: var(--ag-ui-bg);
|
|
695
|
+
color: var(--ag-ui-accent);
|
|
696
|
+
padding: 4px 10px;
|
|
697
|
+
font: inherit;
|
|
698
|
+
font-size: 0.85em;
|
|
699
|
+
cursor: pointer;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
.drawer-list {
|
|
703
|
+
flex: 1;
|
|
704
|
+
min-height: 0;
|
|
705
|
+
overflow-y: auto;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.drawer-empty {
|
|
709
|
+
padding: var(--ag-ui-pad);
|
|
710
|
+
font-size: 0.9em;
|
|
711
|
+
color: var(--ag-ui-muted);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
.drawer-row {
|
|
715
|
+
display: flex;
|
|
716
|
+
align-items: stretch;
|
|
717
|
+
border-bottom: 1px solid var(--ag-ui-border);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.drawer-row--active {
|
|
721
|
+
background: var(--ag-ui-assistant-bg);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.drawer-row-select {
|
|
725
|
+
flex: 1;
|
|
726
|
+
min-width: 0;
|
|
727
|
+
display: flex;
|
|
728
|
+
flex-direction: column;
|
|
729
|
+
gap: 2px;
|
|
730
|
+
padding: 8px 12px;
|
|
731
|
+
border: none;
|
|
732
|
+
background: none;
|
|
733
|
+
color: inherit;
|
|
734
|
+
font: inherit;
|
|
735
|
+
text-align: left;
|
|
736
|
+
cursor: pointer;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.drawer-row-title {
|
|
740
|
+
font-weight: 600;
|
|
741
|
+
overflow: hidden;
|
|
742
|
+
white-space: nowrap;
|
|
743
|
+
text-overflow: ellipsis;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
.drawer-row-time {
|
|
747
|
+
font-size: 0.72em;
|
|
748
|
+
color: var(--ag-ui-muted);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.drawer-row-preview {
|
|
752
|
+
font-size: 0.8em;
|
|
753
|
+
color: var(--ag-ui-muted);
|
|
754
|
+
overflow: hidden;
|
|
755
|
+
white-space: nowrap;
|
|
756
|
+
text-overflow: ellipsis;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
.drawer-row-actions {
|
|
760
|
+
display: flex;
|
|
761
|
+
align-items: center;
|
|
762
|
+
gap: 2px;
|
|
763
|
+
padding: 0 6px;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
.drawer-row-rename,
|
|
767
|
+
.drawer-row-delete {
|
|
768
|
+
border: none;
|
|
769
|
+
background: none;
|
|
770
|
+
color: var(--ag-ui-muted);
|
|
771
|
+
font-size: 0.9em;
|
|
772
|
+
padding: 4px;
|
|
773
|
+
cursor: pointer;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
.drawer-rename-input {
|
|
777
|
+
flex: 1;
|
|
778
|
+
min-width: 0;
|
|
779
|
+
margin: 6px 10px;
|
|
780
|
+
padding: 4px 8px;
|
|
781
|
+
border: 1px solid var(--ag-ui-accent);
|
|
782
|
+
border-radius: 6px;
|
|
783
|
+
background: var(--ag-ui-input-bg);
|
|
784
|
+
color: var(--ag-ui-fg);
|
|
785
|
+
font: inherit;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.drawer-confirm {
|
|
789
|
+
display: flex;
|
|
790
|
+
align-items: center;
|
|
791
|
+
gap: 8px;
|
|
792
|
+
padding: 8px 12px;
|
|
793
|
+
font-size: 0.85em;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
.drawer-confirm-label {
|
|
797
|
+
color: var(--ag-ui-danger);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
.drawer-confirm-yes {
|
|
801
|
+
border: none;
|
|
802
|
+
border-radius: 6px;
|
|
803
|
+
background: var(--ag-ui-danger);
|
|
804
|
+
color: #ffffff;
|
|
805
|
+
padding: 3px 10px;
|
|
806
|
+
font: inherit;
|
|
807
|
+
cursor: pointer;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
.drawer-confirm-no {
|
|
811
|
+
border: 1px solid var(--ag-ui-border);
|
|
812
|
+
border-radius: 6px;
|
|
813
|
+
background: none;
|
|
814
|
+
color: inherit;
|
|
815
|
+
padding: 3px 10px;
|
|
816
|
+
font: inherit;
|
|
817
|
+
cursor: pointer;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/* Embedded placement: an inline, flush side panel rather than a dimmed,
|
|
821
|
+
floating slide-over. */
|
|
822
|
+
:host([placement="embedded"]) .drawer-backdrop {
|
|
823
|
+
background: none;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
:host([placement="embedded"]) .drawer-panel {
|
|
827
|
+
width: 100%;
|
|
828
|
+
border-right: none;
|
|
829
|
+
box-shadow: none;
|
|
830
|
+
}
|
|
634
831
|
`;
|