@runtypelabs/persona 4.1.0 → 4.2.1
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/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +53 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.global.js +41 -41
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +51 -51
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +15 -0
- package/dist/smart-dom-reader.d.ts +15 -0
- package/dist/theme-editor-preview.cjs +46 -46
- package/dist/theme-editor-preview.d.cts +15 -0
- package/dist/theme-editor-preview.d.ts +15 -0
- package/dist/theme-editor-preview.js +46 -46
- package/dist/theme-editor.cjs +1 -1
- package/dist/theme-editor.d.cts +15 -0
- package/dist/theme-editor.d.ts +15 -0
- package/dist/theme-editor.js +1 -1
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +224 -0
- package/package.json +1 -1
- package/src/client.test.ts +128 -0
- package/src/client.ts +25 -0
- package/src/components/approval-actions.test.ts +309 -0
- package/src/components/approval-actions.ts +461 -0
- package/src/styles/widget.css +224 -0
- package/src/theme-reference.ts +2 -2
- package/src/types.ts +15 -0
- package/src/ui.ts +19 -3
- package/src/utils/tokens.ts +15 -12
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in default approval renderer.
|
|
3
|
+
*
|
|
4
|
+
* Renders the neutral "permission card": a tool icon, a "The assistant wants to
|
|
5
|
+
* use <tool>" title, the call arguments collapsed behind a "show more" header
|
|
6
|
+
* chevron, and an action row. By default the row is a single "Allow" (allow
|
|
7
|
+
* once) + "Deny". When `config.approval.enableAlwaysAllow` is true it becomes a
|
|
8
|
+
* split "Always allow ⏎" primary with an "Allow once ⌘⏎" dropdown plus
|
|
9
|
+
* "Deny Esc", with keyboard shortcuts — that affordance is opt-in because
|
|
10
|
+
* "Always allow" only means anything if the integrator persists the policy via
|
|
11
|
+
* `onDecision`'s `remember` flag (needs a backend).
|
|
12
|
+
*
|
|
13
|
+
* Installed as an internal `renderApproval` plugin (see ui.ts) so it rides the
|
|
14
|
+
* existing plugin stub-and-hydrate + teardown path and survives idiomorph
|
|
15
|
+
* re-renders. A user-supplied `renderApproval` plugin still fully overrides it.
|
|
16
|
+
*
|
|
17
|
+
* Resolved states mirror the example plugin: approved → render nothing (the
|
|
18
|
+
* tool call takes over the transcript); denied/timeout → a subtle one-line
|
|
19
|
+
* trace.
|
|
20
|
+
*/
|
|
21
|
+
import { createElement } from "../utils/dom";
|
|
22
|
+
import { renderLucideIcon } from "../utils/icons";
|
|
23
|
+
import { formatUnknownValue } from "../utils/formatting";
|
|
24
|
+
import { WEBMCP_TOOL_PREFIX, getWebMcpToolDisplayTitle } from "../webmcp-bridge";
|
|
25
|
+
import { createPopover, isEditableEventTarget, type PopoverHandle } from "../plugin-kit";
|
|
26
|
+
import type {
|
|
27
|
+
AgentWidgetMessage,
|
|
28
|
+
AgentWidgetConfig,
|
|
29
|
+
AgentWidgetApprovalConfig,
|
|
30
|
+
} from "../types";
|
|
31
|
+
import type { AgentWidgetPlugin } from "../plugins/types";
|
|
32
|
+
import { humanizeToolName, approvalDetailsExpansionState } from "./approval-bubble";
|
|
33
|
+
|
|
34
|
+
type Approval = NonNullable<AgentWidgetMessage["approval"]>;
|
|
35
|
+
type Decide = (options?: { remember?: boolean }) => void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Per-widget-instance runtime state. The document `keydown` handler (re-bound on
|
|
39
|
+
* each render to the freshest approve/deny closures) and the "Allow once"
|
|
40
|
+
* popover are torn down when the approval resolves, the bubble rebuilds, or the
|
|
41
|
+
* widget is destroyed. This lives on the plugin instance (NOT module scope) so
|
|
42
|
+
* tearing down one widget never clears listeners/popovers another widget on the
|
|
43
|
+
* same page still owns.
|
|
44
|
+
*/
|
|
45
|
+
interface InstanceState {
|
|
46
|
+
keyHandlers: Map<string, (e: KeyboardEvent) => void>;
|
|
47
|
+
popovers: Map<string, PopoverHandle>;
|
|
48
|
+
// Order in which approvals FIRST became pending — not re-render order. The
|
|
49
|
+
// newest pending approval (bottom of the thread) owns the keyboard shortcuts,
|
|
50
|
+
// so Enter/Esc don't fire on every pending card at once, and an older card
|
|
51
|
+
// re-rendering can't steal ownership from a newer one.
|
|
52
|
+
pendingOrder: string[];
|
|
53
|
+
latestPendingApprovalId: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const createInstanceState = (): InstanceState => ({
|
|
57
|
+
keyHandlers: new Map(),
|
|
58
|
+
popovers: new Map(),
|
|
59
|
+
pendingOrder: [],
|
|
60
|
+
latestPendingApprovalId: null,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Drop a message's transient listener + popover WITHOUT touching its place in
|
|
64
|
+
// the pending order. Used when a still-pending card re-renders and rebinds fresh
|
|
65
|
+
// closures (so a rebuild of an older card doesn't reorder it to "newest").
|
|
66
|
+
const detachMessage = (state: InstanceState, messageId: string): void => {
|
|
67
|
+
const prevKey = state.keyHandlers.get(messageId);
|
|
68
|
+
if (prevKey) {
|
|
69
|
+
document.removeEventListener("keydown", prevKey);
|
|
70
|
+
state.keyHandlers.delete(messageId);
|
|
71
|
+
}
|
|
72
|
+
const popover = state.popovers.get(messageId);
|
|
73
|
+
if (popover) {
|
|
74
|
+
popover.destroy();
|
|
75
|
+
state.popovers.delete(messageId);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Fully retire a message (resolved, denied, or widget destroyed): detach its
|
|
80
|
+
// listener/popover AND remove it from the pending order. If the keyboard owner
|
|
81
|
+
// just went away, promote the newest remaining pending approval so Enter/Esc
|
|
82
|
+
// keep working instead of going dead while another pending card remains.
|
|
83
|
+
const teardownMessage = (state: InstanceState, messageId: string): void => {
|
|
84
|
+
detachMessage(state, messageId);
|
|
85
|
+
const idx = state.pendingOrder.indexOf(messageId);
|
|
86
|
+
if (idx !== -1) state.pendingOrder.splice(idx, 1);
|
|
87
|
+
if (state.latestPendingApprovalId === messageId) {
|
|
88
|
+
state.latestPendingApprovalId = state.pendingOrder.length
|
|
89
|
+
? state.pendingOrder[state.pendingOrder.length - 1]
|
|
90
|
+
: null;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const resolveApprovalConfig = (
|
|
95
|
+
config?: AgentWidgetConfig
|
|
96
|
+
): AgentWidgetApprovalConfig | undefined =>
|
|
97
|
+
config?.approval !== false ? config?.approval : undefined;
|
|
98
|
+
|
|
99
|
+
const isDetailsExpanded = (
|
|
100
|
+
messageId: string,
|
|
101
|
+
approvalConfig?: AgentWidgetApprovalConfig
|
|
102
|
+
): boolean => {
|
|
103
|
+
const mode = approvalConfig?.detailsDisplay ?? "collapsed";
|
|
104
|
+
return approvalDetailsExpansionState.get(messageId) ?? mode === "expanded";
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const kbd = (label: string): HTMLElement => {
|
|
108
|
+
const el = createElement("span", "persona-approval-kbd");
|
|
109
|
+
el.textContent = label;
|
|
110
|
+
return el;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Title: "The assistant wants to use <tool>" with an optional "from <source>"
|
|
114
|
+
// when a non-WebMCP `toolType` source label is present. `formatDescription`
|
|
115
|
+
// fully overrides the line. Mirrors the built-in bubble's label priority
|
|
116
|
+
// (formatDescription → declared WebMCP title → humanized tool name).
|
|
117
|
+
const buildTitle = (
|
|
118
|
+
approval: Approval,
|
|
119
|
+
approvalConfig?: AgentWidgetApprovalConfig
|
|
120
|
+
): HTMLElement => {
|
|
121
|
+
const title = createElement("span", "persona-approval-title");
|
|
122
|
+
if (approvalConfig?.titleColor) title.style.color = approvalConfig.titleColor;
|
|
123
|
+
|
|
124
|
+
const isWebMcp =
|
|
125
|
+
approval.toolType === "webmcp" || approval.toolName.startsWith(WEBMCP_TOOL_PREFIX);
|
|
126
|
+
const declaredTitle = isWebMcp
|
|
127
|
+
? getWebMcpToolDisplayTitle(approval.toolName)
|
|
128
|
+
: undefined;
|
|
129
|
+
|
|
130
|
+
const custom = approvalConfig?.formatDescription?.({
|
|
131
|
+
toolName: approval.toolName,
|
|
132
|
+
toolType: approval.toolType,
|
|
133
|
+
description: approval.description ?? "",
|
|
134
|
+
parameters: approval.parameters,
|
|
135
|
+
...(declaredTitle ? { displayTitle: declaredTitle } : {}),
|
|
136
|
+
...(approval.reason ? { reason: approval.reason } : {}),
|
|
137
|
+
});
|
|
138
|
+
if (custom) {
|
|
139
|
+
title.textContent = custom;
|
|
140
|
+
return title;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const toolDisplay = declaredTitle ?? humanizeToolName(approval.toolName);
|
|
144
|
+
const source =
|
|
145
|
+
approval.toolType && approval.toolType !== "webmcp" ? approval.toolType : null;
|
|
146
|
+
title.append("The assistant wants to use ");
|
|
147
|
+
const toolStrong = document.createElement("strong");
|
|
148
|
+
toolStrong.textContent = toolDisplay;
|
|
149
|
+
title.appendChild(toolStrong);
|
|
150
|
+
if (source) {
|
|
151
|
+
title.append(" from ");
|
|
152
|
+
const srcStrong = document.createElement("strong");
|
|
153
|
+
srcStrong.textContent = source;
|
|
154
|
+
title.appendChild(srcStrong);
|
|
155
|
+
}
|
|
156
|
+
return title;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const buildResolvedTrace = (approval: Approval): HTMLElement => {
|
|
160
|
+
const row = createElement("div", "persona-approval-resolved");
|
|
161
|
+
const icon = renderLucideIcon("ban", 15, "currentColor", 2);
|
|
162
|
+
if (icon) row.appendChild(icon);
|
|
163
|
+
const name = createElement("span", "persona-approval-resolved-name");
|
|
164
|
+
name.textContent = approval.toolName ? humanizeToolName(approval.toolName) : "Tool";
|
|
165
|
+
row.append(name, document.createTextNode(approval.status === "timeout" ? " timed out" : " denied"));
|
|
166
|
+
return row;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const buildPending = (
|
|
170
|
+
state: InstanceState,
|
|
171
|
+
message: AgentWidgetMessage,
|
|
172
|
+
approval: Approval,
|
|
173
|
+
approvalConfig: AgentWidgetApprovalConfig | undefined,
|
|
174
|
+
approve: Decide,
|
|
175
|
+
deny: Decide,
|
|
176
|
+
enableAlways: boolean
|
|
177
|
+
): HTMLElement => {
|
|
178
|
+
const card = createElement("div", "persona-approval-card persona-shadow-sm");
|
|
179
|
+
card.id = `bubble-${message.id}`;
|
|
180
|
+
card.setAttribute("data-message-id", message.id);
|
|
181
|
+
card.setAttribute("data-bubble-type", "approval");
|
|
182
|
+
if (approvalConfig?.backgroundColor) card.style.background = approvalConfig.backgroundColor;
|
|
183
|
+
if (approvalConfig?.borderColor) card.style.borderColor = approvalConfig.borderColor;
|
|
184
|
+
if (approvalConfig?.shadow !== undefined) {
|
|
185
|
+
card.style.boxShadow = approvalConfig.shadow.trim() === "" ? "none" : approvalConfig.shadow;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const detailsMode = approvalConfig?.detailsDisplay ?? "collapsed";
|
|
189
|
+
// The disclosure surfaces the agent-facing description (prompt prose, usage
|
|
190
|
+
// rules) and the raw call parameters. `buildTitle` never falls back to the
|
|
191
|
+
// raw description (it uses formatDescription → declared title → humanized
|
|
192
|
+
// name), so the description is only ever visible here. Mirrors the legacy
|
|
193
|
+
// bubble, which also opened a disclosure when only a description was present.
|
|
194
|
+
const hasDescription = Boolean(approval.description) && detailsMode !== "hidden";
|
|
195
|
+
const hasParams = approval.parameters != null && detailsMode !== "hidden";
|
|
196
|
+
const hasDetails = hasDescription || hasParams;
|
|
197
|
+
const expanded = hasDetails && isDetailsExpanded(message.id, approvalConfig);
|
|
198
|
+
|
|
199
|
+
// The card uses the whole header as the disclosure toggle (chevron-only, no
|
|
200
|
+
// separate text button), but the legacy `showDetailsLabel`/`hideDetailsLabel`
|
|
201
|
+
// strings are still honored as the toggle's accessible label so integrators
|
|
202
|
+
// who localized them keep a meaningful, customizable name on the control.
|
|
203
|
+
const showDetailsLabel = approvalConfig?.showDetailsLabel ?? "Show details";
|
|
204
|
+
const hideDetailsLabel = approvalConfig?.hideDetailsLabel ?? "Hide details";
|
|
205
|
+
|
|
206
|
+
// Header. When a disclosure exists, the whole header toggles its visibility.
|
|
207
|
+
const head = createElement("button", "persona-approval-head") as HTMLButtonElement;
|
|
208
|
+
head.type = "button";
|
|
209
|
+
if (hasDetails) {
|
|
210
|
+
head.setAttribute("data-action", "toggle-params");
|
|
211
|
+
head.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
212
|
+
head.setAttribute("aria-label", expanded ? hideDetailsLabel : showDetailsLabel);
|
|
213
|
+
} else {
|
|
214
|
+
head.setAttribute("data-static", "true");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const logo = createElement("span", "persona-approval-logo");
|
|
218
|
+
const glyph = renderLucideIcon("shield-check", 16, "currentColor", 2);
|
|
219
|
+
if (glyph) logo.appendChild(glyph);
|
|
220
|
+
head.appendChild(logo);
|
|
221
|
+
|
|
222
|
+
const title = buildTitle(approval, approvalConfig);
|
|
223
|
+
if (hasDetails) {
|
|
224
|
+
const toggle = createElement("span", "persona-approval-toggle");
|
|
225
|
+
toggle.setAttribute("aria-hidden", "true");
|
|
226
|
+
const chevron = renderLucideIcon("chevron-down", 14, "currentColor", 2);
|
|
227
|
+
if (chevron) toggle.appendChild(chevron);
|
|
228
|
+
title.append(" ");
|
|
229
|
+
title.appendChild(toggle);
|
|
230
|
+
}
|
|
231
|
+
head.appendChild(title);
|
|
232
|
+
card.appendChild(head);
|
|
233
|
+
|
|
234
|
+
const body = createElement("div", "persona-approval-body");
|
|
235
|
+
|
|
236
|
+
if (hasDetails) {
|
|
237
|
+
const details = createElement("div", "persona-approval-details");
|
|
238
|
+
details.setAttribute("data-role", "params");
|
|
239
|
+
details.hidden = !expanded;
|
|
240
|
+
|
|
241
|
+
if (hasDescription) {
|
|
242
|
+
const desc = createElement("p", "persona-approval-desc");
|
|
243
|
+
if (approvalConfig?.descriptionColor) desc.style.color = approvalConfig.descriptionColor;
|
|
244
|
+
desc.textContent = approval.description as string;
|
|
245
|
+
details.appendChild(desc);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (hasParams) {
|
|
249
|
+
const pre = createElement("pre", "persona-approval-params");
|
|
250
|
+
if (approvalConfig?.parameterBackgroundColor) pre.style.background = approvalConfig.parameterBackgroundColor;
|
|
251
|
+
if (approvalConfig?.parameterTextColor) pre.style.color = approvalConfig.parameterTextColor;
|
|
252
|
+
pre.textContent = formatUnknownValue(approval.parameters);
|
|
253
|
+
details.appendChild(pre);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
body.appendChild(details);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Agent-authored justification: attacker-writable, so plain text + attributed.
|
|
260
|
+
if (approval.reason) {
|
|
261
|
+
const reasonLine = createElement("p", "persona-approval-reason");
|
|
262
|
+
if (approvalConfig?.reasonColor) reasonLine.style.color = approvalConfig.reasonColor;
|
|
263
|
+
else if (approvalConfig?.descriptionColor) reasonLine.style.color = approvalConfig.descriptionColor;
|
|
264
|
+
const label = createElement("span", "persona-approval-reason-label");
|
|
265
|
+
label.textContent = `${approvalConfig?.reasonLabel ?? "Agent's stated reason:"} `;
|
|
266
|
+
reasonLine.append(label, document.createTextNode(approval.reason));
|
|
267
|
+
body.appendChild(reasonLine);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const actions = createElement("div", "persona-approval-actions");
|
|
271
|
+
let popover: PopoverHandle | null = null;
|
|
272
|
+
|
|
273
|
+
const applyPrimaryColors = (el: HTMLElement): void => {
|
|
274
|
+
if (approvalConfig?.approveButtonColor) el.style.background = approvalConfig.approveButtonColor;
|
|
275
|
+
if (approvalConfig?.approveButtonTextColor) el.style.color = approvalConfig.approveButtonTextColor;
|
|
276
|
+
};
|
|
277
|
+
const denyBtn = createElement("button", "persona-approval-deny") as HTMLButtonElement;
|
|
278
|
+
denyBtn.type = "button";
|
|
279
|
+
denyBtn.setAttribute("data-action", "deny");
|
|
280
|
+
if (approvalConfig?.denyButtonColor) denyBtn.style.background = approvalConfig.denyButtonColor;
|
|
281
|
+
if (approvalConfig?.denyButtonTextColor) denyBtn.style.color = approvalConfig.denyButtonTextColor;
|
|
282
|
+
denyBtn.append(approvalConfig?.denyLabel ?? "Deny");
|
|
283
|
+
|
|
284
|
+
if (enableAlways) {
|
|
285
|
+
const split = createElement("div", "persona-approval-split");
|
|
286
|
+
const primary = createElement("button", "persona-approval-primary") as HTMLButtonElement;
|
|
287
|
+
primary.type = "button";
|
|
288
|
+
primary.setAttribute("data-action", "always");
|
|
289
|
+
applyPrimaryColors(primary);
|
|
290
|
+
primary.append(approvalConfig?.approveLabel ?? "Always allow", kbd("⏎"));
|
|
291
|
+
|
|
292
|
+
const caret = createElement("button", "persona-approval-caret") as HTMLButtonElement;
|
|
293
|
+
caret.type = "button";
|
|
294
|
+
caret.setAttribute("data-action", "toggle-menu");
|
|
295
|
+
caret.setAttribute("aria-label", "More options");
|
|
296
|
+
applyPrimaryColors(caret);
|
|
297
|
+
const caretIcon = renderLucideIcon("chevron-down", 15, "currentColor", 2);
|
|
298
|
+
if (caretIcon) caret.appendChild(caretIcon);
|
|
299
|
+
|
|
300
|
+
split.append(primary, caret);
|
|
301
|
+
actions.append(split, denyBtn);
|
|
302
|
+
denyBtn.append(kbd("Esc"));
|
|
303
|
+
|
|
304
|
+
// "Allow once" menu, portaled out of the transcript by createPopover so it
|
|
305
|
+
// overlays the rest of the UI and isn't clipped by the scroll container.
|
|
306
|
+
const menu = createElement("div", "persona-approval-menu");
|
|
307
|
+
const once = createElement("button", "persona-approval-menu-item") as HTMLButtonElement;
|
|
308
|
+
once.type = "button";
|
|
309
|
+
once.append("Allow once", kbd("⌘⏎"));
|
|
310
|
+
menu.appendChild(once);
|
|
311
|
+
popover = createPopover({
|
|
312
|
+
anchor: split,
|
|
313
|
+
content: menu,
|
|
314
|
+
placement: "bottom-start",
|
|
315
|
+
matchAnchorWidth: true,
|
|
316
|
+
});
|
|
317
|
+
state.popovers.set(message.id, popover);
|
|
318
|
+
once.addEventListener("click", () => {
|
|
319
|
+
teardownMessage(state, message.id);
|
|
320
|
+
approve(); // Allow once
|
|
321
|
+
});
|
|
322
|
+
} else {
|
|
323
|
+
const allow = createElement(
|
|
324
|
+
"button",
|
|
325
|
+
"persona-approval-primary persona-approval-primary--solo"
|
|
326
|
+
) as HTMLButtonElement;
|
|
327
|
+
allow.type = "button";
|
|
328
|
+
allow.setAttribute("data-action", "allow");
|
|
329
|
+
applyPrimaryColors(allow);
|
|
330
|
+
allow.append(approvalConfig?.approveLabel ?? "Allow");
|
|
331
|
+
actions.append(allow, denyBtn);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
body.appendChild(actions);
|
|
335
|
+
card.appendChild(body);
|
|
336
|
+
|
|
337
|
+
// Single delegated click listener; survives morph via the plugin hydrate path.
|
|
338
|
+
card.addEventListener("click", (e) => {
|
|
339
|
+
const target = e.target instanceof Element ? e.target.closest("[data-action]") : null;
|
|
340
|
+
if (!target) return;
|
|
341
|
+
const action = target.getAttribute("data-action");
|
|
342
|
+
if (action === "toggle-params") {
|
|
343
|
+
const pre = card.querySelector<HTMLElement>('[data-role="params"]');
|
|
344
|
+
if (pre) {
|
|
345
|
+
const willOpen = pre.hidden;
|
|
346
|
+
pre.hidden = !willOpen;
|
|
347
|
+
head.setAttribute("aria-expanded", willOpen ? "true" : "false");
|
|
348
|
+
head.setAttribute("aria-label", willOpen ? hideDetailsLabel : showDetailsLabel);
|
|
349
|
+
approvalDetailsExpansionState.set(message.id, willOpen);
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (action === "toggle-menu") {
|
|
354
|
+
popover?.toggle();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (action === "always") {
|
|
358
|
+
teardownMessage(state, message.id);
|
|
359
|
+
approve({ remember: true });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (action === "allow") {
|
|
363
|
+
teardownMessage(state, message.id);
|
|
364
|
+
approve();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (action === "deny") {
|
|
368
|
+
teardownMessage(state, message.id);
|
|
369
|
+
deny();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
return card;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* The built-in approval renderer, shaped as a plugin so ui.ts can run it through
|
|
379
|
+
* the same `renderApproval` pipeline as user plugins (which still take
|
|
380
|
+
* precedence). Reads `config` from the render context, so a single plugin
|
|
381
|
+
* serves every render for one widget instance.
|
|
382
|
+
*
|
|
383
|
+
* Returns the plugin alongside a `teardown` the host pushes into its destroy
|
|
384
|
+
* callbacks — both close over the SAME per-instance state, so destroying one
|
|
385
|
+
* widget never disturbs another widget's open approvals on the same page.
|
|
386
|
+
*/
|
|
387
|
+
export const createBuiltInApprovalPlugin = (): {
|
|
388
|
+
plugin: AgentWidgetPlugin;
|
|
389
|
+
teardown: () => void;
|
|
390
|
+
} => {
|
|
391
|
+
const state = createInstanceState();
|
|
392
|
+
|
|
393
|
+
const plugin: AgentWidgetPlugin = {
|
|
394
|
+
id: "persona-built-in-approval",
|
|
395
|
+
renderApproval: ({ message, approve, deny, config }) => {
|
|
396
|
+
const approval = message?.approval;
|
|
397
|
+
if (!approval) return null;
|
|
398
|
+
const approvalConfig = resolveApprovalConfig(config);
|
|
399
|
+
|
|
400
|
+
if (approval.status !== "pending") {
|
|
401
|
+
teardownMessage(state, message.id);
|
|
402
|
+
// Approved → render nothing; the tool call takes over the transcript.
|
|
403
|
+
// (An empty hidden element, not null, suppresses the legacy fallback.)
|
|
404
|
+
if (approval.status === "approved") {
|
|
405
|
+
const hidden = document.createElement("div");
|
|
406
|
+
hidden.style.display = "none";
|
|
407
|
+
return hidden;
|
|
408
|
+
}
|
|
409
|
+
return buildResolvedTrace(approval);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Rebuild: drop any prior listener/popover before (re)binding fresh
|
|
413
|
+
// closures, but KEEP this approval's place in the pending order so a
|
|
414
|
+
// re-render of an older card doesn't reorder it ahead of a newer one.
|
|
415
|
+
detachMessage(state, message.id);
|
|
416
|
+
const enableAlways = approvalConfig?.enableAlwaysAllow === true;
|
|
417
|
+
const card = buildPending(state, message, approval, approvalConfig, approve, deny, enableAlways);
|
|
418
|
+
|
|
419
|
+
if (enableAlways) {
|
|
420
|
+
if (!state.pendingOrder.includes(message.id)) state.pendingOrder.push(message.id);
|
|
421
|
+
// The newest first-seen pending approval owns the shortcuts, regardless
|
|
422
|
+
// of which card happens to be re-rendering right now.
|
|
423
|
+
state.latestPendingApprovalId = state.pendingOrder[state.pendingOrder.length - 1];
|
|
424
|
+
const onKeydown = (e: KeyboardEvent): void => {
|
|
425
|
+
if (isEditableEventTarget(e)) return;
|
|
426
|
+
if (message.id !== state.latestPendingApprovalId) return;
|
|
427
|
+
if (e.key !== "Escape" && e.key !== "Enter") return;
|
|
428
|
+
e.preventDefault();
|
|
429
|
+
// Resolving here promotes the next-newest pending approval to owner.
|
|
430
|
+
// Stop immediate propagation so the SAME keypress doesn't then reach
|
|
431
|
+
// that freshly-promoted card's listener and resolve it too — one
|
|
432
|
+
// keypress resolves exactly one approval; the next owner waits for the
|
|
433
|
+
// next press.
|
|
434
|
+
e.stopImmediatePropagation();
|
|
435
|
+
teardownMessage(state, message.id);
|
|
436
|
+
if (e.key === "Escape") {
|
|
437
|
+
deny();
|
|
438
|
+
} else if (e.metaKey || e.ctrlKey) {
|
|
439
|
+
approve(); // Allow once
|
|
440
|
+
} else {
|
|
441
|
+
approve({ remember: true }); // Always allow
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
state.keyHandlers.set(message.id, onKeydown);
|
|
445
|
+
document.addEventListener("keydown", onKeydown);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return card;
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// Release every pending approval's global listener + popover for THIS widget.
|
|
453
|
+
const teardown = (): void => {
|
|
454
|
+
for (const id of [...state.keyHandlers.keys(), ...state.popovers.keys()]) {
|
|
455
|
+
teardownMessage(state, id);
|
|
456
|
+
}
|
|
457
|
+
state.latestPendingApprovalId = null;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
return { plugin, teardown };
|
|
461
|
+
};
|
package/src/styles/widget.css
CHANGED
|
@@ -2405,6 +2405,230 @@
|
|
|
2405
2405
|
transform: scale(0.97);
|
|
2406
2406
|
}
|
|
2407
2407
|
|
|
2408
|
+
/* ============================================
|
|
2409
|
+
Approval card (default renderer, components/approval-actions.ts)
|
|
2410
|
+
Neutral surface card. Every color is a --persona-* token; the legacy
|
|
2411
|
+
--persona-approval-* shorthands are honored fallback-first so a consumer who
|
|
2412
|
+
themed them still wins, then the new semantic default applies.
|
|
2413
|
+
============================================ */
|
|
2414
|
+
.persona-approval-card {
|
|
2415
|
+
width: 100%;
|
|
2416
|
+
max-width: 100%;
|
|
2417
|
+
box-sizing: border-box;
|
|
2418
|
+
background: var(--persona-approval-bg, var(--persona-surface, #ffffff));
|
|
2419
|
+
border: 0.5px solid var(--persona-approval-border, var(--persona-border, rgba(11, 11, 11, 0.1)));
|
|
2420
|
+
border-radius: 0.75rem;
|
|
2421
|
+
padding: 0.5rem;
|
|
2422
|
+
}
|
|
2423
|
+
.persona-approval-card.persona-shadow-sm {
|
|
2424
|
+
box-shadow: var(--persona-approval-shadow, 0 1px 2px 0 rgba(11, 11, 11, 0.06), 0 2px 8px 0 rgba(11, 11, 11, 0.04));
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
.persona-approval-head {
|
|
2428
|
+
display: flex;
|
|
2429
|
+
align-items: center;
|
|
2430
|
+
gap: 0.75rem;
|
|
2431
|
+
width: 100%;
|
|
2432
|
+
border: none;
|
|
2433
|
+
background: transparent;
|
|
2434
|
+
padding: 0.4rem 0.6rem;
|
|
2435
|
+
cursor: pointer;
|
|
2436
|
+
text-align: left;
|
|
2437
|
+
font: inherit;
|
|
2438
|
+
color: var(--persona-text, #0b0b0b);
|
|
2439
|
+
border-radius: 0.5rem;
|
|
2440
|
+
transition: background 0.12s ease;
|
|
2441
|
+
}
|
|
2442
|
+
.persona-approval-head:hover { background: var(--persona-container, rgba(11, 11, 11, 0.04)); }
|
|
2443
|
+
.persona-approval-head[data-static="true"] { cursor: default; }
|
|
2444
|
+
.persona-approval-head[data-static="true"]:hover { background: transparent; }
|
|
2445
|
+
|
|
2446
|
+
.persona-approval-logo {
|
|
2447
|
+
flex-shrink: 0;
|
|
2448
|
+
width: 24px;
|
|
2449
|
+
height: 24px;
|
|
2450
|
+
display: grid;
|
|
2451
|
+
place-items: center;
|
|
2452
|
+
box-sizing: border-box;
|
|
2453
|
+
border: 0.5px solid var(--persona-border, rgba(11, 11, 11, 0.12));
|
|
2454
|
+
border-radius: 6.5px;
|
|
2455
|
+
background: var(--persona-surface, #ffffff);
|
|
2456
|
+
box-shadow: 0 1px 2px 0 rgba(11, 11, 11, 0.08);
|
|
2457
|
+
color: var(--persona-muted, #6b7280);
|
|
2458
|
+
overflow: hidden;
|
|
2459
|
+
}
|
|
2460
|
+
.persona-approval-logo svg { width: 15px; height: 15px; display: block; }
|
|
2461
|
+
|
|
2462
|
+
.persona-approval-title {
|
|
2463
|
+
flex: 1;
|
|
2464
|
+
min-width: 0;
|
|
2465
|
+
font-size: 0.875rem;
|
|
2466
|
+
line-height: 1.4;
|
|
2467
|
+
color: var(--persona-text, #0b0b0b);
|
|
2468
|
+
}
|
|
2469
|
+
.persona-approval-title strong { font-weight: 600; }
|
|
2470
|
+
|
|
2471
|
+
.persona-approval-toggle {
|
|
2472
|
+
display: inline-flex;
|
|
2473
|
+
align-items: center;
|
|
2474
|
+
margin-left: 0.25rem;
|
|
2475
|
+
color: var(--persona-muted, #6b7280);
|
|
2476
|
+
vertical-align: middle;
|
|
2477
|
+
transition: transform 0.15s ease;
|
|
2478
|
+
}
|
|
2479
|
+
.persona-approval-toggle svg { width: 0.85rem; height: 0.85rem; display: block; }
|
|
2480
|
+
.persona-approval-head[aria-expanded="false"] .persona-approval-toggle { transform: rotate(-90deg); }
|
|
2481
|
+
|
|
2482
|
+
.persona-approval-body {
|
|
2483
|
+
padding: 0.5rem 0.6rem 0.35rem calc(24px + 0.75rem + 0.6rem);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
.persona-approval-details {
|
|
2487
|
+
margin: 0 0 0.75rem;
|
|
2488
|
+
}
|
|
2489
|
+
.persona-approval-details[hidden] { display: none; }
|
|
2490
|
+
|
|
2491
|
+
.persona-approval-desc {
|
|
2492
|
+
margin: 0 0 0.5rem;
|
|
2493
|
+
font-size: 0.8125rem;
|
|
2494
|
+
line-height: 1.4;
|
|
2495
|
+
color: var(--persona-muted, #6b7280);
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
.persona-approval-params {
|
|
2499
|
+
margin: 0;
|
|
2500
|
+
padding: 0.7rem 0.85rem;
|
|
2501
|
+
border-radius: 0.625rem;
|
|
2502
|
+
background: var(--persona-container, rgba(11, 11, 11, 0.04));
|
|
2503
|
+
color: var(--persona-text, #1f2937);
|
|
2504
|
+
font-size: 0.75rem;
|
|
2505
|
+
line-height: 1rem;
|
|
2506
|
+
overflow-x: auto;
|
|
2507
|
+
max-height: 9rem;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
.persona-approval-reason {
|
|
2511
|
+
margin: 0 0 0.6rem;
|
|
2512
|
+
font-size: 0.8125rem;
|
|
2513
|
+
line-height: 1.4;
|
|
2514
|
+
color: var(--persona-muted, #6b7280);
|
|
2515
|
+
}
|
|
2516
|
+
.persona-approval-reason-label { font-weight: 500; }
|
|
2517
|
+
|
|
2518
|
+
.persona-approval-resolved {
|
|
2519
|
+
display: flex;
|
|
2520
|
+
align-items: center;
|
|
2521
|
+
gap: 0.45rem;
|
|
2522
|
+
margin: 0.5rem 0;
|
|
2523
|
+
padding: 0.3rem 0.15rem;
|
|
2524
|
+
font-size: 0.8rem;
|
|
2525
|
+
color: var(--persona-muted, #6b7280);
|
|
2526
|
+
}
|
|
2527
|
+
.persona-approval-resolved svg { width: 0.95rem; height: 0.95rem; display: block; flex-shrink: 0; }
|
|
2528
|
+
.persona-approval-resolved-name { font-weight: 600; }
|
|
2529
|
+
|
|
2530
|
+
.persona-approval-actions {
|
|
2531
|
+
display: flex;
|
|
2532
|
+
align-items: center;
|
|
2533
|
+
gap: 0.5rem;
|
|
2534
|
+
flex-wrap: wrap;
|
|
2535
|
+
}
|
|
2536
|
+
.persona-approval-split { position: relative; display: inline-flex; }
|
|
2537
|
+
|
|
2538
|
+
.persona-approval-primary,
|
|
2539
|
+
.persona-approval-caret,
|
|
2540
|
+
.persona-approval-deny {
|
|
2541
|
+
border: none;
|
|
2542
|
+
cursor: pointer;
|
|
2543
|
+
font: inherit;
|
|
2544
|
+
font-size: 0.875rem;
|
|
2545
|
+
font-weight: 500;
|
|
2546
|
+
height: 2rem;
|
|
2547
|
+
display: inline-flex;
|
|
2548
|
+
align-items: center;
|
|
2549
|
+
gap: 0.375rem;
|
|
2550
|
+
transition: background 0.12s ease, box-shadow 0.12s ease;
|
|
2551
|
+
}
|
|
2552
|
+
/* Primary anchors to the brand token via the established button-primary pair. */
|
|
2553
|
+
.persona-approval-primary {
|
|
2554
|
+
background: var(--persona-approval-approve-bg, var(--persona-button-primary-bg, var(--persona-primary, #111827)));
|
|
2555
|
+
color: var(--persona-button-primary-fg, var(--persona-text-inverse, #ffffff));
|
|
2556
|
+
padding: 0 0.75rem;
|
|
2557
|
+
border-radius: 0.5rem 0 0 0.5rem;
|
|
2558
|
+
}
|
|
2559
|
+
.persona-approval-primary--solo { border-radius: 0.5rem; }
|
|
2560
|
+
.persona-approval-caret {
|
|
2561
|
+
position: relative;
|
|
2562
|
+
background: var(--persona-approval-approve-bg, var(--persona-button-primary-bg, var(--persona-primary, #111827)));
|
|
2563
|
+
color: var(--persona-button-primary-fg, var(--persona-text-inverse, #ffffff));
|
|
2564
|
+
width: 2rem;
|
|
2565
|
+
justify-content: center;
|
|
2566
|
+
padding: 0;
|
|
2567
|
+
border-radius: 0 0.5rem 0.5rem 0;
|
|
2568
|
+
}
|
|
2569
|
+
.persona-approval-caret svg { width: 0.95rem; height: 0.95rem; display: block; }
|
|
2570
|
+
.persona-approval-caret::before {
|
|
2571
|
+
content: "";
|
|
2572
|
+
position: absolute;
|
|
2573
|
+
left: 0;
|
|
2574
|
+
top: 4px;
|
|
2575
|
+
bottom: 4px;
|
|
2576
|
+
width: 1px;
|
|
2577
|
+
background: var(--persona-button-primary-fg, #ffffff);
|
|
2578
|
+
opacity: 0.25;
|
|
2579
|
+
transition: opacity 0.12s ease;
|
|
2580
|
+
}
|
|
2581
|
+
.persona-approval-caret:hover::before,
|
|
2582
|
+
.persona-approval-primary:hover + .persona-approval-caret::before { opacity: 0; }
|
|
2583
|
+
.persona-approval-primary:hover,
|
|
2584
|
+
.persona-approval-caret:hover { box-shadow: inset 0 0 0 999px rgba(255, 255, 255, 0.16); }
|
|
2585
|
+
|
|
2586
|
+
.persona-approval-deny {
|
|
2587
|
+
background: var(--persona-approval-deny-bg, var(--persona-container, rgba(11, 11, 11, 0.04)));
|
|
2588
|
+
color: var(--persona-text, #1f2937);
|
|
2589
|
+
padding: 0 0.75rem;
|
|
2590
|
+
border-radius: 0.5rem;
|
|
2591
|
+
box-shadow: inset 0 0 0 0.5px var(--persona-border, rgba(0, 0, 0, 0.1));
|
|
2592
|
+
}
|
|
2593
|
+
.persona-approval-deny:hover { box-shadow: inset 0 0 0 0.5px var(--persona-border, rgba(0, 0, 0, 0.1)), inset 0 0 0 999px rgba(11, 11, 11, 0.04); }
|
|
2594
|
+
|
|
2595
|
+
.persona-approval-menu {
|
|
2596
|
+
width: max-content;
|
|
2597
|
+
max-width: 18rem;
|
|
2598
|
+
box-sizing: border-box;
|
|
2599
|
+
background: var(--persona-surface, #ffffff);
|
|
2600
|
+
border: 0.5px solid var(--persona-border, rgba(11, 11, 11, 0.1));
|
|
2601
|
+
border-radius: 0.625rem;
|
|
2602
|
+
box-shadow: 0 8px 24px rgba(11, 11, 11, 0.12), 0 2px 6px rgba(11, 11, 11, 0.08);
|
|
2603
|
+
padding: 0.25rem;
|
|
2604
|
+
white-space: nowrap;
|
|
2605
|
+
}
|
|
2606
|
+
.persona-approval-menu-item {
|
|
2607
|
+
display: flex;
|
|
2608
|
+
align-items: center;
|
|
2609
|
+
gap: 0.6rem;
|
|
2610
|
+
width: 100%;
|
|
2611
|
+
border: none;
|
|
2612
|
+
background: transparent;
|
|
2613
|
+
cursor: pointer;
|
|
2614
|
+
font: inherit;
|
|
2615
|
+
font-size: 0.875rem;
|
|
2616
|
+
color: var(--persona-text, #1f2937);
|
|
2617
|
+
padding: 0.45rem 0.6rem;
|
|
2618
|
+
border-radius: 0.4rem;
|
|
2619
|
+
text-align: left;
|
|
2620
|
+
transition: background 0.12s ease;
|
|
2621
|
+
}
|
|
2622
|
+
.persona-approval-menu-item:hover { background: var(--persona-container, rgba(11, 11, 11, 0.06)); }
|
|
2623
|
+
.persona-approval-kbd {
|
|
2624
|
+
font-family: inherit;
|
|
2625
|
+
font-size: 0.75rem;
|
|
2626
|
+
line-height: 1;
|
|
2627
|
+
opacity: 0.7;
|
|
2628
|
+
margin-left: 0.25rem;
|
|
2629
|
+
}
|
|
2630
|
+
.persona-approval-menu-item .persona-approval-kbd { margin-left: auto; }
|
|
2631
|
+
|
|
2408
2632
|
/* ============================================
|
|
2409
2633
|
Visibility (Tailwind parity: prefix persona-)
|
|
2410
2634
|
Shipped widget.css is hand-maintained; these utilities are used in TS but not generated by Tailwind at build time.
|