@artooi/ag-ui-web-component 0.23.1 → 0.25.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 +194 -1
- package/README.md +188 -38
- package/dist/ag-ui-web-component.bundle.js +189 -49
- package/dist/ag-ui-web-component.bundle.js.map +3 -3
- package/dist/constants.d.ts +25 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +24 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +18 -0
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +2 -0
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/run_index.d.ts +10 -0
- package/dist/core/run_index.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +335 -44
- package/dist/index.js.map +2 -2
- package/dist/ui/checkpoint_menu.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/tool_call_card.d.ts +26 -2
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +2 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +26 -0
- package/src/core/ag_ui_chat.ts +186 -40
- package/src/core/conversation_store.ts +30 -0
- package/src/core/remote_conversation_store.ts +14 -0
- package/src/core/run_index.ts +10 -0
- package/src/index.ts +3 -0
- package/src/ui/checkpoint_menu.ts +32 -7
- package/src/ui/styles.ts +153 -13
- package/src/ui/tool_call_card.ts +41 -3
- package/src/ui/ui_strings.ts +3 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
LOAD_CAPABILITY_TOOL,
|
|
11
11
|
MESSAGE_ROLE,
|
|
12
12
|
READ_PAGE_TOOL,
|
|
13
|
+
RUN_FINISHED_EVENT,
|
|
13
14
|
STATE_EVENT,
|
|
14
15
|
SUBMIT_EVENT,
|
|
15
16
|
TOGGLE_EVENT,
|
|
@@ -105,6 +106,23 @@ export interface StateDetail {
|
|
|
105
106
|
readonly state: Readonly<Record<string, unknown>>;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/** One tool that ran during an interaction, as {@link RunFinishedDetail} lists it. */
|
|
110
|
+
export interface ToolRun {
|
|
111
|
+
readonly name: string;
|
|
112
|
+
/**
|
|
113
|
+
* Where it executed. `"server"` is the one a data-rendering host cares about:
|
|
114
|
+
* a `"client"` tool ran in the host's own handler, so the host already knows
|
|
115
|
+
* whatever it did.
|
|
116
|
+
*/
|
|
117
|
+
readonly side: "server" | "client";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `detail` shape of the {@link RUN_FINISHED_EVENT} CustomEvent. */
|
|
121
|
+
export interface RunFinishedDetail {
|
|
122
|
+
/** In settle order. Empty when the interaction called no tools. */
|
|
123
|
+
readonly tools: readonly ToolRun[];
|
|
124
|
+
}
|
|
125
|
+
|
|
108
126
|
/** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
|
|
109
127
|
export interface ToggleDetail {
|
|
110
128
|
readonly collapsed: boolean;
|
|
@@ -375,6 +393,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
375
393
|
* the real output with the generic "executed on the server" fallback.
|
|
376
394
|
*/
|
|
377
395
|
readonly #serverSettled = new Set<string>();
|
|
396
|
+
/**
|
|
397
|
+
* Tool calls made during the current interaction, in the order they started,
|
|
398
|
+
* so {@link RUN_FINISHED_EVENT} can report them once the whole thing settles.
|
|
399
|
+
* Spans tool rounds and an approval interrupt; cleared when the event fires.
|
|
400
|
+
*/
|
|
401
|
+
#runTools: { readonly id: string; readonly name: string }[] = [];
|
|
378
402
|
readonly #root: ShadowRoot;
|
|
379
403
|
readonly #chat: HTMLDivElement;
|
|
380
404
|
readonly #messages: HTMLDivElement;
|
|
@@ -1572,6 +1596,11 @@ export class AgUiChat extends HTMLElement {
|
|
|
1572
1596
|
* {@link toggleCollapsed} and {@link toggleTheme}.
|
|
1573
1597
|
*/
|
|
1574
1598
|
openThreads(): void {
|
|
1599
|
+
// Two overlapping surfaces, so opening one dismisses the other. Clicking away
|
|
1600
|
+
// already covers the built-in buttons, but a host driving its own chrome
|
|
1601
|
+
// through these methods raises no pointer event — and the drawer would then
|
|
1602
|
+
// open *underneath* a popover still floating over it.
|
|
1603
|
+
this.#checkpoints.close();
|
|
1575
1604
|
void this.#refreshDrawer();
|
|
1576
1605
|
this.#drawer.open();
|
|
1577
1606
|
}
|
|
@@ -1584,10 +1613,31 @@ export class AgUiChat extends HTMLElement {
|
|
|
1584
1613
|
* an empty panel.
|
|
1585
1614
|
*/
|
|
1586
1615
|
openCheckpoints(): void {
|
|
1616
|
+
// The other half of the pair — see `openThreads`.
|
|
1617
|
+
this.#drawer.close();
|
|
1587
1618
|
void this.#refreshCheckpoints();
|
|
1588
1619
|
this.#checkpoints.open();
|
|
1589
1620
|
}
|
|
1590
1621
|
|
|
1622
|
+
/** Close the checkpoints panel, if it is open. */
|
|
1623
|
+
closeCheckpoints(): void {
|
|
1624
|
+
this.#checkpoints.close();
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* Open the checkpoints panel, or close it if it is already open — what the
|
|
1629
|
+
* built-in ⭯ button does, because a control that opens a panel is read as the
|
|
1630
|
+
* control that also dismisses it. {@link openCheckpoints} stays open-only for a
|
|
1631
|
+
* host that means exactly that.
|
|
1632
|
+
*/
|
|
1633
|
+
toggleCheckpoints(): void {
|
|
1634
|
+
if (this.#checkpoints.open_) {
|
|
1635
|
+
this.#checkpoints.close();
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
this.openCheckpoints();
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1591
1641
|
/**
|
|
1592
1642
|
* Start a fresh conversation: forget the persisted history, drop the
|
|
1593
1643
|
* in-memory run state, clear the transcript, and mint a new thread id.
|
|
@@ -1909,8 +1959,11 @@ export class AgUiChat extends HTMLElement {
|
|
|
1909
1959
|
const history = this.#headerButton("history", this.#strings.chatHistory, "☰");
|
|
1910
1960
|
history.addEventListener("click", () => this.openThreads());
|
|
1911
1961
|
|
|
1912
|
-
|
|
1913
|
-
|
|
1962
|
+
// ↺ rather than ⭯: the same idea in a glyph that has a font behind it in
|
|
1963
|
+
// every browser. The obscure one rendered as an unreadable mark at 14px, and a
|
|
1964
|
+
// header control nobody can name is one nobody presses.
|
|
1965
|
+
const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "↺");
|
|
1966
|
+
checkpoints.addEventListener("click", () => this.toggleCheckpoints());
|
|
1914
1967
|
|
|
1915
1968
|
const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
|
|
1916
1969
|
newChat.addEventListener("click", () => this.newChat());
|
|
@@ -2053,6 +2106,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
2053
2106
|
this.#checkpoints.element,
|
|
2054
2107
|
);
|
|
2055
2108
|
|
|
2109
|
+
// Clicking away dismisses the checkpoints popover. Escape already did, and the
|
|
2110
|
+
// drawer has a backdrop that swallows the click — this popover has neither, so
|
|
2111
|
+
// it could only be closed by answering it.
|
|
2112
|
+
//
|
|
2113
|
+
// `pointerdown`, and the header button excluded: pointerdown runs *before* the
|
|
2114
|
+
// button's own click, so closing here and toggling there would land back open.
|
|
2115
|
+
// Composed path rather than `target`, because the event is retargeted at the
|
|
2116
|
+
// shadow boundary and every one of these nodes is inside it.
|
|
2117
|
+
this.#chat.addEventListener("pointerdown", (event) => {
|
|
2118
|
+
if (!this.#checkpoints.open_) {
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
const path = event.composedPath();
|
|
2122
|
+
if (path.includes(this.#checkpoints.element) || path.includes(checkpoints)) {
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
this.#checkpoints.close();
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2056
2128
|
// What a collapsed widget shrinks to: a round floating button, or the slim
|
|
2057
2129
|
// edge rail under `placement="sidebar"` — one element, shaped by CSS.
|
|
2058
2130
|
// A sibling of the panel, so it survives the panel being hidden.
|
|
@@ -2566,55 +2638,82 @@ export class AgUiChat extends HTMLElement {
|
|
|
2566
2638
|
* Render an approval card per server-side-tool interrupt and collect the
|
|
2567
2639
|
* user's decisions (approve → run it, deny → decline it).
|
|
2568
2640
|
*
|
|
2641
|
+
* **One card per gated call, in that call's own tool card, all at once.** A run
|
|
2642
|
+
* can defer several calls, and the wire answers each independently — so the UI
|
|
2643
|
+
* has to let a person answer each independently, which means saying which is
|
|
2644
|
+
* which. The prompt cannot: it comes from the tool's `x-confirm` and is
|
|
2645
|
+
* identical for every call of that tool. The tool card can, by position, and it
|
|
2646
|
+
* is already showing the arguments. Asking them serially was the other half of
|
|
2647
|
+
* the problem: the second question only appeared once the first was answered,
|
|
2648
|
+
* so a person could neither compare them nor tell that more were coming.
|
|
2649
|
+
*
|
|
2650
|
+
* Each gated card is marked `deferred` for the wait. That is not cosmetic — at
|
|
2651
|
+
* `pending` it read "running…" while the stream was over and the server idle.
|
|
2652
|
+
*
|
|
2569
2653
|
* The run is suspended on these cards. A Stop while any is open aborts the
|
|
2570
2654
|
* shared {@link #confirmAbort} controller, resolving every still-open card as
|
|
2571
2655
|
* denied. An approved tool runs on the follow-up resume run and streams its
|
|
2572
|
-
* result into the same
|
|
2573
|
-
* result will ever arrive.
|
|
2656
|
+
* result into the same card (returned to `pending`, since it now really is
|
|
2657
|
+
* running); a denied one settles here, as no result will ever arrive.
|
|
2574
2658
|
*/
|
|
2575
2659
|
async #resolveInterrupts(
|
|
2576
2660
|
interrupts: readonly Interrupt[],
|
|
2577
2661
|
): Promise<Record<string, InterruptResponse>> {
|
|
2578
|
-
const responses: Record<string, InterruptResponse> = {};
|
|
2579
2662
|
// One controller covers the whole batch: a single Stop denies all of them.
|
|
2580
2663
|
this.#confirmAbort = new AbortController();
|
|
2581
2664
|
this.#hidePending();
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
//
|
|
2613
|
-
//
|
|
2614
|
-
card?.
|
|
2615
|
-
|
|
2616
|
-
|
|
2665
|
+
const signal = this.#confirmAbort.signal;
|
|
2666
|
+
const answered = await Promise.all(
|
|
2667
|
+
interrupts.map(async (interrupt) => {
|
|
2668
|
+
const card =
|
|
2669
|
+
interrupt.toolCallId !== undefined
|
|
2670
|
+
? this.#toolCards.get(interrupt.toolCallId)
|
|
2671
|
+
: undefined;
|
|
2672
|
+
const request: ApprovalRequest = {};
|
|
2673
|
+
const phrase = confirmPhrase(interrupt) ?? interrupt.message;
|
|
2674
|
+
if (phrase !== undefined) {
|
|
2675
|
+
request.message = phrase;
|
|
2676
|
+
}
|
|
2677
|
+
const toolName = card?.element.getAttribute("data-tool-name");
|
|
2678
|
+
if (toolName !== null && toolName !== undefined) {
|
|
2679
|
+
request.toolName = toolName;
|
|
2680
|
+
}
|
|
2681
|
+
card?.mark(TOOL_CALL_STATUS.DEFERRED);
|
|
2682
|
+
// A host-supplied renderer takes full control of the approval UI. The
|
|
2683
|
+
// built-in card renders into the gated call's own card, falling back to
|
|
2684
|
+
// the answer group when the interrupt names no call we hold one for.
|
|
2685
|
+
const approved =
|
|
2686
|
+
this.approvalRenderer !== null
|
|
2687
|
+
? await this.approvalRenderer(request, { signal })
|
|
2688
|
+
: await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
|
|
2689
|
+
signal,
|
|
2690
|
+
strings: this.#strings,
|
|
2691
|
+
});
|
|
2692
|
+
// Same annotation as the client-side confirmation gate. Without it the
|
|
2693
|
+
// two gates read differently for the same act: a locally-confirmed call
|
|
2694
|
+
// said who let it through and a server-gated one said nothing, which is
|
|
2695
|
+
// backwards, since the server-side gate is the one guarding the tools
|
|
2696
|
+
// that actually run on the backend.
|
|
2697
|
+
card?.recordDecision(approved ? "approved" : "declined");
|
|
2698
|
+
if (approved) {
|
|
2699
|
+
card?.mark(TOOL_CALL_STATUS.PENDING);
|
|
2700
|
+
} else {
|
|
2701
|
+
// No TOOL_CALL_RESULT will stream for a denied tool — settle its card
|
|
2702
|
+
// now rather than leaving it hanging until the onSettled sweep.
|
|
2703
|
+
card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
|
|
2704
|
+
}
|
|
2705
|
+
return { id: interrupt.id, approved };
|
|
2706
|
+
}),
|
|
2707
|
+
);
|
|
2708
|
+
this.#updateEmptyState();
|
|
2709
|
+
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
2617
2710
|
this.#confirmAbort = null;
|
|
2711
|
+
const responses: Record<string, InterruptResponse> = {};
|
|
2712
|
+
for (const { id, approved } of answered) {
|
|
2713
|
+
responses[id] = approved
|
|
2714
|
+
? { status: "resolved", payload: { approved: true } }
|
|
2715
|
+
: { status: "cancelled" };
|
|
2716
|
+
}
|
|
2618
2717
|
return responses;
|
|
2619
2718
|
}
|
|
2620
2719
|
|
|
@@ -2671,6 +2770,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
2671
2770
|
if (this.#noticeIfSkillLoad(call)) {
|
|
2672
2771
|
return;
|
|
2673
2772
|
}
|
|
2773
|
+
// Recorded after the skill-load return: a capability load is the agent
|
|
2774
|
+
// arranging itself, not work a host's data could have moved under.
|
|
2775
|
+
this.#runTools.push({ id: call.id, name: call.name });
|
|
2674
2776
|
this.#cardFor(call);
|
|
2675
2777
|
},
|
|
2676
2778
|
onActivity: (activityType, content) => {
|
|
@@ -2735,10 +2837,35 @@ export class AgUiChat extends HTMLElement {
|
|
|
2735
2837
|
}
|
|
2736
2838
|
this.#currentGroup = null;
|
|
2737
2839
|
this.#thoughts = null;
|
|
2840
|
+
this.#dispatchRunFinished();
|
|
2738
2841
|
},
|
|
2739
2842
|
};
|
|
2740
2843
|
}
|
|
2741
2844
|
|
|
2845
|
+
/**
|
|
2846
|
+
* Tell the host the interaction is over and what ran in it.
|
|
2847
|
+
*
|
|
2848
|
+
* Last thing in `onSettled`, so a listener that refetches sees a transcript
|
|
2849
|
+
* that has already stopped changing. `side` is read from the streamed-result
|
|
2850
|
+
* bookkeeping rather than from the tool list: whether a call executed on the
|
|
2851
|
+
* server is a fact about the run, and a name can appear on both sides across a
|
|
2852
|
+
* conversation.
|
|
2853
|
+
*/
|
|
2854
|
+
#dispatchRunFinished(): void {
|
|
2855
|
+
const tools: ToolRun[] = this.#runTools.map(({ id, name }) => ({
|
|
2856
|
+
name,
|
|
2857
|
+
side: this.#serverSettled.has(id) ? "server" : "client",
|
|
2858
|
+
}));
|
|
2859
|
+
this.#runTools = [];
|
|
2860
|
+
this.dispatchEvent(
|
|
2861
|
+
new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
|
|
2862
|
+
detail: { tools },
|
|
2863
|
+
bubbles: true,
|
|
2864
|
+
composed: true,
|
|
2865
|
+
}),
|
|
2866
|
+
);
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2742
2869
|
/** A muted "⏹ Stopped" line in the transcript (distinct from the ⚠️ error bubble). */
|
|
2743
2870
|
#appendStoppedNote(): void {
|
|
2744
2871
|
const note = document.createElement("div");
|
|
@@ -2866,6 +2993,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
2866
2993
|
}
|
|
2867
2994
|
}
|
|
2868
2995
|
|
|
2996
|
+
/**
|
|
2997
|
+
* A server-authored question for a gated call, read off the interrupt's metadata.
|
|
2998
|
+
*
|
|
2999
|
+
* The question an AG-UI interrupt carries by default is the call itself, spelled
|
|
3000
|
+
* out: `Approve create_event({"title": "Design sync", …})?`. Accurate, and not
|
|
3001
|
+
* something to put in front of a person. A client-side confirmation has
|
|
3002
|
+
* `x-confirm` on the tool's schema for exactly this, so the same key is read here
|
|
3003
|
+
* — whichever end gates a call, the phrase comes from one place, and a server
|
|
3004
|
+
* that supplies none keeps the generated text.
|
|
3005
|
+
*
|
|
3006
|
+
* Narrowed rather than trusted: `metadata` is `Record<string, any>` on the wire,
|
|
3007
|
+
* so anything at all can arrive under that key, and a non-string would render as
|
|
3008
|
+
* "[object Object]" in the one place a person is being asked to allow a write.
|
|
3009
|
+
*/
|
|
3010
|
+
function confirmPhrase(interrupt: Interrupt): string | undefined {
|
|
3011
|
+
const phrase = interrupt.metadata?.[X_CONFIRM_KEY];
|
|
3012
|
+
return typeof phrase === "string" && phrase.trim() !== "" ? phrase : undefined;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
2869
3015
|
/** One tool call as a restored assistant message carries it. */
|
|
2870
3016
|
interface RestoredToolCall {
|
|
2871
3017
|
readonly id: string;
|
|
@@ -58,6 +58,23 @@ export interface ClientConversationStore {
|
|
|
58
58
|
setActiveThread(threadId: string): void;
|
|
59
59
|
/** Set a thread's display title (the drawer renaming a row). */
|
|
60
60
|
renameThread(threadId: string, title: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* Whether `threadId` was minted here and has never been saved — a thread in
|
|
63
|
+
* which nothing has been sent yet.
|
|
64
|
+
*
|
|
65
|
+
* Exists so a server-backed store can skip asking a server for history that
|
|
66
|
+
* cannot be there: the element mints an id on first mount and immediately tries
|
|
67
|
+
* to restore it, which answers `404` and logs one in the console on every first
|
|
68
|
+
* visit. Nothing is wrong, and it looks exactly like something being wrong.
|
|
69
|
+
*
|
|
70
|
+
* Optional, and deliberately narrow. A store that cannot tell omits it and the
|
|
71
|
+
* fetch happens as before, which is also the right answer for a store that
|
|
72
|
+
* holds nothing locally: "I have no messages for this id" is not the same claim
|
|
73
|
+
* as "this id is new", and only the store that minted the id can make the
|
|
74
|
+
* second one. A thread picked from the drawer was never minted here, so it is
|
|
75
|
+
* still fetched.
|
|
76
|
+
*/
|
|
77
|
+
isUnsent?(threadId: string): boolean;
|
|
61
78
|
}
|
|
62
79
|
|
|
63
80
|
const KEY_ROOT = "ag-ui-chat";
|
|
@@ -65,6 +82,9 @@ const THREAD_SUFFIX = "thread";
|
|
|
65
82
|
const THREADS_SUFFIX = "threads";
|
|
66
83
|
const MESSAGES_SUFFIX = "messages:";
|
|
67
84
|
const CHECKPOINT_SUFFIX = "checkpoint:";
|
|
85
|
+
// Marks an id this store minted and nothing has been sent in yet. Dropped on
|
|
86
|
+
// the first save, so it never outlives the one question it answers.
|
|
87
|
+
const MINTED_SUFFIX = "minted:";
|
|
68
88
|
|
|
69
89
|
const TITLE_LIMIT = 60;
|
|
70
90
|
const PREVIEW_LIMIT = 100;
|
|
@@ -110,15 +130,24 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
110
130
|
}
|
|
111
131
|
const id = randomUUID();
|
|
112
132
|
sessionStorage.setItem(key, id);
|
|
133
|
+
sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
|
|
113
134
|
return id;
|
|
114
135
|
}
|
|
115
136
|
|
|
137
|
+
isUnsent(threadId: string): boolean {
|
|
138
|
+
return (
|
|
139
|
+
sessionStorage.getItem(this.#key(MINTED_SUFFIX + threadId)) !== null &&
|
|
140
|
+
sessionStorage.getItem(this.#key(MESSAGES_SUFFIX + threadId)) === null
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
loadMessages(threadId: string): Promise<readonly Message[] | null> {
|
|
117
145
|
return Promise.resolve(this.#readJson<Message[]>(this.#key(MESSAGES_SUFFIX + threadId)));
|
|
118
146
|
}
|
|
119
147
|
|
|
120
148
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
121
149
|
sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
|
|
150
|
+
sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
|
|
122
151
|
this.#touchThread(threadId, messages);
|
|
123
152
|
}
|
|
124
153
|
|
|
@@ -138,6 +167,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
138
167
|
clear(threadId: string): void {
|
|
139
168
|
sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
|
|
140
169
|
sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
|
|
170
|
+
sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
|
|
141
171
|
this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
|
|
142
172
|
// Only drop the active pointer when the active thread itself is cleared, so
|
|
143
173
|
// the next `threadId()` mints a fresh one. Deleting another thread from the
|
|
@@ -69,6 +69,11 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
69
69
|
this.#local.setActiveThread(threadId);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** Delegated, so wrapping a store does not lose what it knows about its own ids. */
|
|
73
|
+
isUnsent(threadId: string): boolean {
|
|
74
|
+
return this.#local.isUnsent?.(threadId) === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
72
77
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
73
78
|
// The agent run persists server-side; keep a local cache for offline replay.
|
|
74
79
|
this.#local.saveMessages(threadId, messages);
|
|
@@ -103,6 +108,15 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
async loadMessages(threadId: string): Promise<readonly Message[] | null> {
|
|
111
|
+
// Don't ask the server for a thread it cannot have. The element mints an id
|
|
112
|
+
// on first mount and immediately tries to restore it, so every first visit
|
|
113
|
+
// spent a request to be told `404` — and logged one in the console, on a page
|
|
114
|
+
// where nothing had gone wrong. Only the store that minted the id can say
|
|
115
|
+
// that; a thread chosen from the drawer, or one created on another device, is
|
|
116
|
+
// still fetched. See `ClientConversationStore.isUnsent`.
|
|
117
|
+
if (this.#local.isUnsent?.(threadId) === true) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
106
120
|
const response = await this.#get(`${this.#url}${encodeURIComponent(threadId)}/`);
|
|
107
121
|
if (response === null || !response.ok) {
|
|
108
122
|
return this.#local.loadMessages(threadId);
|
package/src/core/run_index.ts
CHANGED
|
@@ -8,6 +8,16 @@ export interface RunRow {
|
|
|
8
8
|
readonly started_at: string | null;
|
|
9
9
|
/** Whether the run has a snapshot to seed from — see {@link RunIndex}. */
|
|
10
10
|
readonly continuable: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* The run's first user message, one line, already truncated by the server —
|
|
13
|
+
* the only field in a row a person recognises a conversation by.
|
|
14
|
+
*
|
|
15
|
+
* Optional because a server predating the field does not send it, and
|
|
16
|
+
* `null` where the run holds no words to show (seeded from history alone, or
|
|
17
|
+
* opened with an image and no caption). A row without one falls back to the
|
|
18
|
+
* time plus a short id, which is what every row used to be.
|
|
19
|
+
*/
|
|
20
|
+
readonly preview?: string | null;
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
/** Live header source, read per request so rotated tokens / CSRF reach the server. */
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
LOAD_CAPABILITY_TOOL,
|
|
8
8
|
MAX_TOOL_ROUNDS,
|
|
9
9
|
MESSAGE_ROLE,
|
|
10
|
+
RUN_FINISHED_EVENT,
|
|
10
11
|
STATE_EVENT,
|
|
11
12
|
SUBMIT_EVENT,
|
|
12
13
|
TOGGLE_EVENT,
|
|
@@ -22,9 +23,11 @@ export {
|
|
|
22
23
|
AgUiChat,
|
|
23
24
|
type AttachmentsDetail,
|
|
24
25
|
type MessageRole,
|
|
26
|
+
type RunFinishedDetail,
|
|
25
27
|
type StateDetail,
|
|
26
28
|
type SubmitDetail,
|
|
27
29
|
type ToggleDetail,
|
|
30
|
+
type ToolRun,
|
|
28
31
|
type UnreadDetail,
|
|
29
32
|
} from "./core/ag_ui_chat.js";
|
|
30
33
|
export {
|
|
@@ -161,18 +161,43 @@ export class CheckpointMenu {
|
|
|
161
161
|
row.className = "checkpoint-row";
|
|
162
162
|
row.setAttribute("part", "checkpoint-row");
|
|
163
163
|
|
|
164
|
+
const preview =
|
|
165
|
+
run.preview !== undefined && run.preview !== null && run.preview !== "" ? run.preview : null;
|
|
166
|
+
const time =
|
|
167
|
+
run.started_at === null
|
|
168
|
+
? null
|
|
169
|
+
: relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
|
|
170
|
+
|
|
164
171
|
const label = document.createElement("span");
|
|
165
172
|
label.className = "checkpoint-label";
|
|
166
173
|
label.setAttribute("part", "checkpoint-label");
|
|
167
|
-
//
|
|
168
|
-
// the id
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
? run.run_id
|
|
172
|
-
: relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
|
|
173
|
-
label.title = run.run_id;
|
|
174
|
+
// What the run was about, if the server says. Otherwise the time, and only
|
|
175
|
+
// then the id — a person recognises the first, reads the second, and
|
|
176
|
+
// recognises nothing at all in the third.
|
|
177
|
+
label.textContent = preview ?? time ?? run.run_id;
|
|
174
178
|
row.append(label);
|
|
175
179
|
|
|
180
|
+
if (preview !== null && time !== null) {
|
|
181
|
+
// Demoted to a chip: with words in the label the time is no longer what
|
|
182
|
+
// identifies the run, but it still orders it.
|
|
183
|
+
const when = document.createElement("span");
|
|
184
|
+
when.className = "checkpoint-time";
|
|
185
|
+
when.setAttribute("part", "checkpoint-time");
|
|
186
|
+
when.textContent = time;
|
|
187
|
+
row.append(when);
|
|
188
|
+
} else if (run.started_at !== null) {
|
|
189
|
+
// No words to show, so the id has to do the identifying: two runs a few
|
|
190
|
+
// seconds apart both read "just now", and picking between them is picking
|
|
191
|
+
// blind. Shown rather than left in a tooltip, since a hover is not an
|
|
192
|
+
// identity either — and eight characters beats a full id on the row.
|
|
193
|
+
const short = document.createElement("span");
|
|
194
|
+
short.className = "checkpoint-id";
|
|
195
|
+
short.setAttribute("part", "checkpoint-id");
|
|
196
|
+
short.textContent = run.run_id.slice(0, 8);
|
|
197
|
+
short.title = run.run_id;
|
|
198
|
+
row.append(short);
|
|
199
|
+
}
|
|
200
|
+
|
|
176
201
|
if (run.parent_run_id !== null) {
|
|
177
202
|
// Lineage, so a branch doesn't read as a duplicate of its parent.
|
|
178
203
|
const branch = document.createElement("span");
|