@artooi/ag-ui-web-component 0.28.0 → 0.30.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 +615 -1
- package/README.md +564 -35
- package/dist/ag-ui-web-component.bundle.js +491 -50
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +129 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +232 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +56 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2081 -98
- package/dist/index.js.map +4 -4
- package/dist/ui/approval_card.d.ts +18 -0
- package/dist/ui/approval_card.d.ts.map +1 -1
- package/dist/ui/checkpoint_menu.d.ts +10 -0
- package/dist/ui/checkpoint_menu.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +16 -0
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/message_actions.d.ts +56 -0
- package/dist/ui/message_actions.d.ts.map +1 -0
- package/dist/ui/page_quote_offer.d.ts +33 -0
- package/dist/ui/page_quote_offer.d.ts.map +1 -0
- package/dist/ui/quote_selection.d.ts +66 -0
- package/dist/ui/quote_selection.d.ts.map +1 -0
- package/dist/ui/relative_time.d.ts +10 -0
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/stick_to_bottom.d.ts +55 -0
- package/dist/ui/stick_to_bottom.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/subagent_panel.d.ts +92 -0
- package/dist/ui/subagent_panel.d.ts.map +1 -0
- package/dist/ui/subagent_update.d.ts +19 -0
- package/dist/ui/subagent_update.d.ts.map +1 -0
- package/dist/ui/suggestion_chips.d.ts +29 -0
- package/dist/ui/suggestion_chips.d.ts.map +1 -0
- package/dist/ui/thread_drawer.d.ts +10 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +81 -1
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +50 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +138 -1
- package/src/core/ag_ui_chat.ts +1081 -73
- package/src/core/agui_client.ts +89 -2
- package/src/index.ts +43 -0
- package/src/ui/approval_card.ts +90 -2
- package/src/ui/checkpoint_menu.ts +22 -5
- package/src/ui/confirmation_card.ts +29 -1
- package/src/ui/message_actions.ts +170 -0
- package/src/ui/page_quote_offer.ts +215 -0
- package/src/ui/quote_selection.ts +345 -0
- package/src/ui/relative_time.ts +11 -0
- package/src/ui/stick_to_bottom.ts +126 -0
- package/src/ui/styles.ts +410 -0
- package/src/ui/subagent_panel.ts +213 -0
- package/src/ui/subagent_update.ts +80 -0
- package/src/ui/suggestion_chips.ts +73 -0
- package/src/ui/thread_drawer.ts +22 -2
- package/src/ui/tool_call_card.ts +138 -3
- package/src/ui/ui_strings.ts +75 -0
- package/src/version.ts +1 -1
package/src/core/agui_client.ts
CHANGED
|
@@ -109,6 +109,24 @@ export interface AgUiClientHandlers {
|
|
|
109
109
|
/** Fired when the reasoning block ends (before the answer text streams). */
|
|
110
110
|
onReasoningEnd(): void;
|
|
111
111
|
onRunEnd(): void;
|
|
112
|
+
/**
|
|
113
|
+
* The server replaced the whole conversation with `MESSAGES_SNAPSHOT`.
|
|
114
|
+
*
|
|
115
|
+
* `@ag-ui/client` applies the event before any subscriber sees it, so by the
|
|
116
|
+
* time this fires `agent.messages` **is** the server's list -- and the run
|
|
117
|
+
* loop persists `agent.messages`. The replacement therefore reaches the
|
|
118
|
+
* conversation store whatever the host does; this hook exists so the host can
|
|
119
|
+
* stop that being invisible.
|
|
120
|
+
*/
|
|
121
|
+
onMessagesSnapshot(messages: readonly Message[]): void;
|
|
122
|
+
/**
|
|
123
|
+
* The agent sent a `CUSTOM` event.
|
|
124
|
+
*
|
|
125
|
+
* Forwarded whole and uninterpreted: `name` is an open string the protocol
|
|
126
|
+
* does not enumerate, so a client that decided which names were legal would
|
|
127
|
+
* be the thing the open field exists to avoid.
|
|
128
|
+
*/
|
|
129
|
+
onCustomEvent(name: string, value: unknown): void;
|
|
112
130
|
onError(message: string): void;
|
|
113
131
|
/**
|
|
114
132
|
* Fired when the user cancelled the run ({@link AgUiClient.cancel}) — the
|
|
@@ -164,6 +182,22 @@ export interface AgUiClientConfig extends AgUiRunInputs {
|
|
|
164
182
|
* passes its localized string.
|
|
165
183
|
*/
|
|
166
184
|
connectionLostMessage?: string;
|
|
185
|
+
/**
|
|
186
|
+
* Upper bound on frontend tool-call to re-run rounds within one
|
|
187
|
+
* {@link AgUiClient.send}. Defaults to {@link MAX_TOOL_ROUNDS}.
|
|
188
|
+
*
|
|
189
|
+
* The default suits a chat whose tools answer questions. A page-driving
|
|
190
|
+
* deployment reaches it legitimately -- filling a form field by field is one
|
|
191
|
+
* round each -- and the symptom is not an error but an answer that stops
|
|
192
|
+
* mid-task, which reads as the model giving up. Raise it where a turn is
|
|
193
|
+
* expected to take many small steps.
|
|
194
|
+
*
|
|
195
|
+
* Read once, when the client is built. The bound is a property of the
|
|
196
|
+
* deployment rather than of a run, so re-reading it per round would only make
|
|
197
|
+
* a mid-run change possible, and a mid-run change to how long the run may
|
|
198
|
+
* last is not a thing a host has any way to reason about.
|
|
199
|
+
*/
|
|
200
|
+
maxToolRounds?: number;
|
|
167
201
|
}
|
|
168
202
|
|
|
169
203
|
/**
|
|
@@ -201,6 +235,7 @@ export class AgUiClient {
|
|
|
201
235
|
*/
|
|
202
236
|
readonly #closedMessageIds = new Set<string>();
|
|
203
237
|
readonly #connectionLostMessage: string;
|
|
238
|
+
readonly #maxToolRounds: number;
|
|
204
239
|
// Set by cancel(); reset at the top of each #run(). Checked by the loop so
|
|
205
240
|
// a cancel between frontend-tool rounds doesn't start another round.
|
|
206
241
|
#cancelled = false;
|
|
@@ -214,6 +249,12 @@ export class AgUiClient {
|
|
|
214
249
|
this.#resolveInterrupts = config.resolveInterrupts ?? null;
|
|
215
250
|
this.#onPersist = config.onPersist ?? (() => {});
|
|
216
251
|
this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
|
|
252
|
+
// Validated here rather than at each caller, so the element's attribute and
|
|
253
|
+
// a direct consumer get the same answer. A bound below one -- or a NaN from
|
|
254
|
+
// an unparseable attribute -- is not a smaller budget but a send that runs
|
|
255
|
+
// the agent zero times, which would look exactly like a broken endpoint.
|
|
256
|
+
const rounds = config.maxToolRounds ?? MAX_TOOL_ROUNDS;
|
|
257
|
+
this.#maxToolRounds = rounds >= 1 ? Math.floor(rounds) : MAX_TOOL_ROUNDS;
|
|
217
258
|
const onStateChanged = config.onStateChanged;
|
|
218
259
|
if (onStateChanged !== undefined) {
|
|
219
260
|
// The agent applies STATE_SNAPSHOT / STATE_DELTA itself; subscribing is
|
|
@@ -252,7 +293,8 @@ export class AgUiClient {
|
|
|
252
293
|
*
|
|
253
294
|
* When the agent calls frontend tools, this executes them and re-runs the
|
|
254
295
|
* agent with the results, looping until the agent stops calling frontend
|
|
255
|
-
* tools (bounded by {@link
|
|
296
|
+
* tools (bounded by {@link AgUiClientConfig.maxToolRounds}, which defaults to
|
|
297
|
+
* {@link MAX_TOOL_ROUNDS}).
|
|
256
298
|
*
|
|
257
299
|
* `attachments` ride on the user message as a non-standard field so the
|
|
258
300
|
* default store round-trips them for history replay; see
|
|
@@ -271,6 +313,45 @@ export class AgUiClient {
|
|
|
271
313
|
await this.#run();
|
|
272
314
|
}
|
|
273
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Drop everything after the most recent user message, so the same question
|
|
318
|
+
* can be asked again.
|
|
319
|
+
*
|
|
320
|
+
* Returns the retained history, or `null` when there is nothing to retry (no
|
|
321
|
+
* user message has been sent yet). **Truncates only** -- the caller re-renders
|
|
322
|
+
* from the returned list and then calls {@link resume}, because the transcript
|
|
323
|
+
* belongs to the element and a client that reached into it would own two
|
|
324
|
+
* things. Running here instead would stream the new answer in underneath the
|
|
325
|
+
* old one.
|
|
326
|
+
*
|
|
327
|
+
* Re-running answers the question the agent was last asked, rather than
|
|
328
|
+
* telling it its answer was wrong, which is what makes the result a
|
|
329
|
+
* *different* answer instead of a conversation about the previous one.
|
|
330
|
+
*
|
|
331
|
+
* **A retried turn re-runs its tools.** For a page-driving agent that is not
|
|
332
|
+
* neutral: the previous attempt already clicked what it clicked, and this
|
|
333
|
+
* does not undo it.
|
|
334
|
+
*/
|
|
335
|
+
truncateToLastUser(): readonly Message[] | null {
|
|
336
|
+
const messages = [...this.#agent.messages];
|
|
337
|
+
// Forward, keeping the last match, rather than a reverse scan with an
|
|
338
|
+
// index lookup: `noUncheckedIndexedAccess` makes the latter reach for an
|
|
339
|
+
// optional chain whose null arm cannot happen and cannot be covered.
|
|
340
|
+
let lastUser = -1;
|
|
341
|
+
for (const [index, message] of messages.entries()) {
|
|
342
|
+
if (message.role === "user") {
|
|
343
|
+
lastUser = index;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (lastUser === -1) {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
const kept = messages.slice(0, lastUser + 1);
|
|
350
|
+
this.#agent.setMessages(kept);
|
|
351
|
+
this.#onPersist(this.#agent.messages);
|
|
352
|
+
return kept;
|
|
353
|
+
}
|
|
354
|
+
|
|
274
355
|
/**
|
|
275
356
|
* Resume the run loop after a navigating tool's result was supplied
|
|
276
357
|
* post-reload (via {@link addToolResult}). Unlike {@link send}, adds no user
|
|
@@ -332,7 +413,7 @@ export class AgUiClient {
|
|
|
332
413
|
// continues an unfinished frontend-tool round after a page load; this stays
|
|
333
414
|
// inside one #run().
|
|
334
415
|
let resume: ResumeEntry[] | undefined;
|
|
335
|
-
for (let round = 0; round <
|
|
416
|
+
for (let round = 0; round < this.#maxToolRounds; round += 1) {
|
|
336
417
|
// A cancel during the previous round's tool execution lands here: the
|
|
337
418
|
// running handler completed, but no further round starts.
|
|
338
419
|
if (this.#cancelled) {
|
|
@@ -483,6 +564,12 @@ export class AgUiClient {
|
|
|
483
564
|
// Emitted after the client has written the patched messages, which is the
|
|
484
565
|
// first moment the result exists. Only the ids marked above are looked at,
|
|
485
566
|
// so an ordinary text delta does not walk the transcript.
|
|
567
|
+
onCustomEvent({ event }) {
|
|
568
|
+
h.onCustomEvent(event.name, event.value);
|
|
569
|
+
},
|
|
570
|
+
onMessagesSnapshotEvent({ event }) {
|
|
571
|
+
h.onMessagesSnapshot(event.messages as readonly Message[]);
|
|
572
|
+
},
|
|
486
573
|
onMessagesChanged({ messages }) {
|
|
487
574
|
if (pendingDeltas.size === 0) {
|
|
488
575
|
return;
|
package/src/index.ts
CHANGED
|
@@ -4,13 +4,19 @@ export {
|
|
|
4
4
|
ATTACHMENT_EVENT,
|
|
5
5
|
CHART_ACTIVITY_TYPE,
|
|
6
6
|
COMPACTION_ACTIVITY_TYPE,
|
|
7
|
+
CUSTOM_AGENT_EVENT,
|
|
7
8
|
ELEMENT_TAG,
|
|
9
|
+
FEEDBACK_EVENT,
|
|
10
|
+
INVALIDATE_CUSTOM_NAME,
|
|
11
|
+
INVALIDATE_EVENT,
|
|
8
12
|
LOAD_CAPABILITY_TOOL,
|
|
9
13
|
MAX_TOOL_ROUNDS,
|
|
14
|
+
MESSAGE_ACTIONS,
|
|
10
15
|
MESSAGE_ROLE,
|
|
11
16
|
RUN_FINISHED_EVENT,
|
|
12
17
|
STATE_EVENT,
|
|
13
18
|
SUBMIT_EVENT,
|
|
19
|
+
SUGGESTIONS_ACTIVITY_TYPE,
|
|
14
20
|
TOGGLE_EVENT,
|
|
15
21
|
TOOL_CALL_STATUS,
|
|
16
22
|
TOOL_DISPLAY,
|
|
@@ -21,8 +27,13 @@ export {
|
|
|
21
27
|
X_SUMMARY_KEY,
|
|
22
28
|
} from "./constants.js";
|
|
23
29
|
export {
|
|
30
|
+
type ActivityRegistration,
|
|
31
|
+
type ActivityRenderer,
|
|
24
32
|
AgUiChat,
|
|
25
33
|
type AttachmentsDetail,
|
|
34
|
+
type CustomAgentDetail,
|
|
35
|
+
type FeedbackDetail,
|
|
36
|
+
type InvalidateDetail,
|
|
26
37
|
type MessageRole,
|
|
27
38
|
type RunFinishedDetail,
|
|
28
39
|
type StateDetail,
|
|
@@ -143,6 +154,16 @@ export {
|
|
|
143
154
|
type ConfirmationRequest,
|
|
144
155
|
requestConfirmation,
|
|
145
156
|
} from "./ui/confirmation_card.js";
|
|
157
|
+
export {
|
|
158
|
+
attachMessageActions,
|
|
159
|
+
type MessageActionsOptions,
|
|
160
|
+
messageActionBar,
|
|
161
|
+
} from "./ui/message_actions.js";
|
|
162
|
+
export {
|
|
163
|
+
attachQuoteOffer,
|
|
164
|
+
type PageQuoteOffer,
|
|
165
|
+
type PageQuoteOfferOptions,
|
|
166
|
+
} from "./ui/page_quote_offer.js";
|
|
146
167
|
export { prettifyToolName } from "./ui/prettify_tool_name.js";
|
|
147
168
|
export {
|
|
148
169
|
type QuestionOptions,
|
|
@@ -150,12 +171,34 @@ export {
|
|
|
150
171
|
type QuestionRequest,
|
|
151
172
|
requestQuestion,
|
|
152
173
|
} from "./ui/question_card.js";
|
|
174
|
+
// Quoting. The transcript wires these itself; they are exported for the half
|
|
175
|
+
// the component cannot reach -- a selection made in the **host page**, which
|
|
176
|
+
// a host reads its own way and hands to `AgUiChat.quote()`.
|
|
177
|
+
export {
|
|
178
|
+
asQuote,
|
|
179
|
+
MAX_QUOTE_CHARS,
|
|
180
|
+
type QuotableSelection,
|
|
181
|
+
quotableSelection,
|
|
182
|
+
} from "./ui/quote_selection.js";
|
|
183
|
+
export {
|
|
184
|
+
type RelativeTimeFormatter,
|
|
185
|
+
relativeTime,
|
|
186
|
+
} from "./ui/relative_time.js";
|
|
153
187
|
export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
|
|
188
|
+
export {
|
|
189
|
+
MAX_SUGGESTION_CHARS,
|
|
190
|
+
MAX_SUGGESTIONS,
|
|
191
|
+
renderSuggestionChips,
|
|
192
|
+
suggestionPrompts,
|
|
193
|
+
} from "./ui/suggestion_chips.js";
|
|
154
194
|
export {
|
|
155
195
|
type SettledStatus,
|
|
156
196
|
ToolCallCard,
|
|
197
|
+
type ToolCallCardOptions,
|
|
157
198
|
type ToolCallStatus,
|
|
158
199
|
type ToolDisplayMode,
|
|
200
|
+
type ToolPayload,
|
|
201
|
+
type ToolPayloadFormatter,
|
|
159
202
|
} from "./ui/tool_call_card.js";
|
|
160
203
|
export { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "./ui/ui_strings.js";
|
|
161
204
|
export { VERSION } from "./version.js";
|
package/src/ui/approval_card.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface ApprovalRequest {
|
|
|
10
10
|
message?: string;
|
|
11
11
|
/** Tool name, surfaced as a `data-tool-name` attribute for styling/tests. */
|
|
12
12
|
toolName?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The call's arguments, shown for editing when {@link ApprovalOptions.onEdit}
|
|
15
|
+
* is set. Omitted when the interrupt names no call whose arguments are known.
|
|
16
|
+
*/
|
|
17
|
+
args?: Record<string, unknown>;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
/** Build a labelled action button. */
|
|
@@ -31,6 +36,19 @@ export interface ApprovalOptions {
|
|
|
31
36
|
signal?: AbortSignal;
|
|
32
37
|
/** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
|
|
33
38
|
strings?: UiStrings;
|
|
39
|
+
/**
|
|
40
|
+
* Offer the call's arguments for editing, and receive what the user approved.
|
|
41
|
+
*
|
|
42
|
+
* Called **only** on approval, and only when the text parses and differs from
|
|
43
|
+
* what was proposed -- an untouched call resolves as a plain approval, so a
|
|
44
|
+
* server sees `editedArgs` exactly when something was actually edited.
|
|
45
|
+
*
|
|
46
|
+
* Absent means no editor, which is deliberate: AG-UI gates this on the
|
|
47
|
+
* agent's own `approveWithEdits` capability, and a card that let a user
|
|
48
|
+
* rewrite arguments a server will discard is worse than one that does not
|
|
49
|
+
* offer to.
|
|
50
|
+
*/
|
|
51
|
+
onEdit?: (args: Record<string, unknown>) => void;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
/**
|
|
@@ -78,6 +96,8 @@ export function requestApproval(
|
|
|
78
96
|
body.setAttribute("part", "approval-body");
|
|
79
97
|
body.textContent = request.message ?? strings.approvalPrompt;
|
|
80
98
|
|
|
99
|
+
const editor = buildEditor(request, options, strings);
|
|
100
|
+
|
|
81
101
|
const actions = document.createElement("div");
|
|
82
102
|
actions.className = "approval-actions";
|
|
83
103
|
actions.setAttribute("part", "approval-actions");
|
|
@@ -98,11 +118,19 @@ export function requestApproval(
|
|
|
98
118
|
};
|
|
99
119
|
|
|
100
120
|
deny.addEventListener("click", () => close(false));
|
|
101
|
-
approve.addEventListener("click", () =>
|
|
121
|
+
approve.addEventListener("click", () => {
|
|
122
|
+
if (editor !== null && !editor.commit()) {
|
|
123
|
+
// Unparseable JSON: say so on the card and stay open. Approving what
|
|
124
|
+
// the user did not write -- the original arguments -- would be the one
|
|
125
|
+
// outcome they cannot see coming.
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
close(true);
|
|
129
|
+
});
|
|
102
130
|
options.signal?.addEventListener("abort", () => close(false), { once: true });
|
|
103
131
|
|
|
104
132
|
actions.append(deny, approve);
|
|
105
|
-
card.append(body, actions);
|
|
133
|
+
card.append(body, ...(editor === null ? [] : [editor.root]), actions);
|
|
106
134
|
host.appendChild(card);
|
|
107
135
|
if (options.signal?.aborted === true) {
|
|
108
136
|
// The run was cancelled before the card could ask; record the denial.
|
|
@@ -112,3 +140,63 @@ export function requestApproval(
|
|
|
112
140
|
approve.focus();
|
|
113
141
|
});
|
|
114
142
|
}
|
|
143
|
+
|
|
144
|
+
/** The editable-arguments region, or `null` when this card does not offer one. */
|
|
145
|
+
function buildEditor(
|
|
146
|
+
request: ApprovalRequest,
|
|
147
|
+
options: ApprovalOptions,
|
|
148
|
+
strings: UiStrings,
|
|
149
|
+
): { root: HTMLElement; commit: () => boolean } | null {
|
|
150
|
+
const { onEdit } = options;
|
|
151
|
+
if (onEdit === undefined || request.args === undefined) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const original = JSON.stringify(request.args, null, 2);
|
|
155
|
+
const root = document.createElement("div");
|
|
156
|
+
root.className = "approval-edit";
|
|
157
|
+
root.setAttribute("part", "approval-edit");
|
|
158
|
+
|
|
159
|
+
const field = document.createElement("textarea");
|
|
160
|
+
field.className = "approval-args";
|
|
161
|
+
field.setAttribute("part", "approval-args");
|
|
162
|
+
field.setAttribute("aria-label", strings.approvalEditArgs);
|
|
163
|
+
field.rows = Math.min(10, original.split("\n").length);
|
|
164
|
+
field.value = original;
|
|
165
|
+
|
|
166
|
+
const error = document.createElement("div");
|
|
167
|
+
error.className = "approval-error";
|
|
168
|
+
error.setAttribute("part", "approval-error");
|
|
169
|
+
// A live region: it appears in response to pressing Approve, and a message
|
|
170
|
+
// that only exists visually leaves a screen-reader user with a button that
|
|
171
|
+
// silently did nothing.
|
|
172
|
+
error.setAttribute("role", "alert");
|
|
173
|
+
error.hidden = true;
|
|
174
|
+
|
|
175
|
+
root.append(field, error);
|
|
176
|
+
return {
|
|
177
|
+
root,
|
|
178
|
+
commit: () => {
|
|
179
|
+
if (field.value === original) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
let parsed: unknown;
|
|
183
|
+
try {
|
|
184
|
+
parsed = JSON.parse(field.value);
|
|
185
|
+
} catch {
|
|
186
|
+
error.textContent = strings.approvalArgsInvalid;
|
|
187
|
+
error.hidden = false;
|
|
188
|
+
field.focus();
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
192
|
+
error.textContent = strings.approvalArgsNotAnObject;
|
|
193
|
+
error.hidden = false;
|
|
194
|
+
field.focus();
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
error.hidden = true;
|
|
198
|
+
onEdit(parsed as Record<string, unknown>);
|
|
199
|
+
return true;
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RunRow } from "../core/run_index.js";
|
|
2
|
-
import { relativeTime } from "./relative_time.js";
|
|
2
|
+
import { type RelativeTimeFormatter, relativeTime } from "./relative_time.js";
|
|
3
3
|
import { DEFAULT_UI_STRINGS, type UiStrings } from "./ui_strings.js";
|
|
4
4
|
|
|
5
5
|
/** How the host continues a picked run. */
|
|
@@ -46,6 +46,7 @@ export class CheckpointMenu {
|
|
|
46
46
|
readonly #heading: HTMLSpanElement;
|
|
47
47
|
/** What had focus before the panel opened, restored on close. */
|
|
48
48
|
#lastFocused: HTMLElement | null = null;
|
|
49
|
+
#formatRelativeTime: RelativeTimeFormatter | null = null;
|
|
49
50
|
#strings: UiStrings;
|
|
50
51
|
#runs: readonly RunRow[] = [];
|
|
51
52
|
|
|
@@ -91,6 +92,25 @@ export class CheckpointMenu {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/** Re-localize a panel built before the host's strings resolved. */
|
|
95
|
+
/**
|
|
96
|
+
* Replace the timestamp formatter, or restore the built-in with `null`.
|
|
97
|
+
*
|
|
98
|
+
* The built-in is deliberately locale-neutral -- there is no `Intl` anywhere
|
|
99
|
+
* in this component, so it never disagrees with a host's own formatting by
|
|
100
|
+
* guessing a locale. That is a defensible default and a poor requirement, so
|
|
101
|
+
* this is the way out.
|
|
102
|
+
*/
|
|
103
|
+
setRelativeTimeFormatter(format: RelativeTimeFormatter | null): void {
|
|
104
|
+
this.#formatRelativeTime = format;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** This row's timestamp, through the host's formatter when it set one. */
|
|
108
|
+
#formatTime(timestamp: number): string {
|
|
109
|
+
return this.#formatRelativeTime !== null
|
|
110
|
+
? this.#formatRelativeTime(timestamp)
|
|
111
|
+
: relativeTime(timestamp, Date.now(), this.#strings);
|
|
112
|
+
}
|
|
113
|
+
|
|
94
114
|
setStrings(strings: UiStrings): void {
|
|
95
115
|
this.#strings = strings;
|
|
96
116
|
this.element.setAttribute("aria-label", strings.checkpoints);
|
|
@@ -207,10 +227,7 @@ export class CheckpointMenu {
|
|
|
207
227
|
row.setAttribute("part", "checkpoint-row");
|
|
208
228
|
|
|
209
229
|
const preview = previewOf(run);
|
|
210
|
-
const time =
|
|
211
|
-
run.started_at === null
|
|
212
|
-
? null
|
|
213
|
-
: relativeTime(Date.parse(run.started_at), Date.now(), this.#strings);
|
|
230
|
+
const time = run.started_at === null ? null : this.#formatTime(Date.parse(run.started_at));
|
|
214
231
|
|
|
215
232
|
const label = document.createElement("span");
|
|
216
233
|
label.className = "checkpoint-label";
|
|
@@ -31,6 +31,16 @@ export interface ConfirmationOptions {
|
|
|
31
31
|
signal?: AbortSignal;
|
|
32
32
|
/** Localized strings; defaults to the English {@link DEFAULT_UI_STRINGS}. */
|
|
33
33
|
strings?: UiStrings;
|
|
34
|
+
/**
|
|
35
|
+
* Offer a third button — "always allow, this session" — and call this when
|
|
36
|
+
* the user picks it. The card still resolves `true`: the extra decision is
|
|
37
|
+
* *in addition to* approving this call, not instead of it.
|
|
38
|
+
*
|
|
39
|
+
* Absent means no button, which is deliberate: presence of the handler is
|
|
40
|
+
* what enables it, so the affordance can never be rendered with nothing
|
|
41
|
+
* listening. A caller that cannot honour the waiver simply does not pass one.
|
|
42
|
+
*/
|
|
43
|
+
onAlwaysAllow?: () => void;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -43,6 +53,12 @@ export interface ConfirmationOptions {
|
|
|
43
53
|
* Answering it removes it: the record of the decision belongs to the tool card
|
|
44
54
|
* this gates, which settles to `done` or `declined` and carries it. A spent
|
|
45
55
|
* form left in place reads as still outstanding.
|
|
56
|
+
*
|
|
57
|
+
* With `onAlwaysAllow` set the card offers a third button. A prompt that is
|
|
58
|
+
* approved nearly every time is not a decision, it is a speed bump — and the
|
|
59
|
+
* reflex it trains is what makes the rare refusal easy to miss. Letting the
|
|
60
|
+
* user say "not this one again, this session" is the affordance that keeps the
|
|
61
|
+
* remaining prompts meaningful.
|
|
46
62
|
*/
|
|
47
63
|
export function requestConfirmation(
|
|
48
64
|
host: Node & ParentNode,
|
|
@@ -75,6 +91,10 @@ export function requestConfirmation(
|
|
|
75
91
|
actions.setAttribute("part", "confirm-actions");
|
|
76
92
|
|
|
77
93
|
const cancel = actionButton("cancel", strings.cancel);
|
|
94
|
+
const always =
|
|
95
|
+
options.onAlwaysAllow === undefined
|
|
96
|
+
? null
|
|
97
|
+
: actionButton("always", strings.confirmAlways.replace("{tool}", request.toolName));
|
|
78
98
|
const confirm = actionButton("confirm", strings.confirm);
|
|
79
99
|
|
|
80
100
|
let settled = false;
|
|
@@ -93,9 +113,17 @@ export function requestConfirmation(
|
|
|
93
113
|
|
|
94
114
|
cancel.addEventListener("click", () => close(false));
|
|
95
115
|
confirm.addEventListener("click", () => close(true));
|
|
116
|
+
always?.addEventListener("click", () => {
|
|
117
|
+
// Recorded before resolving, so a caller that reads its own allowlist
|
|
118
|
+
// synchronously on the next call already sees this one.
|
|
119
|
+
options.onAlwaysAllow?.();
|
|
120
|
+
close(true);
|
|
121
|
+
});
|
|
96
122
|
options.signal?.addEventListener("abort", () => close(false), { once: true });
|
|
97
123
|
|
|
98
|
-
|
|
124
|
+
// Confirm stays last, and rightmost: the waiver is the wider decision, and
|
|
125
|
+
// putting it where the eye lands for "yes" is how it gets taken by accident.
|
|
126
|
+
actions.append(cancel, ...(always === null ? [] : [always]), confirm);
|
|
99
127
|
card.append(body, args, actions);
|
|
100
128
|
host.appendChild(card);
|
|
101
129
|
if (options.signal?.aborted === true) {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { UiStrings } from "./ui_strings.js";
|
|
2
|
+
|
|
3
|
+
/** How long a button shows its confirmation before reverting. */
|
|
4
|
+
const CONFIRM_MS = 1500;
|
|
5
|
+
|
|
6
|
+
/** What an action bar can do, beyond copying. */
|
|
7
|
+
export interface MessageActionsOptions {
|
|
8
|
+
/** Localized strings. */
|
|
9
|
+
strings: UiStrings;
|
|
10
|
+
/**
|
|
11
|
+
* The text Copy puts on the clipboard. Absent means no copy button.
|
|
12
|
+
*
|
|
13
|
+
* A function rather than a string because a bubble's content is rewritten
|
|
14
|
+
* while it streams, and the bar is attached to the element rather than to a
|
|
15
|
+
* snapshot of it. Optional for the same reason `onFeedback` is: what a button
|
|
16
|
+
* needs to do its job is also the statement that the button belongs here, so
|
|
17
|
+
* there is no second flag saying the same thing and no way for the two to
|
|
18
|
+
* disagree.
|
|
19
|
+
*/
|
|
20
|
+
text?: () => string;
|
|
21
|
+
/**
|
|
22
|
+
* Report a rating for this message. Absent means no feedback buttons.
|
|
23
|
+
*
|
|
24
|
+
* The component stores nothing: a rating is the host's to keep, and a
|
|
25
|
+
* write-only table nobody reads is not worth a schema.
|
|
26
|
+
*/
|
|
27
|
+
onFeedback?: (rating: "up" | "down") => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Give one finished message bubble its row of actions.
|
|
32
|
+
*
|
|
33
|
+
* **Finished** is load-bearing. A streaming bubble reassigns its `innerHTML` on
|
|
34
|
+
* every delta, so anything attached mid-stream is discarded and rebuilt for
|
|
35
|
+
* each one -- the same constraint `attachCopyButtons` records, one level up.
|
|
36
|
+
*
|
|
37
|
+
* Retry is deliberately **not** here: it belongs to the last turn only, so the
|
|
38
|
+
* element owns it and moves it as the transcript grows. Everything on this bar
|
|
39
|
+
* is safe to offer on any message, however old.
|
|
40
|
+
*
|
|
41
|
+
* Idempotent -- a bubble already given a bar is skipped, so a re-render or a
|
|
42
|
+
* second call cannot stack rows.
|
|
43
|
+
*
|
|
44
|
+
* Each button is present because the option it needs was passed: `text` for
|
|
45
|
+
* Copy, `onFeedback` for the rating pair. Passing neither builds an empty row,
|
|
46
|
+
* which is a caller's mistake rather than a state to guard against -- the
|
|
47
|
+
* element skips the call entirely when a host has turned both off.
|
|
48
|
+
*/
|
|
49
|
+
export function attachMessageActions(bubble: HTMLElement, options: MessageActionsOptions): void {
|
|
50
|
+
if (existingBar(bubble) !== null) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const bar = messageActionBar(bubble, options.strings);
|
|
54
|
+
const text = options.text;
|
|
55
|
+
if (text !== undefined) {
|
|
56
|
+
bar.appendChild(copyButton(options.strings, text));
|
|
57
|
+
}
|
|
58
|
+
if (options.onFeedback !== undefined) {
|
|
59
|
+
bar.append(
|
|
60
|
+
feedbackButton("up", options.strings.feedbackUp, options.onFeedback),
|
|
61
|
+
feedbackButton("down", options.strings.feedbackDown, options.onFeedback),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The empty action row on `bubble`, created if it has none yet.
|
|
68
|
+
*
|
|
69
|
+
* Shared so a bubble that wants *only* Retry -- a failed run, which has nothing
|
|
70
|
+
* worth copying and nothing to rate -- gets the same row, the same part name
|
|
71
|
+
* and the same accessible grouping as every other message, rather than a
|
|
72
|
+
* second thing that looks like one.
|
|
73
|
+
*/
|
|
74
|
+
export function messageActionBar(bubble: HTMLElement, strings: UiStrings): HTMLElement {
|
|
75
|
+
const existing = existingBar(bubble);
|
|
76
|
+
if (existing !== null) {
|
|
77
|
+
return existing;
|
|
78
|
+
}
|
|
79
|
+
const bar = document.createElement("div");
|
|
80
|
+
bar.className = "message-actions";
|
|
81
|
+
bar.setAttribute("part", "message-actions");
|
|
82
|
+
// A group rather than a toolbar: these are independent actions on the message
|
|
83
|
+
// above, not a set the user arrows between.
|
|
84
|
+
bar.setAttribute("role", "group");
|
|
85
|
+
bar.setAttribute("aria-label", strings.messageActions);
|
|
86
|
+
// A **sibling**, never a child. Inside the bubble the buttons join its
|
|
87
|
+
// `textContent`, which is what Copy reads, what history persists and what
|
|
88
|
+
// every existing assertion about a message's text compares against -- so an
|
|
89
|
+
// answer would be copied back with the glyphs of the buttons that copied it.
|
|
90
|
+
// The bubble must therefore already be in the tree when this is called.
|
|
91
|
+
bubble.after(bar);
|
|
92
|
+
return bar;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The action row belonging to `bubble`, if it has one. */
|
|
96
|
+
function existingBar(bubble: HTMLElement): HTMLElement | null {
|
|
97
|
+
const next = bubble.nextElementSibling;
|
|
98
|
+
return next?.classList.contains("message-actions") === true ? (next as HTMLElement) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build one action button, labelled for screen readers rather than by glyph. */
|
|
102
|
+
export function messageActionButton(
|
|
103
|
+
modifier: string,
|
|
104
|
+
label: string,
|
|
105
|
+
glyph: string,
|
|
106
|
+
): HTMLButtonElement {
|
|
107
|
+
const button = document.createElement("button");
|
|
108
|
+
button.type = "button";
|
|
109
|
+
button.className = `message-action message-action--${modifier}`;
|
|
110
|
+
button.setAttribute("part", `message-action message-action-${modifier}`);
|
|
111
|
+
button.title = label;
|
|
112
|
+
button.setAttribute("aria-label", label);
|
|
113
|
+
const icon = document.createElement("span");
|
|
114
|
+
icon.setAttribute("aria-hidden", "true");
|
|
115
|
+
icon.textContent = glyph;
|
|
116
|
+
button.appendChild(icon);
|
|
117
|
+
return button;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function copyButton(strings: UiStrings, text: () => string): HTMLButtonElement {
|
|
121
|
+
const button = messageActionButton("copy", strings.copyMessage, "⎘");
|
|
122
|
+
button.addEventListener("click", () => {
|
|
123
|
+
void navigator.clipboard.writeText(text()).then(
|
|
124
|
+
() => flash(button, strings.copied, strings.copyMessage),
|
|
125
|
+
// A denied clipboard permission is the common case, not an exception:
|
|
126
|
+
// say so on the button rather than throwing into an unhandled rejection.
|
|
127
|
+
() => flash(button, strings.copyFailed, strings.copyMessage),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
return button;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function feedbackButton(
|
|
134
|
+
rating: "up" | "down",
|
|
135
|
+
label: string,
|
|
136
|
+
report: (rating: "up" | "down") => void,
|
|
137
|
+
): HTMLButtonElement {
|
|
138
|
+
const button = messageActionButton(
|
|
139
|
+
rating === "up" ? "up" : "down",
|
|
140
|
+
label,
|
|
141
|
+
rating === "up" ? "\u{1F44D}" : "\u{1F44E}",
|
|
142
|
+
);
|
|
143
|
+
button.addEventListener("click", () => {
|
|
144
|
+
// Pressed rather than removed: the rating is a standing statement about the
|
|
145
|
+
// message, and a button that vanishes leaves no record of what was said.
|
|
146
|
+
const pressed = button.getAttribute("aria-pressed") === "true";
|
|
147
|
+
button.setAttribute("aria-pressed", pressed ? "false" : "true");
|
|
148
|
+
report(rating);
|
|
149
|
+
});
|
|
150
|
+
button.setAttribute("aria-pressed", "false");
|
|
151
|
+
return button;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Flash `message` on the button, then restore `label`.
|
|
156
|
+
*
|
|
157
|
+
* The label to restore is passed rather than read back off the element: the
|
|
158
|
+
* caller is the one that set it, and reading it would introduce a null arm that
|
|
159
|
+
* cannot happen and cannot be covered.
|
|
160
|
+
*/
|
|
161
|
+
function flash(button: HTMLButtonElement, message: string, label: string): void {
|
|
162
|
+
button.title = message;
|
|
163
|
+
button.setAttribute("aria-label", message);
|
|
164
|
+
button.classList.add("message-action--confirmed");
|
|
165
|
+
setTimeout(() => {
|
|
166
|
+
button.title = label;
|
|
167
|
+
button.setAttribute("aria-label", label);
|
|
168
|
+
button.classList.remove("message-action--confirmed");
|
|
169
|
+
}, CONFIRM_MS);
|
|
170
|
+
}
|