@artooi/ag-ui-web-component 0.4.0 → 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.
@@ -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,
@@ -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
@@ -644,4 +644,188 @@ export const STYLES = `
644
644
  font-size: 0.85em;
645
645
  color: var(--ag-ui-danger);
646
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
+ }
647
831
  `;
@@ -0,0 +1,200 @@
1
+ import type { ThreadMeta } from "../core/conversation_store.js";
2
+ import { relativeTime } from "./relative_time.js";
3
+
4
+ /** Actions the host ({@link AgUiChat}) wires to the drawer's rows. */
5
+ export interface ThreadDrawerCallbacks {
6
+ /** A row was picked — load that thread and make it active. */
7
+ readonly onSelect: (threadId: string) => void;
8
+ /** The "New chat" action — start a fresh thread. */
9
+ readonly onNew: () => void;
10
+ /** A row was renamed to `title`. */
11
+ readonly onRename: (threadId: string, title: string) => void;
12
+ /** A row was deleted (after the inline confirm). */
13
+ readonly onDelete: (threadId: string) => void;
14
+ }
15
+
16
+ /**
17
+ * The chat-history drawer: a slide-over listing the user's threads (title,
18
+ * relative time, preview), with select / new / rename / delete actions and an
19
+ * empty state. Pure DOM in the spirit of {@link SkillsMenu} — the host appends
20
+ * {@link element}, toggles it, feeds rows via {@link setThreads}, and acts on
21
+ * the callbacks. The drawer is a *view*: it does not mutate the store; after a
22
+ * callback the host updates the store and calls {@link setThreads} to refresh.
23
+ */
24
+ export class ThreadDrawer {
25
+ /** The drawer root (backdrop + panel). Append to the chat shell; hidden until opened. */
26
+ readonly element: HTMLDivElement;
27
+
28
+ readonly #callbacks: ThreadDrawerCallbacks;
29
+ readonly #list: HTMLDivElement;
30
+ #threads: readonly ThreadMeta[] = [];
31
+ #activeId = "";
32
+
33
+ constructor(callbacks: ThreadDrawerCallbacks) {
34
+ this.#callbacks = callbacks;
35
+
36
+ this.element = document.createElement("div");
37
+ this.element.className = "drawer";
38
+ this.element.hidden = true;
39
+
40
+ const backdrop = document.createElement("div");
41
+ backdrop.className = "drawer-backdrop";
42
+ backdrop.addEventListener("click", () => this.close());
43
+
44
+ const panel = document.createElement("div");
45
+ panel.className = "drawer-panel";
46
+ panel.setAttribute("role", "dialog");
47
+ panel.setAttribute("aria-label", "Chat history");
48
+
49
+ const header = document.createElement("div");
50
+ 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", () => {
59
+ this.close();
60
+ this.#callbacks.onNew();
61
+ });
62
+ header.append(heading, newButton);
63
+
64
+ this.#list = document.createElement("div");
65
+ this.#list.className = "drawer-list";
66
+
67
+ panel.append(header, this.#list);
68
+ this.element.append(backdrop, panel);
69
+ }
70
+
71
+ isOpen(): boolean {
72
+ return !this.element.hidden;
73
+ }
74
+
75
+ open(): void {
76
+ this.element.hidden = false;
77
+ }
78
+
79
+ close(): void {
80
+ this.element.hidden = true;
81
+ }
82
+
83
+ toggle(): void {
84
+ this.element.hidden = !this.element.hidden;
85
+ }
86
+
87
+ /** Render the rows (or the empty state), highlighting the active thread. */
88
+ setThreads(threads: readonly ThreadMeta[], activeId: string): void {
89
+ this.#threads = threads;
90
+ this.#activeId = activeId;
91
+ this.#renderList();
92
+ }
93
+
94
+ #renderList(): void {
95
+ this.#list.replaceChildren();
96
+ if (this.#threads.length === 0) {
97
+ const empty = document.createElement("div");
98
+ empty.className = "drawer-empty";
99
+ empty.textContent = "No conversations yet.";
100
+ this.#list.appendChild(empty);
101
+ return;
102
+ }
103
+ for (const meta of this.#threads) {
104
+ this.#list.appendChild(this.#renderRow(meta));
105
+ }
106
+ }
107
+
108
+ #renderRow(meta: ThreadMeta): HTMLDivElement {
109
+ const row = document.createElement("div");
110
+ row.className = "drawer-row";
111
+ if (meta.threadId === this.#activeId) {
112
+ row.classList.add("drawer-row--active");
113
+ }
114
+
115
+ const select = document.createElement("button");
116
+ select.type = "button";
117
+ select.className = "drawer-row-select";
118
+ const title = document.createElement("span");
119
+ title.className = "drawer-row-title";
120
+ title.textContent = meta.title;
121
+ const time = document.createElement("span");
122
+ time.className = "drawer-row-time";
123
+ time.textContent = relativeTime(meta.updatedAt);
124
+ const preview = document.createElement("span");
125
+ preview.className = "drawer-row-preview";
126
+ preview.textContent = meta.preview;
127
+ select.append(title, time, preview);
128
+ select.addEventListener("click", () => {
129
+ this.close();
130
+ this.#callbacks.onSelect(meta.threadId);
131
+ });
132
+
133
+ const rename = document.createElement("button");
134
+ rename.type = "button";
135
+ rename.className = "drawer-row-rename";
136
+ rename.title = "Rename";
137
+ rename.setAttribute("aria-label", "Rename conversation");
138
+ rename.textContent = "✎";
139
+ rename.addEventListener("click", () => this.#startRename(row, meta));
140
+
141
+ const remove = document.createElement("button");
142
+ remove.type = "button";
143
+ remove.className = "drawer-row-delete";
144
+ remove.title = "Delete";
145
+ remove.setAttribute("aria-label", "Delete conversation");
146
+ remove.textContent = "🗑";
147
+ remove.addEventListener("click", () => this.#confirmDelete(row, meta));
148
+
149
+ const actions = document.createElement("div");
150
+ actions.className = "drawer-row-actions";
151
+ actions.append(rename, remove);
152
+
153
+ row.append(select, actions);
154
+ return row;
155
+ }
156
+
157
+ /** Swap a row for an inline rename input; Enter commits, Escape cancels. */
158
+ #startRename(row: HTMLDivElement, meta: ThreadMeta): void {
159
+ const input = document.createElement("input");
160
+ input.type = "text";
161
+ input.className = "drawer-rename-input";
162
+ input.value = meta.title;
163
+ input.addEventListener("keydown", (event) => {
164
+ if (event.key === "Enter") {
165
+ const value = input.value.trim();
166
+ if (value === "") {
167
+ this.#renderList();
168
+ } else {
169
+ this.#callbacks.onRename(meta.threadId, value);
170
+ }
171
+ } else if (event.key === "Escape") {
172
+ this.#renderList();
173
+ }
174
+ });
175
+ row.replaceChildren(input);
176
+ input.focus();
177
+ input.select();
178
+ }
179
+
180
+ /** Swap a row for an inline "Delete? [Delete] [Cancel]" confirm. */
181
+ #confirmDelete(row: HTMLDivElement, meta: ThreadMeta): void {
182
+ const confirm = document.createElement("div");
183
+ confirm.className = "drawer-confirm";
184
+ const label = document.createElement("span");
185
+ label.className = "drawer-confirm-label";
186
+ label.textContent = "Delete?";
187
+ const yes = document.createElement("button");
188
+ yes.type = "button";
189
+ yes.className = "drawer-confirm-yes";
190
+ yes.textContent = "Delete";
191
+ yes.addEventListener("click", () => this.#callbacks.onDelete(meta.threadId));
192
+ const no = document.createElement("button");
193
+ no.type = "button";
194
+ no.className = "drawer-confirm-no";
195
+ no.textContent = "Cancel";
196
+ no.addEventListener("click", () => this.#renderList());
197
+ confirm.append(label, yes, no);
198
+ row.replaceChildren(confirm);
199
+ }
200
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.4.0";
1
+ export const VERSION: string = "0.5.0";