agent-yes 1.303.0 → 1.303.1-beta.786.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/README.md +15 -0
- package/default.config.yaml +5 -5
- package/dist/{SUPPORTED_CLIS-CqmE-ESB.js → SUPPORTED_CLIS-BVh0ZtgP.js} +2 -2
- package/dist/{SUPPORTED_CLIS-BvWBFlrN.js → SUPPORTED_CLIS-D7WKQjDO.js} +3 -3
- package/dist/{agentShare-Gb4vxJzE.js → agentShare-CdyLD881.js} +7 -6
- package/dist/{askCli-J9hHcgJz.js → askCli-BU3CNCLU.js} +2 -2
- package/dist/{callback-ZzRPkYjf.js → callback-B62xWBOe.js} +7 -6
- package/dist/{callback-CuIY-75l.js → callback-CUNI_et9.js} +5 -5
- package/dist/{callbackCore-DXVwvq0X.js → callbackCore-BesbQ6-Z.js} +1 -1
- package/dist/{callbackCore-BVEHd6Yt.js → callbackCore-DSJWhqAe.js} +1 -1
- package/dist/{channels-BLdkzCbv.js → channels-j5nWuzlQ.js} +2 -2
- package/dist/cli.js +11 -11
- package/dist/{cmdDsh-CymghOa0.js → cmdDsh-2LP0grim.js} +1 -1
- package/dist/{cmdPs-DSKYjOKS.js → cmdPs-mWdk73nd.js} +7 -6
- package/dist/{cwdConflictWarn-DXh7qJz6.js → cwdConflictWarn-skQSMwEh.js} +1 -1
- package/dist/{cwdPassthroughHint-DADidTOP.js → cwdPassthroughHint-BUbWMoz0.js} +1 -1
- package/dist/{expose-ZFn10b_l.js → expose-Dawq1LxC.js} +1 -1
- package/dist/{forkNested-DD_4xEL8.js → forkNested-CavHyWzA.js} +1 -1
- package/dist/{hist-Dy-JVuUY.js → hist-Cs0KFSUW.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/{initMsg-BGBIVi_Q.js → initMsg-CcTqFtsp.js} +1 -1
- package/dist/{initMsg-NleE4YLm.js → initMsg-D9KJnfgC.js} +1 -1
- package/dist/liagent/box-BSIrRRc_.js +2320 -0
- package/dist/liagent/box-DloywR57.d.ts +409 -0
- package/dist/liagent/box.d.ts +3 -0
- package/dist/liagent/box.js +3 -0
- package/dist/liagent/cli.d.ts +1 -0
- package/dist/liagent/cli.js +908 -0
- package/dist/liagent/client.d.ts +28 -0
- package/dist/liagent/client.js +240 -0
- package/dist/liagent/echo-BvzPSI0x.js +86 -0
- package/dist/liagent/echo-W2RcMEny.d.ts +38 -0
- package/dist/liagent/hub.d.ts +405 -0
- package/dist/liagent/hub.js +1831 -0
- package/dist/liagent/index.d.ts +14 -0
- package/dist/liagent/index.js +10 -0
- package/dist/liagent/liagent.js +339 -0
- package/dist/liagent/mic-audio-C3Shud_F.js +754 -0
- package/dist/liagent/mic-audio-DMCWtEI-.d.ts +317 -0
- package/dist/liagent/mic-button-CtZHFBSG.js +651 -0
- package/dist/liagent/mic-button-Y0ioPetQ.d.ts +248 -0
- package/dist/liagent/mic.d.ts +3 -0
- package/dist/liagent/mic.js +4 -0
- package/dist/liagent/progress-CJs08bXb.js +473 -0
- package/dist/liagent/progress-CfE-meHr.d.ts +205 -0
- package/dist/liagent/progress.d.ts +2 -0
- package/dist/liagent/progress.js +3 -0
- package/dist/liagent/protocol.d.ts +324 -0
- package/dist/liagent/protocol.js +504 -0
- package/dist/liagent/speech-rate-DXeJjRTc.js +42 -0
- package/dist/liagent/state-tree-BZCmAknI.js +353 -0
- package/dist/liagent/tree-BrR7NwMf.js +710 -0
- package/dist/liagent/tree-HB9RhX_H.d.ts +295 -0
- package/dist/liagent/tree.d.ts +2 -0
- package/dist/liagent/tree.js +4 -0
- package/dist/liagent/ui-strings-B03le3fh.d.ts +27 -0
- package/dist/liagent/ui-strings-CJIwW5Gf.js +794 -0
- package/dist/liagent/ui.d.ts +394 -0
- package/dist/liagent/ui.js +5 -0
- package/dist/liagent/voice-D7yeS9VM.js +802 -0
- package/dist/liagent/voice-Qo7SCgi8.d.ts +308 -0
- package/dist/liagent/voice.d.ts +3 -0
- package/dist/liagent/voice.js +4 -0
- package/dist/{needsInput-D__56HYf.js → needsInput-hRiWFjGy.js} +1 -1
- package/dist/{nodeRuntime-oANVaOqi.js → nodeRuntime-DGMQRTGT.js} +1 -1
- package/dist/{notifyDaemon-C_gTWqYZ.js → notifyDaemon-CphDin-i.js} +6 -5
- package/dist/{notifyStore-D9cNjXFG.js → notifyStore-C4pH_-zJ.js} +1 -1
- package/dist/{notifyStore-DI4iKFut.js → notifyStore-DIfkfKB2.js} +1 -1
- package/dist/{openBrowser-B6ZyKxfP.js → openBrowser-1cIo4dJL.js} +1 -1
- package/dist/{parentLink-qUr7dRdh.js → parentLink-B58V1RVd.js} +1 -1
- package/dist/{parentLink-CB8Nc-dr.js → parentLink-DoEWVGld.js} +1 -1
- package/dist/{parentPingLoop-BhVR1EQm.js → parentPingLoop-D-HLYj7R.js} +6 -6
- package/dist/{procStats-xvUthnOx.js → procStats-BvmrBrOs.js} +1 -1
- package/dist/{remotes-COBEhmlr.js → remotes-DevcBeLl.js} +5 -4
- package/dist/{remotes-C36aI8Ty.js → remotes-MfdV9M8u.js} +2 -2
- package/dist/roomReplyAlias-DFqg_6IU.js +58 -0
- package/dist/roomReplyAlias-DuEkSSDI.js +6 -0
- package/dist/rustBinary-BwjZgO1K.js +4 -0
- package/dist/{rustBinary-eOcvZ5yS.js → rustBinary-CCDzbWCA.js} +24 -3
- package/dist/{schedule-E2QlsME5.js → schedule-CFBKuWRT.js} +6 -6
- package/dist/{serve-BWy7zL7j.js → serve-950j7kR5.js} +48 -41
- package/dist/{serveRust-BgY5Te5E.js → serveRust-CbBrcUrg.js} +2 -2
- package/dist/{setup-CD9-RyhT.js → setup-DyKq1l88.js} +4 -4
- package/dist/{share-530eCWft.js → share-DrS3LYqf.js} +1 -1
- package/dist/{share-Jl8Eia9b.js → share-w5oRbvlj.js} +1 -1
- package/dist/{shareCmd-Drxb-SjM.js → shareCmd-CZ_HAdpT.js} +10 -10
- package/dist/{spawnGate-CsQ4_N5h.js → spawnGate-DeQBTX-o.js} +1 -1
- package/dist/{spawnGate-SX13EqI3.js → spawnGate-lr3tvA6U.js} +2 -2
- package/dist/{subcommands-Cc6bmEnB.js → subcommands-BfVi1sd7.js} +36 -31
- package/dist/{subcommands-C627cuOl.js → subcommands-HLrcwhwq.js} +5 -4
- package/dist/{systemPathLink-eWUYHNXm.js → systemPathLink-SjUzgSiq.js} +1 -1
- package/dist/{terminal-BddOL2fx.js → terminal-DAwgHVwQ.js} +2 -2
- package/dist/{todoCli-B62mo9af.js → todoCli-BWm5nZE0.js} +2 -2
- package/dist/{todoIdentity-CY1UkEs7.js → todoIdentity-BvXZKWJI.js} +1 -1
- package/dist/{tray-DJtC0yib.js → tray-BeRSozJz.js} +1 -1
- package/dist/trayApp-B9BmH_BF.js +5 -0
- package/dist/{trayApp-D0UYVT6n.js → trayApp-CV_d2Ufj.js} +2 -2
- package/dist/{ts-Bm6FhYS4.js → ts-BPP6xmfi.js} +5 -5
- package/dist/{versionChecker-CFyZKym2.js → versionChecker-Qho6qsKf.js} +2 -2
- package/dist/{webrtcLink-CHLgPKWj.js → webrtcLink-SsInrVHU.js} +1 -1
- package/dist/{webrtcLink-bWxiCBTV.js → webrtcLink-va_gAR77.js} +1 -1
- package/dist/{webrtcRemote-RmkbzY6Y.js → webrtcRemote-BlGyNZDC.js} +2 -2
- package/dist/{widget-C6-P26Ue.js → widget-o1BO92I5.js} +3 -3
- package/dist/{workspaceConfig-CWGh8o36.js → workspaceConfig-BLWB5Wu9.js} +1 -1
- package/dist/{ws-DCcdDRXH.js → ws-CzAKQ2Rd.js} +3 -3
- package/dist/{ws-DmiSL50p.js → ws-S3E4PXL1.js} +6 -5
- package/lab/ui/index.html +1 -1
- package/lab/ui/lab/posts/2026-08-11-slack-term-safe-writes.md +1 -1
- package/package.json +73 -8
- package/ts/buildEnvelope.bun.spec.ts +10 -0
- package/ts/buildEnvelope.spec.ts +10 -0
- package/ts/cli-idle.bun.spec.ts +1 -1
- package/ts/cli-idle.spec.ts +1 -1
- package/ts/composerGuard.spec.ts +12 -4
- package/ts/copyLiagent.ts +42 -0
- package/ts/remotes.ts +9 -1
- package/ts/roomReplyAlias.spec.ts +21 -0
- package/ts/roomReplyAlias.ts +67 -0
- package/ts/rustBinary.ts +19 -0
- package/ts/senderAncestry.ts +1 -3
- package/ts/serve.ts +15 -7
- package/ts/subcommands.spec.ts +6 -3
- package/ts/subcommands.ts +13 -9
- package/dist/rustBinary-Ep0x7WrS.js +0 -4
- package/dist/trayApp-CdLbJK5F.js +0 -5
|
@@ -0,0 +1,2320 @@
|
|
|
1
|
+
import { BUSY_AFTER_REPLY_MS, BUSY_MS, MAX_CHAT, ROOT_NODE, applyEdit, completeNode, dictatedDraft, draftSource, lineNode, nodePath, parseAddress, pathLabel, pickLang, pickVoice, trimHistory } from "./protocol.js";
|
|
2
|
+
import { r as countOpen, s as frontier } from "./state-tree-BZCmAknI.js";
|
|
3
|
+
import { c as watchTree, r as TREE_STRINGS_EN } from "./tree-BrR7NwMf.js";
|
|
4
|
+
import { $ as maxDetent, A as FLOAT_MAX, C as shownAgentId, E as placeBox, F as createArrivals, J as dragOffset, L as createFloatLife, P as countUnread, Q as isVisible, R as dwellMs, S as shortId, X as indexTree, Z as initialNav, _ as LONG_PRESS_MS, a as treeDefaults, b as countBadge, d as createAxisLock, et as treeNav, f as createDragFlag, g as FRONTIER_ROWS, k as FADE_MS, m as createTapper, o as uiStrings, p as createLongPress, q as dragCells, x as frontierView, z as minimizeFloat } from "./ui-strings-CJIwW5Gf.js";
|
|
5
|
+
import { a as speechRate, i as setSpeechRate, n as SPEECH_RATES } from "./speech-rate-DXeJjRTc.js";
|
|
6
|
+
|
|
7
|
+
//#region src/input.ts
|
|
8
|
+
/** Typing pauses this long before the draft is sent. */
|
|
9
|
+
const DEBOUNCE_MS = 3e3;
|
|
10
|
+
/** A burst this long or longer (dictation) that ends a sentence sends at once. */
|
|
11
|
+
const BURST_CHARS = 10;
|
|
12
|
+
const ENDS_SENTENCE = /[。..!?!?]$/;
|
|
13
|
+
/** An Enter keydown that sends: not Shift+Enter, not inside or confirming an IME composition. */
|
|
14
|
+
function enterSends(e, composing) {
|
|
15
|
+
if (e.key !== "Enter" || e.shiftKey) return false;
|
|
16
|
+
return !(e.isComposing || composing || e.keyCode === 229);
|
|
17
|
+
}
|
|
18
|
+
/** A beforeinput line break that sends (Android): not Shift+Enter, not composing. */
|
|
19
|
+
function lineBreakSends(inputType, opts) {
|
|
20
|
+
if (inputType !== "insertLineBreak" && inputType !== "insertParagraph") return false;
|
|
21
|
+
return !(opts.composing || opts.isComposing || opts.shiftEnter);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* After the draft changed outside a composition: "send" now (a dictated
|
|
25
|
+
* sentence), "arm" the debounce, or "idle" (nothing to send). `settledLen` is
|
|
26
|
+
* the draft's length at the previous settled change.
|
|
27
|
+
*/
|
|
28
|
+
function afterChange(text, settledLen, composing) {
|
|
29
|
+
if (composing) return "idle";
|
|
30
|
+
if (text.length - settledLen > BURST_CHARS && ENDS_SENTENCE.test(text.trim())) return "send";
|
|
31
|
+
return text.trim() ? "arm" : "idle";
|
|
32
|
+
}
|
|
33
|
+
const CJK_START = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul} -〿-]/u;
|
|
34
|
+
const CJK_END = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul} -〿-]$/u;
|
|
35
|
+
/**
|
|
36
|
+
* What a live (not yet final) transcript adds after the draft, as shown
|
|
37
|
+
* dimmed in the input: a space between two words, none after a space or a
|
|
38
|
+
* line break, or next to CJK text. Empty for no partial.
|
|
39
|
+
*/
|
|
40
|
+
function partialTail(draft, partial) {
|
|
41
|
+
const p = (partial ?? "").trim();
|
|
42
|
+
if (!p) return "";
|
|
43
|
+
if (!draft || /\s$/.test(draft) || CJK_END.test(draft) || CJK_START.test(p)) return p;
|
|
44
|
+
return ` ${p}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/avatar.ts
|
|
49
|
+
/** A short, stable hash (FNV-1a, 32 bits, base 36). */
|
|
50
|
+
function hashId(s) {
|
|
51
|
+
let h = 2166136261;
|
|
52
|
+
for (let i = 0; i < s.length; i++) {
|
|
53
|
+
h ^= s.charCodeAt(i);
|
|
54
|
+
h = Math.imul(h, 16777619);
|
|
55
|
+
}
|
|
56
|
+
return (h >>> 0).toString(36);
|
|
57
|
+
}
|
|
58
|
+
/** The URLs in an AvatarSrc, in order, empty ones dropped. */
|
|
59
|
+
function avatarSrcs(src) {
|
|
60
|
+
if (typeof src === "string") return src ? [src] : [];
|
|
61
|
+
if (Array.isArray(src)) return src.filter((u) => typeof u === "string" && !!u);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
/** The fallback's letter: the name's first grapheme, upper-cased; "?" for none. */
|
|
65
|
+
function initialOf(name) {
|
|
66
|
+
const s = (name ?? "").trim();
|
|
67
|
+
if (!s) return "?";
|
|
68
|
+
const Seg = Intl.Segmenter;
|
|
69
|
+
return ((Seg ? new Seg(void 0, { granularity: "grapheme" }).segment(s)[Symbol.iterator]().next().value?.segment ?? s[0] : Array.from(s)[0] ?? s) ?? s).toLocaleUpperCase();
|
|
70
|
+
}
|
|
71
|
+
/** Relative luminance (WCAG) of an oklch colour, clamped into sRGB. */
|
|
72
|
+
function oklchLuminance(l, c, hDeg) {
|
|
73
|
+
const h = hDeg * Math.PI / 180;
|
|
74
|
+
const a = c * Math.cos(h);
|
|
75
|
+
const b = c * Math.sin(h);
|
|
76
|
+
const l_ = (l + .3963377774 * a + .2158037573 * b) ** 3;
|
|
77
|
+
const m_ = (l - .1055613458 * a - .0638541728 * b) ** 3;
|
|
78
|
+
const s_ = (l - .0894841775 * a - 1.291485548 * b) ** 3;
|
|
79
|
+
const clamp = (v) => Math.min(1, Math.max(0, v));
|
|
80
|
+
const r = clamp(4.0767416621 * l_ - 3.3077115913 * m_ + .2309699292 * s_);
|
|
81
|
+
const g = clamp(-1.2684380046 * l_ + 2.6097574011 * m_ - .3413193965 * s_);
|
|
82
|
+
const bl = clamp(-.0041960863 * l_ - .7034186147 * m_ + 1.707614701 * s_);
|
|
83
|
+
return .2126 * r + .7152 * g + .0722 * bl;
|
|
84
|
+
}
|
|
85
|
+
/** WCAG contrast ratio of two relative luminances. */
|
|
86
|
+
const contrastRatio = (y1, y2) => (Math.max(y1, y2) + .05) / (Math.min(y1, y2) + .05);
|
|
87
|
+
const AVATAR_L = .7;
|
|
88
|
+
const AVATAR_C = .12;
|
|
89
|
+
const INK$1 = [{
|
|
90
|
+
css: "oklch(0.2 0 0)",
|
|
91
|
+
y: oklchLuminance(.2, 0, 0)
|
|
92
|
+
}, {
|
|
93
|
+
css: "oklch(1 0 0)",
|
|
94
|
+
y: 1
|
|
95
|
+
}];
|
|
96
|
+
/**
|
|
97
|
+
* The fallback's colours for a seed: oklch(0.7 0.12 <hue>), the hue from a
|
|
98
|
+
* hash so a person keeps one colour, the letter in whichever ink contrasts
|
|
99
|
+
* more (at least 4.5:1 on every hue).
|
|
100
|
+
*/
|
|
101
|
+
function avatarColors(seed) {
|
|
102
|
+
const hue = parseInt(hashId(`avatar:${seed}`), 36) % 360;
|
|
103
|
+
const y = oklchLuminance(AVATAR_L, AVATAR_C, hue);
|
|
104
|
+
const ink = INK$1.reduce((best, i) => contrastRatio(i.y, y) > contrastRatio(best.y, y) ? i : best);
|
|
105
|
+
return {
|
|
106
|
+
bg: `oklch(${AVATAR_L} ${AVATAR_C} ${hue})`,
|
|
107
|
+
fg: ink.css,
|
|
108
|
+
hue
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A line's avatar. The agent's is `agentAvatar` (the host app's logo, a URL
|
|
113
|
+
* or an element) named by its label. Anyone else's comes from `avatarFor`
|
|
114
|
+
* (the host's resolver), else the line's own `sender`; the viewer's own
|
|
115
|
+
* lines (owner lines on the owner's page, friend lines on a friend's) fill
|
|
116
|
+
* the gaps from `user` and are named "you".
|
|
117
|
+
*/
|
|
118
|
+
function lineAvatar(m, opts) {
|
|
119
|
+
if (m.from === "agent") {
|
|
120
|
+
const name = m.agentId || m.label || "agent";
|
|
121
|
+
const a = opts.agentAvatar;
|
|
122
|
+
const isSrc = typeof a === "string" || Array.isArray(a);
|
|
123
|
+
return {
|
|
124
|
+
srcs: isSrc ? avatarSrcs(a) : [],
|
|
125
|
+
...a && !isSrc ? { node: a } : {},
|
|
126
|
+
name,
|
|
127
|
+
initialFrom: name,
|
|
128
|
+
seed: `agent:${name}`,
|
|
129
|
+
mine: false
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const mine = m.from === (opts.friend ? "friend" : "owner");
|
|
133
|
+
const own = opts.avatarFor?.(m) ?? void 0;
|
|
134
|
+
const s = {
|
|
135
|
+
...mine ? opts.user : void 0,
|
|
136
|
+
...m.sender,
|
|
137
|
+
...own
|
|
138
|
+
};
|
|
139
|
+
const srcs = [
|
|
140
|
+
...avatarSrcs(own?.avatar),
|
|
141
|
+
...mine ? avatarSrcs(opts.user?.avatar) : [],
|
|
142
|
+
...avatarSrcs(m.sender?.avatar)
|
|
143
|
+
];
|
|
144
|
+
const name = s.name || (m.from === "friend" ? "friend" : "owner");
|
|
145
|
+
return {
|
|
146
|
+
srcs: [...new Set(srcs)],
|
|
147
|
+
name: mine ? "you" : name,
|
|
148
|
+
initialFrom: name,
|
|
149
|
+
seed: s.id || m.thread || m.from,
|
|
150
|
+
mine
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/tree-chips.ts
|
|
156
|
+
/**
|
|
157
|
+
* The state tree as the chat box shows it, driven by treeNav (tree-nav.ts):
|
|
158
|
+
* the top-level nodes as a row of chips (status glyph, title, 〔open count〕;
|
|
159
|
+
* the row scrolls sideways), and the one open branch below it as lines, each
|
|
160
|
+
* level indented, as deep as its detents opened it. Box-less, like the log:
|
|
161
|
+
* text with the halo, an underline on the focused one. Tapping a chip or a
|
|
162
|
+
* line focuses it and opens it a level. Plain DOM, text only.
|
|
163
|
+
*
|
|
164
|
+
* The pure parts (navIndexOf, openTopOf, branchLines) are tested in
|
|
165
|
+
* test/tree-chips.test.ts; renderTreeChips needs a document.
|
|
166
|
+
*/
|
|
167
|
+
/** Ids are index paths ("0", "0.2", "0.2.1"), so a reload keeps the focus. */
|
|
168
|
+
function navIndexOf(root) {
|
|
169
|
+
const byId = /* @__PURE__ */ new Map();
|
|
170
|
+
return {
|
|
171
|
+
ix: indexTree(root?.children ?? [], (n) => n.children, (n, parent, i) => {
|
|
172
|
+
const id = parent === null ? String(i) : `${parent}.${i}`;
|
|
173
|
+
byId.set(id, n);
|
|
174
|
+
return id;
|
|
175
|
+
}),
|
|
176
|
+
byId
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** The open branch's top chip: the one the accordion has open (null: none). */
|
|
180
|
+
function openTopOf(nav) {
|
|
181
|
+
const ids = Object.keys(nav.expanded);
|
|
182
|
+
return ids.length ? ids[0].split(".")[0] : null;
|
|
183
|
+
}
|
|
184
|
+
/** The open branch's lines, in order: every visible descendant of its top chip. */
|
|
185
|
+
function branchLines(ix, nav) {
|
|
186
|
+
const top = openTopOf(nav);
|
|
187
|
+
const lines = [];
|
|
188
|
+
const walk = (id, level) => {
|
|
189
|
+
for (const c of ix.children.get(id) ?? []) {
|
|
190
|
+
if (!isVisible(ix, nav, c)) continue;
|
|
191
|
+
lines.push({
|
|
192
|
+
id: c,
|
|
193
|
+
level
|
|
194
|
+
});
|
|
195
|
+
walk(c, level + 1);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
if (top !== null) walk(top, 0);
|
|
199
|
+
return lines;
|
|
200
|
+
}
|
|
201
|
+
const GLYPH = {
|
|
202
|
+
open: "○",
|
|
203
|
+
doing: "◐",
|
|
204
|
+
blocked: "!",
|
|
205
|
+
done: "✓"
|
|
206
|
+
};
|
|
207
|
+
const INK = {
|
|
208
|
+
open: "oklch(0.62 0.03 260)",
|
|
209
|
+
doing: "oklch(0.6 0.16 250)",
|
|
210
|
+
blocked: "oklch(0.7 0.16 70)",
|
|
211
|
+
done: "oklch(0.62 0.15 150)"
|
|
212
|
+
};
|
|
213
|
+
const ACCENT = "oklch(0.58 0.21 33)";
|
|
214
|
+
/** The chips' CSS, for the shadow root that holds them (the box's). */
|
|
215
|
+
const TREE_CHIPS_CSS = `
|
|
216
|
+
.chips { min-width: 0; max-width: 100%; display: flex; flex-direction: column; gap: 2px; line-height: 1.35; }
|
|
217
|
+
.chiprow { pointer-events: auto; display: flex; column-gap: 12px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; white-space: nowrap; scrollbar-width: none; }
|
|
218
|
+
.chiprow::-webkit-scrollbar { display: none; }
|
|
219
|
+
.chips button { all: unset; cursor: pointer; pointer-events: auto; text-align: start; }
|
|
220
|
+
.chiprow > button { flex: none; min-height: 24px; }
|
|
221
|
+
.chiprow > button[data-open] { font-weight: 600; }
|
|
222
|
+
.chips button[data-focus] .lbl { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; }
|
|
223
|
+
.chiplines { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
224
|
+
.chiplines .note { margin: 0; padding-inline-start: 12px; font-size: 10px; opacity: .6; }
|
|
225
|
+
.chips .g { display: inline-block; margin-inline-end: 2px; }
|
|
226
|
+
.chips .done { opacity: .6; }
|
|
227
|
+
.chips .need { margin-inline-start: 2px; font-weight: 700; color: ${ACCENT}; }
|
|
228
|
+
.chips .cnt { margin-inline-start: 2px; opacity: .6; font-variant-numeric: tabular-nums; }
|
|
229
|
+
.chips .tw { margin-inline-end: 2px; opacity: .6; }
|
|
230
|
+
.chips .status { margin: 0; opacity: .6; }
|
|
231
|
+
`;
|
|
232
|
+
const el = (tag, cls, text) => {
|
|
233
|
+
const e = document.createElement(tag);
|
|
234
|
+
if (cls) e.className = cls;
|
|
235
|
+
if (text !== void 0) e.textContent = text;
|
|
236
|
+
return e;
|
|
237
|
+
};
|
|
238
|
+
/** A node's label: status glyph (blinking while in progress), title, (!), 〔open〕 when its branch is shut. */
|
|
239
|
+
function label(node, shut, needsYou) {
|
|
240
|
+
const box = el("span", "lbl");
|
|
241
|
+
if (node.status) {
|
|
242
|
+
const g = el("span", `g${node.status === "doing" ? " blink" : ""}`, GLYPH[node.status]);
|
|
243
|
+
g.setAttribute("aria-hidden", "true");
|
|
244
|
+
g.style.color = INK[node.status];
|
|
245
|
+
box.append(g);
|
|
246
|
+
}
|
|
247
|
+
box.append(el("span", node.status === "done" ? "done" : void 0, node.title || "—"));
|
|
248
|
+
if (node.needsOwner && node.status !== "done") {
|
|
249
|
+
const n = el("span", "need", "(!)");
|
|
250
|
+
n.setAttribute("aria-label", needsYou);
|
|
251
|
+
box.append(n);
|
|
252
|
+
}
|
|
253
|
+
const open = shut ? countOpen(node).open : 0;
|
|
254
|
+
if (open > 0) box.append(el("span", "cnt", `〔${open}〕`));
|
|
255
|
+
return box;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* The chips and the open branch, as one element. Taps never take the
|
|
259
|
+
* input's focus (pointerdown is prevented), so the draft and the open log stay.
|
|
260
|
+
*/
|
|
261
|
+
function renderTreeChips(o) {
|
|
262
|
+
if (o.status || !o.root || !o.ix.roots.length) {
|
|
263
|
+
const p = el("p", "status", o.status ?? o.empty);
|
|
264
|
+
p.dataset.testid = "tree-chips-status";
|
|
265
|
+
return p;
|
|
266
|
+
}
|
|
267
|
+
const keep = (b, id) => {
|
|
268
|
+
b.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
269
|
+
b.addEventListener("click", () => o.onNav({
|
|
270
|
+
type: "tapChip",
|
|
271
|
+
id
|
|
272
|
+
}));
|
|
273
|
+
};
|
|
274
|
+
const top = openTopOf(o.nav);
|
|
275
|
+
const focus = o.nav.focusId;
|
|
276
|
+
const wrap = el("div", "chips");
|
|
277
|
+
wrap.dataset.testid = "tree-chips";
|
|
278
|
+
const row = el("div", "chiprow");
|
|
279
|
+
row.setAttribute("role", "tablist");
|
|
280
|
+
row.setAttribute("aria-label", o.label);
|
|
281
|
+
for (const id of o.ix.roots) {
|
|
282
|
+
const node = o.byId.get(id);
|
|
283
|
+
const b = el("button");
|
|
284
|
+
b.setAttribute("type", "button");
|
|
285
|
+
b.setAttribute("role", "tab");
|
|
286
|
+
b.setAttribute("aria-selected", String(id === top));
|
|
287
|
+
b.dataset.id = id;
|
|
288
|
+
if (id === focus) b.dataset.focus = "";
|
|
289
|
+
if (id === top) b.dataset.open = "";
|
|
290
|
+
b.append(label(node, id !== top, o.needsYou));
|
|
291
|
+
keep(b, id);
|
|
292
|
+
row.append(b);
|
|
293
|
+
}
|
|
294
|
+
wrap.append(row);
|
|
295
|
+
const lines = branchLines(o.ix, o.nav);
|
|
296
|
+
if (lines.length) {
|
|
297
|
+
const ul = el("ul", "chiplines");
|
|
298
|
+
for (const { id, level } of lines) {
|
|
299
|
+
const node = o.byId.get(id);
|
|
300
|
+
const kids = o.ix.children.get(id) ?? [];
|
|
301
|
+
const shut = kids.length > 0 && !kids.some((k) => isVisible(o.ix, o.nav, k));
|
|
302
|
+
const li = el("li");
|
|
303
|
+
li.dataset.id = id;
|
|
304
|
+
li.dataset.level = String(level);
|
|
305
|
+
li.style.paddingInlineStart = `${level * .75}rem`;
|
|
306
|
+
const b = el("button");
|
|
307
|
+
b.setAttribute("type", "button");
|
|
308
|
+
if (id === focus) b.dataset.focus = "";
|
|
309
|
+
if (kids.length) {
|
|
310
|
+
const tw = el("span", "tw", shut ? "▸" : "▾");
|
|
311
|
+
tw.setAttribute("aria-hidden", "true");
|
|
312
|
+
b.append(tw);
|
|
313
|
+
}
|
|
314
|
+
b.append(label(node, shut, o.needsYou));
|
|
315
|
+
keep(b, id);
|
|
316
|
+
li.append(b);
|
|
317
|
+
if (node.note && !shut && level < 1) li.append(el("p", "note", node.note));
|
|
318
|
+
ul.append(li);
|
|
319
|
+
}
|
|
320
|
+
wrap.append(ul);
|
|
321
|
+
}
|
|
322
|
+
return wrap;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/box.ts
|
|
327
|
+
/** A page's label is "<path> <user agent>": the path and a short device hint. */
|
|
328
|
+
function pageHint(label) {
|
|
329
|
+
const [path = "", ...rest] = label.split(" ");
|
|
330
|
+
const ua = rest.join(" ");
|
|
331
|
+
const device = /iPhone/.test(ua) ? "iPhone" : /iPad/.test(ua) ? "iPad" : /Android/.test(ua) ? "Android" : /Macintosh|Mac OS/.test(ua) ? "Mac" : /Windows/.test(ua) ? "Windows" : /Linux|X11/.test(ua) ? "Linux" : ua.split(" ").find((w) => w !== "pwa") ?? "";
|
|
332
|
+
const pwa = /(^| )pwa( |$)/.test(ua) ? " app" : "";
|
|
333
|
+
return device ? `${path} · ${device}${pwa}` : path;
|
|
334
|
+
}
|
|
335
|
+
const Base = typeof HTMLElement === "undefined" ? class {} : HTMLElement;
|
|
336
|
+
const synth = () => typeof speechSynthesis === "undefined" ? null : speechSynthesis;
|
|
337
|
+
let unlocked = false;
|
|
338
|
+
/** iOS speaks only after a user gesture: an empty utterance on the first one unlocks it. */
|
|
339
|
+
function unlockSpeech() {
|
|
340
|
+
const s = synth();
|
|
341
|
+
if (unlocked || !s) return;
|
|
342
|
+
unlocked = true;
|
|
343
|
+
s.speak(new SpeechSynthesisUtterance(""));
|
|
344
|
+
}
|
|
345
|
+
/** Drop whatever is being read and queued — the owner has started typing. */
|
|
346
|
+
function cancelSpeech() {
|
|
347
|
+
const s = synth();
|
|
348
|
+
if (s && (s.speaking || s.pending)) s.cancel();
|
|
349
|
+
}
|
|
350
|
+
const reducedMotion = () => typeof matchMedia !== "undefined" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
351
|
+
/** env(safe-area-inset-*) in px, measured with a probe. */
|
|
352
|
+
function safeInsets() {
|
|
353
|
+
const probe = document.createElement("div");
|
|
354
|
+
probe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left)";
|
|
355
|
+
document.body.append(probe);
|
|
356
|
+
const cs = getComputedStyle(probe);
|
|
357
|
+
const v = {
|
|
358
|
+
top: Number.parseFloat(cs.paddingTop) || 0,
|
|
359
|
+
left: Number.parseFloat(cs.paddingLeft) || 0,
|
|
360
|
+
right: Number.parseFloat(cs.paddingRight) || 0
|
|
361
|
+
};
|
|
362
|
+
probe.remove();
|
|
363
|
+
return v;
|
|
364
|
+
}
|
|
365
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
366
|
+
/** HH:MM:SS today, YYYY-MM-DD HH:MM before — local time, ISO order. */
|
|
367
|
+
function when(at, now = /* @__PURE__ */ new Date()) {
|
|
368
|
+
const d = new Date(at);
|
|
369
|
+
const day = (x) => `${x.getFullYear()}-${pad(x.getMonth() + 1)}-${pad(x.getDate())}`;
|
|
370
|
+
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
371
|
+
return day(d) === day(now) ? `${hm}:${pad(d.getSeconds())}` : `${day(d)} ${hm}`;
|
|
372
|
+
}
|
|
373
|
+
/** In the input while the mark is held. */
|
|
374
|
+
const DRAG_HINT = "drag: move · tap: minimize · double-tap: menu";
|
|
375
|
+
const MARK_HINT = "Tap: minimize or restore · double-tap: menu · drag: move the chat";
|
|
376
|
+
const TREE_HINT = "Tap: show or hide the state tree · drag right: open the focused node deeper · drag left: fold it · arrow keys: move";
|
|
377
|
+
/** The input grows to about five lines, then scrolls inside itself. */
|
|
378
|
+
const INPUT_MAX_PX = 128;
|
|
379
|
+
const ICONS = {
|
|
380
|
+
lock: `<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden="true"><path fill="currentColor" d="M5 7V5a3 3 0 0 1 6 0v2h1v7H4V7zm1.5 0h3V5a1.5 1.5 0 0 0-3 0z"/></svg>`,
|
|
381
|
+
sparkle: `<svg viewBox="0 0 256 256" width="1em" height="1em" aria-hidden="true"><path fill="currentColor" d="M104,40Q112,136,200,144Q112,152,104,248Q96,152,8,144Q96,136,104,40ZM200,8Q204,48,244,52Q204,56,200,96Q196,56,156,52Q196,48,200,8Z"/></svg>`,
|
|
382
|
+
speaker: `<svg viewBox="0 0 16 16" width="12" height="12" aria-hidden="true"><path fill="currentColor" d="M2 6h3l4-3v10l-4-3H2z"/><path d="M11 5.5a3.5 3.5 0 0 1 0 5M12.5 3.5a6 6 0 0 1 0 9" stroke="currentColor" fill="none" stroke-width="1.3"/></svg>`,
|
|
383
|
+
mark: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 3h10a4 4 0 0 1 4 4v7a4 4 0 0 1-4 4h-7l-5 3v-3.6A4 4 0 0 1 3 14V7a4 4 0 0 1 4-4Z"/><path d="M10.5 6.5v8"/><path d="M15.5 5.6l.55 1.35 1.35.55-1.35.55-.55 1.35-.55-1.35-1.35-.55 1.35-.55Z" fill="oklch(0.72 0.17 150)" stroke="oklch(0.72 0.17 150)" stroke-width="1"/></svg>`,
|
|
384
|
+
tree: `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="10" width="7" height="4.5" rx="1.5"/><rect x="14" y="16.5" width="7" height="4.5" rx="1.5"/><path d="M6.5 8v10.75H14M6.5 12.25H14"/></svg>`
|
|
385
|
+
};
|
|
386
|
+
const CSS = `
|
|
387
|
+
:host {
|
|
388
|
+
all: initial;
|
|
389
|
+
position: fixed;
|
|
390
|
+
right: 0.5rem;
|
|
391
|
+
bottom: calc(0.5rem + env(safe-area-inset-bottom));
|
|
392
|
+
z-index: var(--liagent-z, 40);
|
|
393
|
+
/* Click-through: only the bar, the menu, the pairing line and opted-in rows take pointer events. */
|
|
394
|
+
pointer-events: none;
|
|
395
|
+
font: 12px/1.35 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
396
|
+
color: var(--liagent-fg, CanvasText);
|
|
397
|
+
--bg: var(--liagent-bg, Canvas);
|
|
398
|
+
--glass: color-mix(in oklch, var(--bg) 55%, transparent);
|
|
399
|
+
--line: color-mix(in oklch, currentColor 25%, transparent);
|
|
400
|
+
--muted: color-mix(in oklch, currentColor 55%, transparent);
|
|
401
|
+
/* Lines straight over the page (floating, frontier): a stronger halo, never a fill. */
|
|
402
|
+
--halo: 0 0 1px var(--bg), 0 0 2px var(--bg), 0 0 4px var(--bg), 0 0 8px var(--bg);
|
|
403
|
+
}
|
|
404
|
+
* { box-sizing: border-box; }
|
|
405
|
+
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
|
406
|
+
.halo { text-shadow: 0 0 3px var(--bg), 0 0 6px var(--bg); }
|
|
407
|
+
.box {
|
|
408
|
+
width: var(--liagent-width, min(20rem, calc(100vw - 1rem)));
|
|
409
|
+
display: flex; flex-direction: column; pointer-events: none;
|
|
410
|
+
opacity: .6; transition: opacity .15s;
|
|
411
|
+
}
|
|
412
|
+
.box:focus-within, .box:active, .box.on { opacity: 1; }
|
|
413
|
+
/* The one scroller: the tree (when open) on top, the log below. Compact at rest. */
|
|
414
|
+
.column {
|
|
415
|
+
max-height: 4.4rem; min-height: 3rem; overflow-y: auto; padding: 6px 8px;
|
|
416
|
+
scrollbar-width: none; transition: max-height .15s ease-out;
|
|
417
|
+
}
|
|
418
|
+
.column::-webkit-scrollbar { display: none; }
|
|
419
|
+
.column.wide { max-height: 30vh; }
|
|
420
|
+
.column.fade { -webkit-mask-image: linear-gradient(to bottom, transparent, black .9rem); mask-image: linear-gradient(to bottom, transparent, black .9rem); }
|
|
421
|
+
.column.wide.fade { -webkit-mask-image: linear-gradient(to bottom, transparent, black 2.5rem); mask-image: linear-gradient(to bottom, transparent, black 2.5rem); }
|
|
422
|
+
.treepanel { pointer-events: none; display: flex; flex-direction: row-reverse; align-items: flex-start; min-width: 0; max-width: 100%; padding-bottom: 4px; animation: appear .18s ease-out; }
|
|
423
|
+
@keyframes appear { from { opacity: 0; } }
|
|
424
|
+
.treepanel .tbody { flex: 1; min-width: 0; }
|
|
425
|
+
.treepanel .x { pointer-events: auto; flex: none; position: relative; padding: 0 6px; font-size: 10px; color: var(--muted); }
|
|
426
|
+
.ftree .tbody { max-height: 4.4rem; overflow-y: auto; scrollbar-width: none; }
|
|
427
|
+
.ftree .tbody.wide { max-height: 30vh; }
|
|
428
|
+
.ftree .tbody.fade { -webkit-mask-image: linear-gradient(to bottom, transparent, black .9rem); mask-image: linear-gradient(to bottom, transparent, black .9rem); }
|
|
429
|
+
.blink { animation: blink 1.6s ease-in-out infinite; }
|
|
430
|
+
@keyframes blink { 50% { opacity: .35; } }
|
|
431
|
+
.log { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
432
|
+
.log li { display: flex; align-items: flex-start; gap: 6px; }
|
|
433
|
+
.text { flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; padding-top: 1px; }
|
|
434
|
+
.log .hint { opacity: .6; display: block; }
|
|
435
|
+
.polished { all: unset; pointer-events: auto; cursor: pointer; margin-inline-start: 4px; font-size: .85em; opacity: .55; vertical-align: baseline; }
|
|
436
|
+
.polished[aria-pressed="true"] { opacity: .9; }
|
|
437
|
+
.raw { opacity: .65; }
|
|
438
|
+
.log li.node { border-inline-start: 2px solid var(--node); padding-inline-start: 4px; }
|
|
439
|
+
.log .sub { display: block; font-size: 10px; line-height: 1.2; opacity: .65; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
440
|
+
/* Fixed size whatever loads, so a late picture shifts nothing. */
|
|
441
|
+
.av {
|
|
442
|
+
--s: var(--liagent-avatar, 18px);
|
|
443
|
+
flex: none; width: var(--s); height: var(--s); border-radius: 50%; overflow: hidden;
|
|
444
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
445
|
+
font-weight: 600; font-size: calc(var(--s) * 0.55); line-height: 1; text-shadow: none; user-select: none;
|
|
446
|
+
}
|
|
447
|
+
img.av { display: block; object-fit: cover; }
|
|
448
|
+
.av > * { max-width: 100%; max-height: 100%; }
|
|
449
|
+
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
450
|
+
.warn { padding: 4px 8px 0; font-size: 10px; color: oklch(0.6 0.17 45); }
|
|
451
|
+
.asks { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 0 0 4px; }
|
|
452
|
+
.asks:empty { display: none; }
|
|
453
|
+
.ask {
|
|
454
|
+
pointer-events: auto; display: inline-flex; align-items: center; gap: 4px;
|
|
455
|
+
border-radius: 999px; padding: 2px 2px 2px 6px; font-size: 10px; color: oklch(1 0 0);
|
|
456
|
+
background: oklch(0.45 0.12 30); text-shadow: none;
|
|
457
|
+
}
|
|
458
|
+
.ask b { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; }
|
|
459
|
+
.ask button { border-radius: 999px; padding: 0 6px; }
|
|
460
|
+
.ask .y { background: oklch(0.55 0.15 145); }
|
|
461
|
+
.ask .c { background: oklch(0.5 0.1 230); }
|
|
462
|
+
.ask .n { background: oklch(0.4 0.02 260); }
|
|
463
|
+
.ask .tag { opacity: .85; }
|
|
464
|
+
.askwrap { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; pointer-events: auto; }
|
|
465
|
+
.pagelist {
|
|
466
|
+
display: flex; flex-direction: column; gap: 1px; padding: 3px 8px; border-radius: 8px; font-size: 11px;
|
|
467
|
+
border: 1px solid var(--line); background: var(--glass); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
468
|
+
}
|
|
469
|
+
.pagelist label { display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
|
470
|
+
.pagelist input { margin: 0; }
|
|
471
|
+
/* The bar's row: two grid columns, the bar's and a spacer's, trading 1fr ⇄ 0fr. */
|
|
472
|
+
.rowbox { position: relative; display: grid; grid-template-columns: 1fr 0fr; transition: grid-template-columns .18s ease-out; }
|
|
473
|
+
.min .rowbox { grid-template-columns: 0fr 1fr; }
|
|
474
|
+
.bar {
|
|
475
|
+
pointer-events: auto; position: relative; display: flex; align-items: flex-end;
|
|
476
|
+
padding: 2px; border-radius: 1.125rem; border: 1px solid var(--line); background: var(--glass);
|
|
477
|
+
backdrop-filter: blur(12px) saturate(1.5); -webkit-backdrop-filter: blur(12px) saturate(1.5);
|
|
478
|
+
touch-action: none; text-shadow: none;
|
|
479
|
+
}
|
|
480
|
+
.min .bar { padding: 1px; }
|
|
481
|
+
/* 40px targets: the hit area reaches 40px whatever the drawn size. */
|
|
482
|
+
.tap { position: relative; flex: none; width: 40px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; }
|
|
483
|
+
.tap::before { content: ""; position: absolute; left: 50%; top: 50%; width: max(100%, 40px); height: max(100%, 40px); transform: translate(-50%, -50%); }
|
|
484
|
+
.mark, .treebtn { touch-action: none; cursor: grab; }
|
|
485
|
+
.treebtn { cursor: pointer; }
|
|
486
|
+
.mark .dot { position: absolute; top: 4px; inset-inline-end: 6px; width: 6px; height: 6px; border-radius: 50%; }
|
|
487
|
+
.evals {
|
|
488
|
+
position: absolute; top: 0; inset-inline-end: 2px; min-width: 14px; height: 14px; padding: 0 2px; border-radius: 999px;
|
|
489
|
+
display: inline-flex; align-items: center; justify-content: center; font-size: 8px; line-height: 1; color: oklch(1 0 0);
|
|
490
|
+
background: oklch(0.55 0.15 30); font-variant-numeric: tabular-nums;
|
|
491
|
+
}
|
|
492
|
+
.evals.ran { animation: pulse 1s ease-in-out infinite; }
|
|
493
|
+
@keyframes pulse { 50% { opacity: .4; } }
|
|
494
|
+
.thint { pointer-events: none; position: absolute; inset-inline-start: 0; bottom: 100%; margin-bottom: 8px; white-space: nowrap; font-size: 10px; text-shadow: var(--halo); }
|
|
495
|
+
.slot { position: relative; display: flex; flex: 1; width: 0; min-width: 0; transition: opacity .18s ease-out; }
|
|
496
|
+
.min .slot { opacity: 0; max-height: 2rem; overflow: hidden; }
|
|
497
|
+
textarea {
|
|
498
|
+
pointer-events: auto; display: block; flex: 1; min-width: 0; resize: none; margin: 0; align-self: flex-end;
|
|
499
|
+
font: 16px/1.35 inherit; font-family: inherit; color: inherit; height: 2.125rem; min-height: 2.125rem; max-height: ${INPUT_MAX_PX}px;
|
|
500
|
+
padding: 4px 8px; border: 1px solid transparent; background: transparent; outline: none;
|
|
501
|
+
overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y;
|
|
502
|
+
}
|
|
503
|
+
textarea.pe { padding-inline-end: 24px; }
|
|
504
|
+
textarea::placeholder { color: color-mix(in oklch, currentColor 45%, transparent); }
|
|
505
|
+
.mirror {
|
|
506
|
+
pointer-events: none; position: absolute; inset: 0; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere;
|
|
507
|
+
border: 1px solid transparent; padding: 4px 8px; font: 16px/1.35 inherit; font-family: inherit;
|
|
508
|
+
}
|
|
509
|
+
.mirror .ghost { visibility: hidden; }
|
|
510
|
+
.mirror .partial { color: var(--muted); }
|
|
511
|
+
.notice { margin: 0; flex: 1; min-width: 0; padding: 8px; opacity: .8; }
|
|
512
|
+
.spin {
|
|
513
|
+
pointer-events: none; position: absolute; inset-inline-end: 6px; top: 50%; margin-top: -6px;
|
|
514
|
+
width: 12px; height: 12px; border-radius: 50%;
|
|
515
|
+
border: 2px solid var(--line); border-top-color: var(--liagent-agent, oklch(0.55 0.14 150));
|
|
516
|
+
animation: rot .8s linear infinite;
|
|
517
|
+
}
|
|
518
|
+
@keyframes rot { to { transform: rotate(360deg); } }
|
|
519
|
+
.mic { pointer-events: auto; flex: none; display: flex; align-items: center; min-height: 32px; }
|
|
520
|
+
.unread {
|
|
521
|
+
pointer-events: none; position: absolute; top: -6px; inset-inline-end: -6px; border-radius: 999px; padding: 0 4px;
|
|
522
|
+
font-size: 10px; line-height: 16px; color: oklch(1 0 0); background: oklch(0.55 0.2 260);
|
|
523
|
+
}
|
|
524
|
+
.drain {
|
|
525
|
+
position: absolute; inset: 0 12px auto 12px; height: 2px; background: oklch(0.55 0.2 260 / .6);
|
|
526
|
+
transform-origin: left; animation: drain var(--drain-ms) linear both; pointer-events: none;
|
|
527
|
+
}
|
|
528
|
+
@keyframes drain { to { transform: scaleX(0); } }
|
|
529
|
+
.aim { position: absolute; inset-inline-start: 84px; bottom: 100%; margin-bottom: 4px; max-width: calc(100% - 6rem); display: flex; flex-direction: column; align-items: flex-start; gap: 4px; pointer-events: none; }
|
|
530
|
+
.aim > * { pointer-events: auto; }
|
|
531
|
+
.target { display: inline-flex; align-items: center; gap: 4px; max-width: 100%; border: 1px solid var(--node, var(--line)); border-radius: 999px; padding: 0 6px; font-size: 10px; line-height: 16px; background: var(--glass); }
|
|
532
|
+
.target.preview { opacity: .7; }
|
|
533
|
+
.target button { all: unset; cursor: pointer; line-height: 1; }
|
|
534
|
+
.nodes { list-style: none; margin: 0; padding: 2px 0; max-width: 100%; border: 1px solid var(--line); border-radius: 8px; font-size: 11px; background: var(--glass); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
|
|
535
|
+
.nodes button { all: unset; display: block; cursor: pointer; padding: 1px 8px; }
|
|
536
|
+
/* Minimized: the tree, the floating lines and the frontier, on the bar's side. */
|
|
537
|
+
.floatbox {
|
|
538
|
+
pointer-events: none; position: absolute; bottom: 100%; margin-bottom: 4px; left: 0;
|
|
539
|
+
width: max-content; max-width: min(20rem, calc(100vw - 16px));
|
|
540
|
+
display: flex; flex-direction: column; gap: 4px; align-items: flex-start;
|
|
541
|
+
}
|
|
542
|
+
.floatbox[data-side="center"] { align-items: center; text-align: center; }
|
|
543
|
+
.floatbox[data-side="right"] { align-items: flex-end; text-align: right; }
|
|
544
|
+
.floats, .frontier { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; align-items: inherit; max-width: 100%; }
|
|
545
|
+
.floats:empty, .frontier:empty, .ftree:empty { display: none; }
|
|
546
|
+
.float {
|
|
547
|
+
pointer-events: auto; max-width: 100%; padding: 2px 0; white-space: pre-wrap; overflow-wrap: anywhere;
|
|
548
|
+
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden;
|
|
549
|
+
text-shadow: var(--halo); opacity: 1; transition: none;
|
|
550
|
+
}
|
|
551
|
+
.float.fading { opacity: 0; transition: opacity ${FADE_MS}ms ease-out; }
|
|
552
|
+
.float .who { margin-inline-end: 4px; font-weight: 500; }
|
|
553
|
+
.frontier > li { pointer-events: auto; max-width: 100%; min-width: 0; text-shadow: var(--halo); }
|
|
554
|
+
.frontier .fl { display: flex; min-width: 0; align-items: center; }
|
|
555
|
+
.frontier .fb { display: flex; min-width: 0; align-items: center; gap: 4px; padding: 2px 4px 2px 0; text-align: start; }
|
|
556
|
+
.frontier .fb[aria-expanded] { cursor: pointer; }
|
|
557
|
+
.frontier .fb:not([aria-expanded]) { cursor: default; }
|
|
558
|
+
.frontier .m { width: 12px; flex: none; text-align: center; opacity: .7; }
|
|
559
|
+
.frontier .t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
560
|
+
.frontier .n { flex: none; opacity: .7; }
|
|
561
|
+
.frontier .fav {
|
|
562
|
+
display: inline-flex; width: 16px; height: 16px; flex: none; align-items: center; justify-content: center;
|
|
563
|
+
border-radius: 50%; font-size: 9px; line-height: 1; font-weight: 600; text-shadow: none;
|
|
564
|
+
}
|
|
565
|
+
.frontier .id { position: relative; margin-inline-start: auto; flex: none; padding: 0 8px 0 6px; user-select: none; -webkit-user-select: none; font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); }
|
|
566
|
+
.frontier .full {
|
|
567
|
+
position: absolute; inset-inline-end: 0; bottom: 100%; margin-bottom: 4px; white-space: nowrap; padding: 2px 6px; border-radius: 6px;
|
|
568
|
+
border: 1px solid var(--line); background: color-mix(in oklch, var(--bg) 85%, transparent); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
|
569
|
+
}
|
|
570
|
+
.frontier .items { list-style: none; margin: 0; padding: 0 8px 4px; max-height: 9.75rem; overflow-y: auto; overscroll-behavior: contain; }
|
|
571
|
+
.frontier .items li { display: flex; min-width: 0; align-items: center; gap: 4px; padding: 1px 0; }
|
|
572
|
+
.frontier .items .p { margin-inline-start: 4px; font-size: 10px; color: var(--muted); }
|
|
573
|
+
.menu {
|
|
574
|
+
pointer-events: auto; position: absolute; bottom: 100%; left: 0; margin-bottom: 4px; min-width: 12rem; max-height: 60vh; overflow-y: auto;
|
|
575
|
+
display: flex; flex-direction: column; padding: 4px 0; border-radius: 8px; text-shadow: none;
|
|
576
|
+
border: 1px solid var(--line); background: var(--glass); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
577
|
+
}
|
|
578
|
+
.menu > * { padding: 5px 12px; text-align: start; display: flex; align-items: center; gap: 8px; }
|
|
579
|
+
.menu .status { opacity: .65; }
|
|
580
|
+
.menu .rate { gap: 4px; padding-top: 2px; padding-bottom: 2px; }
|
|
581
|
+
.menu .rate span { opacity: .65; margin-inline-end: 4px; }
|
|
582
|
+
.menu .rate button { min-width: 40px; min-height: 28px; border-radius: 4px; font-variant-numeric: tabular-nums; opacity: .7; }
|
|
583
|
+
.menu .rate button[aria-checked="true"] { opacity: 1; font-weight: 600; text-decoration: underline; text-underline-offset: 2px; }
|
|
584
|
+
.menu .sess, .menu .pg { opacity: .75; }
|
|
585
|
+
.menu .sess span, .menu .pg span { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
586
|
+
.menu .sess button, .menu .pg button { text-decoration: underline; padding: 0; }
|
|
587
|
+
.menu .head { opacity: .5; font-size: 10px; padding-bottom: 0; }
|
|
588
|
+
.menu [aria-checked="false"] svg { opacity: .45; }
|
|
589
|
+
${TREE_CHIPS_CSS}
|
|
590
|
+
@media (prefers-reduced-motion: reduce) {
|
|
591
|
+
.rowbox, .slot, .column, .box, .float.fading { transition: none; }
|
|
592
|
+
.blink, .treepanel, .evals.ran { animation: none; }
|
|
593
|
+
}
|
|
594
|
+
[hidden] { display: none !important; }
|
|
595
|
+
`;
|
|
596
|
+
/** The chat box element. Plain properties in, callbacks out; see the file header. */
|
|
597
|
+
var ChatBox = class extends Base {
|
|
598
|
+
static get observedAttributes() {
|
|
599
|
+
return ["mic-active"];
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Called with each draft that goes out, and the state-tree node it is
|
|
603
|
+
* addressed to (absent = the main thread); `opts.source` is "stt" when the
|
|
604
|
+
* line is dictation, sent unchanged. Return false if it could not be sent
|
|
605
|
+
* (the draft is kept).
|
|
606
|
+
*/
|
|
607
|
+
onSend;
|
|
608
|
+
/** The user answered a pairing request. */
|
|
609
|
+
onAnswer;
|
|
610
|
+
/** End was pressed on a session in the menu. */
|
|
611
|
+
onEnd;
|
|
612
|
+
/** End was pressed (and, for this page, confirmed) on one of the user's pages in the menu. */
|
|
613
|
+
onEndPage;
|
|
614
|
+
/**
|
|
615
|
+
* The host's avatar for a line that is not the agent's: its pictures are
|
|
616
|
+
* tried first, its name and id fill in. Return nothing to use the line's
|
|
617
|
+
* `sender` (and, on the user's own lines, `userAvatar`).
|
|
618
|
+
*/
|
|
619
|
+
avatarFor;
|
|
620
|
+
/**
|
|
621
|
+
* A voice output (`{ speak(text, id?), stop(why?) }`): replies are read
|
|
622
|
+
* through it instead of speechSynthesis. Typing calls `stop("typing")`.
|
|
623
|
+
*/
|
|
624
|
+
voice;
|
|
625
|
+
_messages = [];
|
|
626
|
+
_live = false;
|
|
627
|
+
_listeners = 0;
|
|
628
|
+
_sessions = [];
|
|
629
|
+
_pending = null;
|
|
630
|
+
_evalRan = false;
|
|
631
|
+
_busy = false;
|
|
632
|
+
busyTimer;
|
|
633
|
+
lastReplyAt = 0;
|
|
634
|
+
_pages = [];
|
|
635
|
+
_self = "";
|
|
636
|
+
/** The pages ticked in the pairing request, per pair id. */
|
|
637
|
+
picked = /* @__PURE__ */ new Map();
|
|
638
|
+
/** The page whose End was tapped once (this page needs a second tap). */
|
|
639
|
+
confirmEnd = "";
|
|
640
|
+
_friend = false;
|
|
641
|
+
_notice = "";
|
|
642
|
+
_warning = "";
|
|
643
|
+
_defaultOpen = true;
|
|
644
|
+
_placeholder = "";
|
|
645
|
+
_treeSource;
|
|
646
|
+
/** `owner/repo` that a bare `#123` in the tree links to. */
|
|
647
|
+
treeRepo;
|
|
648
|
+
/** The tree's strings (English defaults; Chinese on a zh page). */
|
|
649
|
+
treeStrings = {};
|
|
650
|
+
_strings = {};
|
|
651
|
+
_agentAvatar = null;
|
|
652
|
+
_user = {};
|
|
653
|
+
/** The nodes agents listen at: "@/…" completions. */
|
|
654
|
+
_nodes = [];
|
|
655
|
+
/** The node the next line goes to ("/" = the main thread, no chip). */
|
|
656
|
+
_target = ROOT_NODE;
|
|
657
|
+
/** Polished lines shown as first heard (their mark tapped). */
|
|
658
|
+
rawIds = /* @__PURE__ */ new Set();
|
|
659
|
+
root;
|
|
660
|
+
open = true;
|
|
661
|
+
panel = "none";
|
|
662
|
+
offline = false;
|
|
663
|
+
pos = {
|
|
664
|
+
x: 0,
|
|
665
|
+
y: 0
|
|
666
|
+
};
|
|
667
|
+
_draft = "";
|
|
668
|
+
/** The draft as dictation left it while nothing typed is in it (else null). */
|
|
669
|
+
pure = null;
|
|
670
|
+
_partial = "";
|
|
671
|
+
_micActive = false;
|
|
672
|
+
/** The input has focus: the log opens. */
|
|
673
|
+
typing = false;
|
|
674
|
+
/** The mark is held: the input shows how to use it. */
|
|
675
|
+
holding = false;
|
|
676
|
+
settledLen = 0;
|
|
677
|
+
composing = false;
|
|
678
|
+
shiftEnter = false;
|
|
679
|
+
timer;
|
|
680
|
+
els = {};
|
|
681
|
+
markDrag = createDragFlag();
|
|
682
|
+
taps = createTapper({
|
|
683
|
+
onSingle: () => {
|
|
684
|
+
this.setPanel("none");
|
|
685
|
+
this.setOpen(!this.open);
|
|
686
|
+
},
|
|
687
|
+
onDouble: () => this.setPanel(this.panel === "menu" ? "none" : "menu")
|
|
688
|
+
});
|
|
689
|
+
atEnd = true;
|
|
690
|
+
arrivals = createArrivals();
|
|
691
|
+
readSeq = 0;
|
|
692
|
+
wasOpen = true;
|
|
693
|
+
floats = /* @__PURE__ */ new Map();
|
|
694
|
+
floatHover = false;
|
|
695
|
+
/** The frontier group opened by a tap (closed again on each minimize). */
|
|
696
|
+
frontierOpen = null;
|
|
697
|
+
place = {
|
|
698
|
+
side: "left",
|
|
699
|
+
left: 0
|
|
700
|
+
};
|
|
701
|
+
ro;
|
|
702
|
+
treeStop;
|
|
703
|
+
treeRoot = null;
|
|
704
|
+
treeError = "";
|
|
705
|
+
navIx = navIndexOf(null);
|
|
706
|
+
nav = initialNav(this.navIx.ix);
|
|
707
|
+
treeAxis = createAxisLock();
|
|
708
|
+
treeDragged = createDragFlag();
|
|
709
|
+
treePress = {
|
|
710
|
+
cells: 0,
|
|
711
|
+
limitAt: null
|
|
712
|
+
};
|
|
713
|
+
userScrollAt = 0;
|
|
714
|
+
get messages() {
|
|
715
|
+
return this._messages;
|
|
716
|
+
}
|
|
717
|
+
/** Replace the log (history on connect): never read aloud. */
|
|
718
|
+
set messages(v) {
|
|
719
|
+
this._messages = trimHistory(Array.isArray(v) ? v : []);
|
|
720
|
+
this.messagesChanged();
|
|
721
|
+
}
|
|
722
|
+
/** One message that arrived live: kept once per id; an agent's is read aloud. */
|
|
723
|
+
push(m) {
|
|
724
|
+
if (!m || typeof m.text !== "string" || typeof m.id !== "string") return;
|
|
725
|
+
if (this._messages.some((x) => x.id === m.id)) return;
|
|
726
|
+
this._messages = trimHistory([...this._messages, m]);
|
|
727
|
+
this.messagesChanged(m.from !== "agent");
|
|
728
|
+
if (m.from !== "agent") return;
|
|
729
|
+
this.lastReplyAt = Date.now();
|
|
730
|
+
this.busy = false;
|
|
731
|
+
this.speak(m.text, m.id);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* The agent polished a voice line ({type:"edited"}): it changes in place,
|
|
735
|
+
* and is never read aloud again.
|
|
736
|
+
*/
|
|
737
|
+
edit(e) {
|
|
738
|
+
if (!e || typeof e.id !== "string" || typeof e.text !== "string") return;
|
|
739
|
+
this._messages = this._messages.map((m) => applyEdit(m, e));
|
|
740
|
+
this.renderLog();
|
|
741
|
+
this.renderFloatTexts();
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* One busy beat from the agent: the spinner shows for BUSY_MS unless the
|
|
745
|
+
* next beat renews it or a reply ends it. A beat right after a reply
|
|
746
|
+
* (BUSY_AFTER_REPLY_MS) is that work's tail, and ignored.
|
|
747
|
+
*/
|
|
748
|
+
beat(at = Date.now()) {
|
|
749
|
+
if (at - this.lastReplyAt < BUSY_AFTER_REPLY_MS) return;
|
|
750
|
+
this.busy = true;
|
|
751
|
+
this.busyTimer = setTimeout(() => this.busy = false, BUSY_MS);
|
|
752
|
+
}
|
|
753
|
+
set live(v) {
|
|
754
|
+
this._live = !!v;
|
|
755
|
+
this.renderStatus();
|
|
756
|
+
}
|
|
757
|
+
get live() {
|
|
758
|
+
return this._live;
|
|
759
|
+
}
|
|
760
|
+
/** The state-tree nodes agents listen at (the hub's `state.nodes`): "@/…" completions. */
|
|
761
|
+
set nodes(v) {
|
|
762
|
+
this._nodes = Array.isArray(v) ? v.filter((n) => typeof n === "string") : [];
|
|
763
|
+
this.renderAim();
|
|
764
|
+
}
|
|
765
|
+
/** The node the next line goes to ("/" = the main thread). */
|
|
766
|
+
get target() {
|
|
767
|
+
return this._target;
|
|
768
|
+
}
|
|
769
|
+
/** Address the next lines to a state-tree node ("/" = back to the main thread). */
|
|
770
|
+
setTarget(path) {
|
|
771
|
+
this._target = nodePath(path) ?? ROOT_NODE;
|
|
772
|
+
this.renderAim();
|
|
773
|
+
this.renderPlaceholder();
|
|
774
|
+
}
|
|
775
|
+
/** Agents listening now (shown in the status). */
|
|
776
|
+
set listeners(v) {
|
|
777
|
+
this._listeners = Number(v) || 0;
|
|
778
|
+
this.renderStatus();
|
|
779
|
+
}
|
|
780
|
+
/** Approved sessions: a count on the tree button for eval, and a line with End in the menu. */
|
|
781
|
+
set sessions(v) {
|
|
782
|
+
this._sessions = Array.isArray(v) ? v : [];
|
|
783
|
+
this.renderEvals();
|
|
784
|
+
this.renderMenu();
|
|
785
|
+
}
|
|
786
|
+
/** A pairing waiting for the owner's answer. */
|
|
787
|
+
set pending(v) {
|
|
788
|
+
this._pending = v ?? null;
|
|
789
|
+
this.renderAsks();
|
|
790
|
+
}
|
|
791
|
+
/** Code just ran on this page: the eval count pulses. */
|
|
792
|
+
set evalRan(v) {
|
|
793
|
+
this._evalRan = !!v;
|
|
794
|
+
this.renderEvals();
|
|
795
|
+
}
|
|
796
|
+
/** The agent is working: a spinner in the input. */
|
|
797
|
+
set busy(v) {
|
|
798
|
+
clearTimeout(this.busyTimer);
|
|
799
|
+
this._busy = !!v;
|
|
800
|
+
this.renderBusy();
|
|
801
|
+
}
|
|
802
|
+
get busy() {
|
|
803
|
+
return this._busy;
|
|
804
|
+
}
|
|
805
|
+
/** The user's connected pages (the hub's state.pages): offered when allowing, ended from the menu. */
|
|
806
|
+
set pages(v) {
|
|
807
|
+
this._pages = Array.isArray(v) ? v : [];
|
|
808
|
+
this.renderAsks();
|
|
809
|
+
this.renderMenu();
|
|
810
|
+
}
|
|
811
|
+
/** Which of `pages` this page is (the hub's state.self). */
|
|
812
|
+
set self(v) {
|
|
813
|
+
this._self = v ?? "";
|
|
814
|
+
this.renderAsks();
|
|
815
|
+
this.renderMenu();
|
|
816
|
+
}
|
|
817
|
+
/** A friend's page (not the user's): pairings are previews, with no chat scope to grant. */
|
|
818
|
+
set friend(v) {
|
|
819
|
+
this._friend = !!v;
|
|
820
|
+
this.renderAsks();
|
|
821
|
+
}
|
|
822
|
+
/** Shown instead of the input when this viewer cannot chat. */
|
|
823
|
+
set notice(v) {
|
|
824
|
+
this._notice = v ?? "";
|
|
825
|
+
this.renderInput();
|
|
826
|
+
}
|
|
827
|
+
/** A one-line note above the input (say, a message dropped by a rate limit). */
|
|
828
|
+
set warning(v) {
|
|
829
|
+
this._warning = v ?? "";
|
|
830
|
+
this.renderInput();
|
|
831
|
+
}
|
|
832
|
+
/** Open or minimized on first visit (later visits remember). Attribute: collapsed. */
|
|
833
|
+
set defaultOpen(v) {
|
|
834
|
+
this._defaultOpen = !!v;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* The state tree (src/tree.ts): a URL serving its markdown, a function
|
|
838
|
+
* returning markdown or nodes, or a feed `{subscribe(onUpdate) → unsubscribe}`.
|
|
839
|
+
* The tree button shows it; it loads again each time the tree opens, and
|
|
840
|
+
* its frontier fills the minimized bar's lines. Unset: "No tree source".
|
|
841
|
+
*/
|
|
842
|
+
set treeSource(v) {
|
|
843
|
+
this._treeSource = v;
|
|
844
|
+
this.loadTree();
|
|
845
|
+
this.renderMenu();
|
|
846
|
+
}
|
|
847
|
+
get treeSource() {
|
|
848
|
+
return this._treeSource;
|
|
849
|
+
}
|
|
850
|
+
/** Show the state tree (true) or hide it (false). */
|
|
851
|
+
setTree(v) {
|
|
852
|
+
this.setPanel(v ? "tree" : this.panel === "tree" ? "none" : this.panel);
|
|
853
|
+
}
|
|
854
|
+
/** The box's own strings (English, or Chinese on a zh page, under these). */
|
|
855
|
+
set strings(v) {
|
|
856
|
+
this._strings = v ?? {};
|
|
857
|
+
this.renderAll();
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The agent's avatar: your app's logo, as a URL (or URLs in fallback
|
|
861
|
+
* order) or an element (cloned onto each line). Attribute: agent-avatar.
|
|
862
|
+
* Unset: the agent name's initial.
|
|
863
|
+
*/
|
|
864
|
+
set agentAvatar(v) {
|
|
865
|
+
if (same(this._agentAvatar, v ?? null)) return;
|
|
866
|
+
this._agentAvatar = v ?? null;
|
|
867
|
+
this.renderLog();
|
|
868
|
+
}
|
|
869
|
+
get agentAvatar() {
|
|
870
|
+
return this._agentAvatar;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* The user's picture on their own lines: a URL, or URLs in fallback order
|
|
874
|
+
* (the app's own avatar, then the sign-in provider's image). Attribute:
|
|
875
|
+
* user-avatar.
|
|
876
|
+
*/
|
|
877
|
+
set userAvatar(v) {
|
|
878
|
+
this.setUser("avatar", v ?? void 0);
|
|
879
|
+
}
|
|
880
|
+
/** The user's name: their fallback avatar's initial. Attribute: user-name. */
|
|
881
|
+
set userName(v) {
|
|
882
|
+
this.setUser("name", v ?? void 0);
|
|
883
|
+
}
|
|
884
|
+
/** A stable id (or a hash of one, never an email): the user's fallback colour. */
|
|
885
|
+
set userId(v) {
|
|
886
|
+
this.setUser("id", v ?? void 0);
|
|
887
|
+
}
|
|
888
|
+
/** Re-render the log only when the value changed (wrappers set props on every render). */
|
|
889
|
+
setUser(k, v) {
|
|
890
|
+
if (same(this._user[k], v)) return;
|
|
891
|
+
this._user = {
|
|
892
|
+
...this._user,
|
|
893
|
+
[k]: v
|
|
894
|
+
};
|
|
895
|
+
this.renderLog();
|
|
896
|
+
}
|
|
897
|
+
set placeholder(v) {
|
|
898
|
+
this._placeholder = v;
|
|
899
|
+
this.renderPlaceholder();
|
|
900
|
+
}
|
|
901
|
+
/** The draft in the input. */
|
|
902
|
+
get draft() {
|
|
903
|
+
return this._draft;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Set the draft. With `source: "stt"` it is dictation: sent unchanged, the
|
|
907
|
+
* line goes out with source "stt" (anything typed into it makes it the
|
|
908
|
+
* user's own). It is never sent by itself while a mic is on.
|
|
909
|
+
*/
|
|
910
|
+
setDraft(text, opts) {
|
|
911
|
+
const next = String(text ?? "").slice(0, MAX_CHAT);
|
|
912
|
+
this.pure = opts?.source === "stt" ? dictatedDraft(this._draft, this.pure, next) : null;
|
|
913
|
+
this._draft = next;
|
|
914
|
+
this.settledLen = next.length;
|
|
915
|
+
this.offline = false;
|
|
916
|
+
const ta = this.els.input;
|
|
917
|
+
if (ta) {
|
|
918
|
+
ta.value = next;
|
|
919
|
+
this.fitInput();
|
|
920
|
+
ta.setSelectionRange?.(next.length, next.length);
|
|
921
|
+
ta.scrollTop = ta.scrollHeight;
|
|
922
|
+
}
|
|
923
|
+
this.renderPartial();
|
|
924
|
+
this.renderStatus();
|
|
925
|
+
this.renderAim();
|
|
926
|
+
this.arm();
|
|
927
|
+
}
|
|
928
|
+
/** A live transcript, shown dimmed after the draft and never committed (null: none). */
|
|
929
|
+
setPartial(text) {
|
|
930
|
+
this._partial = text ?? "";
|
|
931
|
+
this.renderPartial();
|
|
932
|
+
this.renderPlaceholder();
|
|
933
|
+
this.arm();
|
|
934
|
+
}
|
|
935
|
+
/** A mic is on: treated as focus (the log opens, the box is opaque). Attribute: mic-active. */
|
|
936
|
+
get micActive() {
|
|
937
|
+
return this._micActive;
|
|
938
|
+
}
|
|
939
|
+
set micActive(v) {
|
|
940
|
+
if (typeof this.toggleAttribute === "function") this.toggleAttribute("mic-active", !!v);
|
|
941
|
+
else this.applyMic(!!v);
|
|
942
|
+
}
|
|
943
|
+
attributeChangedCallback(name) {
|
|
944
|
+
if (name === "mic-active") this.applyMic(this.hasAttribute("mic-active"));
|
|
945
|
+
}
|
|
946
|
+
applyMic(v) {
|
|
947
|
+
if (v === this._micActive) return;
|
|
948
|
+
this._micActive = v;
|
|
949
|
+
this.renderLayout();
|
|
950
|
+
this.arm();
|
|
951
|
+
}
|
|
952
|
+
/** Per-device settings live under this prefix (attribute storage-key; default "liagent"). */
|
|
953
|
+
key(name) {
|
|
954
|
+
return `${this.getAttribute("storage-key") || "liagent"}:${name}`;
|
|
955
|
+
}
|
|
956
|
+
read(name) {
|
|
957
|
+
try {
|
|
958
|
+
return localStorage.getItem(this.key(name));
|
|
959
|
+
} catch {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
write(name, v) {
|
|
964
|
+
try {
|
|
965
|
+
localStorage.setItem(this.key(name), v);
|
|
966
|
+
} catch {}
|
|
967
|
+
}
|
|
968
|
+
/** The speed setting under this box's storage key. */
|
|
969
|
+
rateStore = {
|
|
970
|
+
getItem: () => this.read("speech-rate"),
|
|
971
|
+
setItem: (_k, v) => this.write("speech-rate", v)
|
|
972
|
+
};
|
|
973
|
+
/** Whether agent replies are read aloud on this device (on unless turned off). */
|
|
974
|
+
get speaking() {
|
|
975
|
+
return this.read("speak") !== "0";
|
|
976
|
+
}
|
|
977
|
+
/** How fast replies are read (1, 1.25, 1.5 or 2; per device, picked in the menu). */
|
|
978
|
+
get speechRate() {
|
|
979
|
+
return speechRate(this.rateStore);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Read a reply aloud, after any still being read (always while a mic is on):
|
|
983
|
+
* through `voice` if set, else speechSynthesis, in the language its script
|
|
984
|
+
* suggests (with a matching installed voice), at `speechRate`.
|
|
985
|
+
*/
|
|
986
|
+
speak(text, id) {
|
|
987
|
+
if (!this.speaking && !this._micActive) return;
|
|
988
|
+
if (this.voice) return this.voice.speak(text, id);
|
|
989
|
+
const s = synth();
|
|
990
|
+
if (!s) return;
|
|
991
|
+
const u = new SpeechSynthesisUtterance(text);
|
|
992
|
+
u.lang = pickLang(text);
|
|
993
|
+
u.rate = this.speechRate;
|
|
994
|
+
const voice = pickVoice(s.getVoices(), u.lang);
|
|
995
|
+
if (voice) u.voice = voice;
|
|
996
|
+
s.speak(u);
|
|
997
|
+
}
|
|
998
|
+
stopSpeech(why) {
|
|
999
|
+
if (this.voice) this.voice.stop(why);
|
|
1000
|
+
else cancelSpeech();
|
|
1001
|
+
}
|
|
1002
|
+
connectedCallback() {
|
|
1003
|
+
document.addEventListener("pointerdown", this.outside, true);
|
|
1004
|
+
document.addEventListener("keydown", this.escape, true);
|
|
1005
|
+
addEventListener("resize", this.relayout);
|
|
1006
|
+
addEventListener("orientationchange", this.relayout);
|
|
1007
|
+
globalThis.visualViewport?.addEventListener("resize", this.relayout);
|
|
1008
|
+
if (this.root) {
|
|
1009
|
+
this.loadTree();
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (this.hasAttribute("collapsed")) this._defaultOpen = false;
|
|
1013
|
+
const ph = this.getAttribute("placeholder");
|
|
1014
|
+
if (ph) this._placeholder = ph;
|
|
1015
|
+
const agentAv = this.getAttribute("agent-avatar");
|
|
1016
|
+
if (agentAv && !this._agentAvatar) this._agentAvatar = agentAv;
|
|
1017
|
+
const userAv = this.getAttribute("user-avatar");
|
|
1018
|
+
if (userAv && !this._user.avatar) this._user.avatar = userAv;
|
|
1019
|
+
const userName = this.getAttribute("user-name");
|
|
1020
|
+
if (userName && !this._user.name) this._user.name = userName;
|
|
1021
|
+
this._micActive = this.hasAttribute("mic-active");
|
|
1022
|
+
const o = this.read("open");
|
|
1023
|
+
this.open = o === null ? this._defaultOpen : o === "1";
|
|
1024
|
+
this.wasOpen = this.open;
|
|
1025
|
+
try {
|
|
1026
|
+
const v = JSON.parse(this.read("pos") ?? "null");
|
|
1027
|
+
if (v && Number.isFinite(v.x) && Number.isFinite(v.y)) this.pos = v;
|
|
1028
|
+
} catch {}
|
|
1029
|
+
synth()?.getVoices();
|
|
1030
|
+
this.root = this.attachShadow({ mode: "open" });
|
|
1031
|
+
this.build();
|
|
1032
|
+
this.messagesChanged();
|
|
1033
|
+
this.renderAll();
|
|
1034
|
+
this.loadTree();
|
|
1035
|
+
}
|
|
1036
|
+
disconnectedCallback() {
|
|
1037
|
+
clearTimeout(this.timer);
|
|
1038
|
+
this.taps.cancel();
|
|
1039
|
+
this.treeStop?.();
|
|
1040
|
+
this.treeStop = void 0;
|
|
1041
|
+
document.removeEventListener("pointerdown", this.outside, true);
|
|
1042
|
+
document.removeEventListener("keydown", this.escape, true);
|
|
1043
|
+
removeEventListener("resize", this.relayout);
|
|
1044
|
+
removeEventListener("orientationchange", this.relayout);
|
|
1045
|
+
globalThis.visualViewport?.removeEventListener("resize", this.relayout);
|
|
1046
|
+
}
|
|
1047
|
+
/** A tap anywhere outside the menu and the mark closes the menu. */
|
|
1048
|
+
outside = (e) => {
|
|
1049
|
+
if (this.panel !== "menu") return;
|
|
1050
|
+
const path = e.composedPath();
|
|
1051
|
+
if (path.includes(this.els.menu) || path.includes(this.els.mark)) return;
|
|
1052
|
+
this.setPanel("none");
|
|
1053
|
+
};
|
|
1054
|
+
/** Escape closes the open panel (caught first, so a mic's own Escape never sees it). */
|
|
1055
|
+
escape = (e) => {
|
|
1056
|
+
if (e.key !== "Escape" || this.panel === "none") return;
|
|
1057
|
+
e.stopPropagation();
|
|
1058
|
+
this.setPanel("none");
|
|
1059
|
+
};
|
|
1060
|
+
/** A resize (window, rotation, the visual viewport): the bar stays on screen, the float box re-placed. */
|
|
1061
|
+
relayout = () => {
|
|
1062
|
+
this.fit();
|
|
1063
|
+
this.placeFloat();
|
|
1064
|
+
};
|
|
1065
|
+
get ui() {
|
|
1066
|
+
return {
|
|
1067
|
+
...uiStrings(),
|
|
1068
|
+
...this._strings
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
get treeText() {
|
|
1072
|
+
return {
|
|
1073
|
+
...TREE_STRINGS_EN,
|
|
1074
|
+
...treeDefaults(),
|
|
1075
|
+
...this.treeStrings
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
build() {
|
|
1079
|
+
const r = this.root;
|
|
1080
|
+
let style = "";
|
|
1081
|
+
try {
|
|
1082
|
+
const sheet = new CSSStyleSheet();
|
|
1083
|
+
sheet.replaceSync(CSS);
|
|
1084
|
+
r.adoptedStyleSheets = [sheet];
|
|
1085
|
+
} catch {
|
|
1086
|
+
style = `<style>${CSS}</style>`;
|
|
1087
|
+
}
|
|
1088
|
+
r.innerHTML = `${style}
|
|
1089
|
+
<section class="box halo" id="box" aria-label="Chat with the agent">
|
|
1090
|
+
<div class="column" id="column">
|
|
1091
|
+
<ol class="log" id="log"></ol>
|
|
1092
|
+
</div>
|
|
1093
|
+
<div class="warn" id="warn" role="alert" hidden></div>
|
|
1094
|
+
<div class="asks" id="asks" aria-label="Agent connections"></div>
|
|
1095
|
+
<div class="rowbox" id="rowbox">
|
|
1096
|
+
<div class="aim" id="aim" hidden></div>
|
|
1097
|
+
<div class="bar" id="bar">
|
|
1098
|
+
<button type="button" class="tap mark" id="mark" title="${MARK_HINT}">${ICONS.mark}<span class="dot" id="dot"></span></button>
|
|
1099
|
+
<button type="button" class="tap treebtn" id="treebtn" aria-expanded="false" title="${TREE_HINT}">${ICONS.tree}<span class="evals" id="evals" aria-hidden="true" hidden></span><span class="thint" id="thint" role="status" hidden></span></button>
|
|
1100
|
+
<div class="slot" id="slot">
|
|
1101
|
+
<div class="mirror" id="mirror" aria-hidden="true" hidden><span class="ghost" id="ghost"></span><span class="partial" id="partial"></span></div>
|
|
1102
|
+
<textarea id="input" rows="1" maxlength="${MAX_CHAT}" aria-label="Message the agent"></textarea>
|
|
1103
|
+
<p class="notice" id="notice" role="status" hidden></p>
|
|
1104
|
+
<span class="spin" id="spin" role="status" hidden></span>
|
|
1105
|
+
</div>
|
|
1106
|
+
<div class="mic" id="mic" hidden><slot name="mic" id="micslot"></slot></div>
|
|
1107
|
+
<span class="unread" id="unread" hidden></span>
|
|
1108
|
+
</div>
|
|
1109
|
+
<span aria-hidden="true"></span>
|
|
1110
|
+
<div class="floatbox" id="floatbox" hidden>
|
|
1111
|
+
<div class="ftree" id="ftree"></div>
|
|
1112
|
+
<ol class="floats" id="floats" aria-live="polite"></ol>
|
|
1113
|
+
<ul class="frontier" id="frontier"></ul>
|
|
1114
|
+
</div>
|
|
1115
|
+
<div class="menu" id="menu" role="menu" hidden></div>
|
|
1116
|
+
</div>
|
|
1117
|
+
</section>`;
|
|
1118
|
+
for (const el of r.querySelectorAll("[id]")) this.els[el.id] = el;
|
|
1119
|
+
const { box, bar, mark, treebtn, column, floats, rowbox } = this.els;
|
|
1120
|
+
const ta = this.els.input;
|
|
1121
|
+
const micslot = this.els.micslot;
|
|
1122
|
+
const panel = document.createElement("div");
|
|
1123
|
+
panel.className = "treepanel";
|
|
1124
|
+
panel.id = "tree";
|
|
1125
|
+
panel.setAttribute("role", "region");
|
|
1126
|
+
const x = document.createElement("button");
|
|
1127
|
+
x.type = "button";
|
|
1128
|
+
x.className = "x tap";
|
|
1129
|
+
x.textContent = "✕";
|
|
1130
|
+
x.addEventListener("mousedown", (e) => e.preventDefault());
|
|
1131
|
+
x.addEventListener("click", () => this.setPanel("none"));
|
|
1132
|
+
const body = document.createElement("div");
|
|
1133
|
+
body.className = "tbody";
|
|
1134
|
+
body.addEventListener("scroll", () => this.treeFade());
|
|
1135
|
+
panel.append(x, body);
|
|
1136
|
+
this.els.tree = panel;
|
|
1137
|
+
this.els.tbody = body;
|
|
1138
|
+
this.els.tclose = x;
|
|
1139
|
+
box.addEventListener("pointerdown", unlockSpeech);
|
|
1140
|
+
box.addEventListener("keydown", unlockSpeech);
|
|
1141
|
+
this.dragHandle(mark, () => true);
|
|
1142
|
+
mark.addEventListener("pointerdown", () => this.setHolding(true));
|
|
1143
|
+
for (const ev of ["pointerup", "pointercancel"]) mark.addEventListener(ev, () => this.setHolding(false));
|
|
1144
|
+
mark.addEventListener("click", () => !this.markDrag.wasDrag() && this.taps.tap());
|
|
1145
|
+
this.dragHandle(bar, (e) => e.target === bar);
|
|
1146
|
+
this.treeGestures(treebtn);
|
|
1147
|
+
column.addEventListener("scroll", () => this.onColumnScroll());
|
|
1148
|
+
column.addEventListener("wheel", () => this.userScrollAt = Date.now(), { passive: true });
|
|
1149
|
+
column.addEventListener("touchmove", () => this.userScrollAt = Date.now(), { passive: true });
|
|
1150
|
+
column.addEventListener("transitionend", (e) => {
|
|
1151
|
+
if (e.target !== column) return;
|
|
1152
|
+
if (!this.wide()) this.toEnd();
|
|
1153
|
+
this.onColumnScroll();
|
|
1154
|
+
});
|
|
1155
|
+
rowbox.addEventListener("transitionend", (e) => {
|
|
1156
|
+
if (e.target !== rowbox) return;
|
|
1157
|
+
this.fitInput();
|
|
1158
|
+
this.placeFloat();
|
|
1159
|
+
});
|
|
1160
|
+
floats.addEventListener("pointerenter", () => this.holdFloats(true));
|
|
1161
|
+
floats.addEventListener("pointerleave", () => this.holdFloats(false));
|
|
1162
|
+
floats.addEventListener("pointercancel", () => this.holdFloats(false));
|
|
1163
|
+
floats.addEventListener("touchstart", () => this.holdFloats(true), { passive: true });
|
|
1164
|
+
floats.addEventListener("touchend", () => this.holdFloats(false), { passive: true });
|
|
1165
|
+
micslot.addEventListener("slotchange", () => {
|
|
1166
|
+
this.els.mic.hidden = !micslot.assignedElements().length;
|
|
1167
|
+
});
|
|
1168
|
+
this.els.mic.hidden = !micslot.assignedElements().length;
|
|
1169
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1170
|
+
let width = ta.clientWidth;
|
|
1171
|
+
this.ro = new ResizeObserver((entries) => {
|
|
1172
|
+
for (const e of entries) if (e.target === ta) {
|
|
1173
|
+
if (ta.clientWidth === width) continue;
|
|
1174
|
+
width = ta.clientWidth;
|
|
1175
|
+
this.fitInput();
|
|
1176
|
+
} else this.placeFloat();
|
|
1177
|
+
});
|
|
1178
|
+
this.ro.observe(ta);
|
|
1179
|
+
this.ro.observe(this.els.floatbox);
|
|
1180
|
+
}
|
|
1181
|
+
ta.addEventListener("input", () => {
|
|
1182
|
+
this._draft = ta.value;
|
|
1183
|
+
this.offline = false;
|
|
1184
|
+
this.stopSpeech("typing");
|
|
1185
|
+
this.fitInput();
|
|
1186
|
+
this.renderPartial();
|
|
1187
|
+
this.arm();
|
|
1188
|
+
this.renderStatus();
|
|
1189
|
+
this.renderAim();
|
|
1190
|
+
});
|
|
1191
|
+
ta.addEventListener("scroll", () => this.els.mirror.scrollTop = ta.scrollTop);
|
|
1192
|
+
ta.addEventListener("focus", () => {
|
|
1193
|
+
this.typing = true;
|
|
1194
|
+
this.renderLayout();
|
|
1195
|
+
});
|
|
1196
|
+
ta.addEventListener("blur", () => {
|
|
1197
|
+
this.typing = false;
|
|
1198
|
+
this.renderLayout();
|
|
1199
|
+
});
|
|
1200
|
+
ta.addEventListener("compositionstart", () => {
|
|
1201
|
+
this.composing = true;
|
|
1202
|
+
this.disarm();
|
|
1203
|
+
});
|
|
1204
|
+
ta.addEventListener("compositionend", () => {
|
|
1205
|
+
this.composing = false;
|
|
1206
|
+
this.arm();
|
|
1207
|
+
});
|
|
1208
|
+
ta.addEventListener("beforeinput", (e) => {
|
|
1209
|
+
const ie = e;
|
|
1210
|
+
if (lineBreakSends(ie.inputType, {
|
|
1211
|
+
composing: this.composing,
|
|
1212
|
+
isComposing: ie.isComposing,
|
|
1213
|
+
shiftEnter: this.shiftEnter
|
|
1214
|
+
})) {
|
|
1215
|
+
e.preventDefault();
|
|
1216
|
+
const first = this.completions()[0];
|
|
1217
|
+
if (first) return this.pick(first);
|
|
1218
|
+
this.submit();
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
ta.addEventListener("keydown", (e) => {
|
|
1222
|
+
this.shiftEnter = e.key === "Enter" && e.shiftKey;
|
|
1223
|
+
const first = this.completions()[0];
|
|
1224
|
+
if ((e.key === "Tab" || e.key === "Enter") && !e.shiftKey && first) {
|
|
1225
|
+
e.preventDefault();
|
|
1226
|
+
return this.pick(first);
|
|
1227
|
+
}
|
|
1228
|
+
if (e.key === "Backspace" && !this._draft && this._target !== ROOT_NODE) return this.setTarget(ROOT_NODE);
|
|
1229
|
+
if (!enterSends(e, this.composing)) return;
|
|
1230
|
+
e.preventDefault();
|
|
1231
|
+
this.submit();
|
|
1232
|
+
});
|
|
1233
|
+
this.swipeScrollsLog(ta);
|
|
1234
|
+
}
|
|
1235
|
+
/** The log is click-through, so it can't be swiped: a vertical swipe on the empty input scrolls it. */
|
|
1236
|
+
swipeScrollsLog(el) {
|
|
1237
|
+
let y = 0;
|
|
1238
|
+
let swiping = false;
|
|
1239
|
+
el.addEventListener("touchstart", (e) => {
|
|
1240
|
+
y = e.touches[0].clientY;
|
|
1241
|
+
swiping = false;
|
|
1242
|
+
}, { passive: true });
|
|
1243
|
+
el.addEventListener("touchmove", (e) => {
|
|
1244
|
+
if (el.value.length > 0) return;
|
|
1245
|
+
const ny = e.touches[0].clientY;
|
|
1246
|
+
if (!swiping && Math.abs(ny - y) < 6) return;
|
|
1247
|
+
swiping = true;
|
|
1248
|
+
this.els.column.scrollTop -= ny - y;
|
|
1249
|
+
this.userScrollAt = Date.now();
|
|
1250
|
+
y = ny;
|
|
1251
|
+
if (e.cancelable) e.preventDefault();
|
|
1252
|
+
}, { passive: false });
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Drag the box by `el` anywhere on screen: an offset from its bottom-right
|
|
1256
|
+
* home, the bar kept on screen (up to the notch) and remembered. A press
|
|
1257
|
+
* that moves under 5 px is still a click.
|
|
1258
|
+
*/
|
|
1259
|
+
dragHandle(el, accept) {
|
|
1260
|
+
let d = null;
|
|
1261
|
+
el.addEventListener("pointerdown", (e) => {
|
|
1262
|
+
if (!accept(e)) return;
|
|
1263
|
+
d = {
|
|
1264
|
+
px: e.clientX,
|
|
1265
|
+
py: e.clientY,
|
|
1266
|
+
x: this.pos.x,
|
|
1267
|
+
y: this.pos.y,
|
|
1268
|
+
moved: false
|
|
1269
|
+
};
|
|
1270
|
+
this.markDrag.down();
|
|
1271
|
+
el.setPointerCapture?.(e.pointerId);
|
|
1272
|
+
});
|
|
1273
|
+
el.addEventListener("pointermove", (e) => {
|
|
1274
|
+
if (!d) return;
|
|
1275
|
+
const dx = e.clientX - d.px;
|
|
1276
|
+
const dy = e.clientY - d.py;
|
|
1277
|
+
if (!d.moved && Math.hypot(dx, dy) < 5) return;
|
|
1278
|
+
d.moved = true;
|
|
1279
|
+
const bar = this.els.bar.getBoundingClientRect();
|
|
1280
|
+
const left = bar.left - (this.pos.x - d.x);
|
|
1281
|
+
const top = bar.top - (this.pos.y - d.y);
|
|
1282
|
+
const safeTop = safeInsets().top;
|
|
1283
|
+
this.pos = {
|
|
1284
|
+
x: d.x + Math.max(-left, Math.min(innerWidth - bar.width - left, dx)),
|
|
1285
|
+
y: d.y + Math.max(safeTop - top, Math.min(innerHeight - bar.height - top, dy))
|
|
1286
|
+
};
|
|
1287
|
+
this.style.transform = `translate(${this.pos.x}px, ${this.pos.y}px)`;
|
|
1288
|
+
});
|
|
1289
|
+
const end = () => {
|
|
1290
|
+
const moved = d?.moved;
|
|
1291
|
+
d = null;
|
|
1292
|
+
this.setHolding(false);
|
|
1293
|
+
if (!moved) return;
|
|
1294
|
+
this.markDrag.dragEnded();
|
|
1295
|
+
this.write("pos", JSON.stringify(this.pos));
|
|
1296
|
+
this.placeFloat();
|
|
1297
|
+
};
|
|
1298
|
+
el.addEventListener("pointerup", end);
|
|
1299
|
+
el.addEventListener("pointercancel", () => {
|
|
1300
|
+
d = null;
|
|
1301
|
+
this.setHolding(false);
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
setHolding(v) {
|
|
1305
|
+
if (this.holding === v) return;
|
|
1306
|
+
this.holding = v;
|
|
1307
|
+
this.renderPlaceholder();
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Bring the bar back on screen (after expanding where the minimized bar
|
|
1311
|
+
* was, or on a smaller screen). Expanded, the bar spans the box, so the
|
|
1312
|
+
* box's own edges count.
|
|
1313
|
+
*/
|
|
1314
|
+
fit() {
|
|
1315
|
+
const bar = this.els.bar?.getBoundingClientRect();
|
|
1316
|
+
const box = this.els.box?.getBoundingClientRect();
|
|
1317
|
+
if (!bar || !box || !bar.width) return;
|
|
1318
|
+
const row = this.open ? box : bar;
|
|
1319
|
+
const top = safeInsets().top;
|
|
1320
|
+
const dx = row.left < 0 ? -row.left : row.right > innerWidth ? innerWidth - row.right : 0;
|
|
1321
|
+
const dy = bar.top < top ? top - bar.top : bar.bottom > innerHeight ? innerHeight - bar.bottom : 0;
|
|
1322
|
+
if (!dx && !dy) return;
|
|
1323
|
+
this.pos = {
|
|
1324
|
+
x: this.pos.x + dx,
|
|
1325
|
+
y: this.pos.y + dy
|
|
1326
|
+
};
|
|
1327
|
+
this.style.transform = `translate(${this.pos.x}px, ${this.pos.y}px)`;
|
|
1328
|
+
this.write("pos", JSON.stringify(this.pos));
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* The tree button: a tap opens or closes the tree; a horizontal drag (axis
|
|
1332
|
+
* locked after 8 px) moves the focused node through its detents — right
|
|
1333
|
+
* opens it a level per cell, left folds it (src/tree-nav.ts) — with a live
|
|
1334
|
+
* preview, resisting past a limit, committed on release. A vertical drag
|
|
1335
|
+
* does nothing. Arrow keys on it move in the open tree.
|
|
1336
|
+
*/
|
|
1337
|
+
treeGestures(b) {
|
|
1338
|
+
b.addEventListener("mousedown", (e) => e.preventDefault());
|
|
1339
|
+
b.addEventListener("pointerdown", (e) => {
|
|
1340
|
+
this.treeAxis.down(e.clientX, e.clientY);
|
|
1341
|
+
this.treeDragged.down();
|
|
1342
|
+
this.treePress = {
|
|
1343
|
+
cells: 0,
|
|
1344
|
+
limitAt: null
|
|
1345
|
+
};
|
|
1346
|
+
b.setPointerCapture?.(e.pointerId);
|
|
1347
|
+
});
|
|
1348
|
+
b.addEventListener("pointermove", (e) => {
|
|
1349
|
+
const was = this.treeAxis.axis();
|
|
1350
|
+
const m = this.treeAxis.move(e.clientX, e.clientY);
|
|
1351
|
+
if (!m || m.axis !== "x" || !this._treeSource) return;
|
|
1352
|
+
if (!was) {
|
|
1353
|
+
if (this.panel !== "tree") this.setPanel("tree");
|
|
1354
|
+
this.nav = treeNav(this.navIx.ix, this.nav, { type: "dragStart" });
|
|
1355
|
+
}
|
|
1356
|
+
const p = this.treePress;
|
|
1357
|
+
p.cells = dragCells(m.dx, p.cells);
|
|
1358
|
+
const next = treeNav(this.navIx.ix, this.nav, {
|
|
1359
|
+
type: "dragMove",
|
|
1360
|
+
cells: p.cells
|
|
1361
|
+
});
|
|
1362
|
+
p.limitAt = next.atLimit ? p.limitAt ?? p.cells - Math.sign(p.cells) : null;
|
|
1363
|
+
this.setNav(next);
|
|
1364
|
+
this.showTreeHint(m.dx, p.limitAt);
|
|
1365
|
+
});
|
|
1366
|
+
b.addEventListener("pointerup", () => {
|
|
1367
|
+
const axis = this.treeAxis.up();
|
|
1368
|
+
if (!axis) return;
|
|
1369
|
+
this.treeDragged.dragEnded();
|
|
1370
|
+
if (axis === "x") this.navTo({ type: "dragEnd" });
|
|
1371
|
+
this.els.thint.hidden = true;
|
|
1372
|
+
});
|
|
1373
|
+
b.addEventListener("pointercancel", () => {
|
|
1374
|
+
if (this.treeAxis.up() === "x") this.navTo({ type: "dragCancel" });
|
|
1375
|
+
this.els.thint.hidden = true;
|
|
1376
|
+
});
|
|
1377
|
+
b.addEventListener("click", () => {
|
|
1378
|
+
if (this.treeDragged.wasDrag()) return;
|
|
1379
|
+
this.setPanel(this.panel === "tree" ? "none" : "tree");
|
|
1380
|
+
});
|
|
1381
|
+
b.addEventListener("keydown", (e) => {
|
|
1382
|
+
if (this.panel !== "tree" || !e.key.startsWith("Arrow")) return;
|
|
1383
|
+
e.preventDefault();
|
|
1384
|
+
this.navTo({
|
|
1385
|
+
type: "key",
|
|
1386
|
+
key: e.key
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
/** While dragging: the focused node and the depth it would open to, following the finger. */
|
|
1391
|
+
showTreeHint(dx, limitAt) {
|
|
1392
|
+
const hint = this.els.thint;
|
|
1393
|
+
const id = this.nav.focusId;
|
|
1394
|
+
const node = id === null ? void 0 : this.navIx.byId.get(id);
|
|
1395
|
+
const depth = id === null ? 0 : this.nav.expanded[id] ?? 0;
|
|
1396
|
+
const max = id === null ? 0 : maxDetent(this.navIx.ix, id);
|
|
1397
|
+
hint.textContent = node ? `${node.title} · ${depth}/${max}` : "—";
|
|
1398
|
+
hint.style.transform = `translateX(${Math.round(dragOffset(dx, limitAt ?? void 0) * .25)}px)`;
|
|
1399
|
+
hint.hidden = false;
|
|
1400
|
+
}
|
|
1401
|
+
/** (Re)load the tree: once for a URL or function, a subscription for a feed. Never polls. */
|
|
1402
|
+
loadTree() {
|
|
1403
|
+
this.treeStop?.();
|
|
1404
|
+
this.treeStop = void 0;
|
|
1405
|
+
const src = this._treeSource;
|
|
1406
|
+
if (!src) {
|
|
1407
|
+
this.treeRoot = null;
|
|
1408
|
+
this.treeError = "";
|
|
1409
|
+
this.setRoot(null);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (!this.isConnected || !this.root) return;
|
|
1413
|
+
this.treeStop = watchTree(src, { repo: this.treeRepo }, (root) => {
|
|
1414
|
+
this.treeError = "";
|
|
1415
|
+
this.setRoot(root);
|
|
1416
|
+
}, (e) => {
|
|
1417
|
+
this.treeError = e instanceof Error ? e.message : String(e);
|
|
1418
|
+
this.renderTree();
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
setRoot(root) {
|
|
1422
|
+
this.treeRoot = root;
|
|
1423
|
+
this.navIx = navIndexOf(root);
|
|
1424
|
+
const f = this.nav.focusId;
|
|
1425
|
+
if (f === null || !this.navIx.ix.parent.has(f)) this.nav = initialNav(this.navIx.ix);
|
|
1426
|
+
this.renderTree();
|
|
1427
|
+
this.renderFrontier();
|
|
1428
|
+
}
|
|
1429
|
+
setNav(next) {
|
|
1430
|
+
if (next === this.nav) return;
|
|
1431
|
+
this.nav = next;
|
|
1432
|
+
this.renderTree();
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* One nav step. After it, an expand brings the focused node and its new
|
|
1436
|
+
* children into view (unless the user is scrolling); a fold keeps the
|
|
1437
|
+
* focused row where it was.
|
|
1438
|
+
*/
|
|
1439
|
+
navTo(ev) {
|
|
1440
|
+
const scroller = this.open ? this.els.column : this.els.tbody;
|
|
1441
|
+
const rows = () => this.els.tbody.querySelectorAll("[data-id]");
|
|
1442
|
+
const tops = /* @__PURE__ */ new Map();
|
|
1443
|
+
for (const r of rows()) tops.set(r.dataset.id, r.getBoundingClientRect().top);
|
|
1444
|
+
const sum = (e) => Object.values(e).reduce((a, b) => a + b, 0);
|
|
1445
|
+
const prev = this.nav;
|
|
1446
|
+
const next = treeNav(this.navIx.ix, prev, ev);
|
|
1447
|
+
this.setNav(next);
|
|
1448
|
+
const f = next.focusId;
|
|
1449
|
+
if (f === null) return;
|
|
1450
|
+
const row = [...rows()].find((r) => r.dataset.id === f);
|
|
1451
|
+
if (!row) return;
|
|
1452
|
+
if (sum(next.expanded) > sum(prev.expanded)) {
|
|
1453
|
+
if (Date.now() - this.userScrollAt < 600) return;
|
|
1454
|
+
const last = [...rows()].filter((r) => r.dataset.id.startsWith(`${f}.`)).at(-1) ?? row;
|
|
1455
|
+
const top = row.getBoundingClientRect().top;
|
|
1456
|
+
const bottom = last.getBoundingClientRect().bottom;
|
|
1457
|
+
const box = scroller.getBoundingClientRect();
|
|
1458
|
+
if (bottom > box.bottom) scroller.scrollTop += Math.min(bottom - box.bottom, top - box.top);
|
|
1459
|
+
else if (top < box.top) scroller.scrollTop -= box.top - top;
|
|
1460
|
+
} else {
|
|
1461
|
+
const was = tops.get(f);
|
|
1462
|
+
if (was !== void 0) scroller.scrollTop += row.getBoundingClientRect().top - was;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
renderTree() {
|
|
1466
|
+
if (!this.root) return;
|
|
1467
|
+
const body = this.els.tbody;
|
|
1468
|
+
const t = this.treeText;
|
|
1469
|
+
body.replaceChildren(this._treeSource ? renderTreeChips({
|
|
1470
|
+
root: this.treeRoot,
|
|
1471
|
+
nav: this.nav,
|
|
1472
|
+
ix: this.navIx.ix,
|
|
1473
|
+
byId: this.navIx.byId,
|
|
1474
|
+
onNav: (ev) => this.navTo(ev),
|
|
1475
|
+
status: this.treeError ? `${t.failed} (${this.treeError})` : this.treeRoot ? void 0 : t.loading,
|
|
1476
|
+
empty: t.empty,
|
|
1477
|
+
label: this.ui.stateTree,
|
|
1478
|
+
needsYou: this.ui.needsYou
|
|
1479
|
+
}) : Object.assign(document.createElement("p"), {
|
|
1480
|
+
className: "chips status",
|
|
1481
|
+
textContent: this.ui.noTreeSource
|
|
1482
|
+
}));
|
|
1483
|
+
this.treeFade();
|
|
1484
|
+
}
|
|
1485
|
+
/** The floating tree overflows (or is scrolled): its top edge fades, as the log's. */
|
|
1486
|
+
treeFade() {
|
|
1487
|
+
const b = this.els.tbody;
|
|
1488
|
+
if (b) b.classList.toggle("fade", b.scrollHeight > b.clientHeight + 1 || b.scrollTop > 0);
|
|
1489
|
+
}
|
|
1490
|
+
disarm() {
|
|
1491
|
+
clearTimeout(this.timer);
|
|
1492
|
+
this.els.bar?.querySelector(".drain")?.remove();
|
|
1493
|
+
}
|
|
1494
|
+
arm() {
|
|
1495
|
+
this.disarm();
|
|
1496
|
+
if (parseAddress(this._draft)?.complete === false) return;
|
|
1497
|
+
const next = afterChange(this._draft, this.settledLen, this.composing);
|
|
1498
|
+
if (!this.composing) this.settledLen = this._draft.length;
|
|
1499
|
+
if (next === "send") return void this.submit();
|
|
1500
|
+
if (next === "idle" || this._micActive || this._partial) return;
|
|
1501
|
+
const bar = document.createElement("div");
|
|
1502
|
+
bar.className = "drain";
|
|
1503
|
+
bar.setAttribute("aria-hidden", "true");
|
|
1504
|
+
bar.style.setProperty("--drain-ms", `${DEBOUNCE_MS}ms`);
|
|
1505
|
+
this.els.bar.prepend(bar);
|
|
1506
|
+
this.timer = setTimeout(() => this.submit(), DEBOUNCE_MS);
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* Send the draft now, as Enter would; true if it went out. Kept (and the
|
|
1510
|
+
* status says so) if it could not.
|
|
1511
|
+
*/
|
|
1512
|
+
submit() {
|
|
1513
|
+
this.disarm();
|
|
1514
|
+
let text = this._draft.trim();
|
|
1515
|
+
const addr = parseAddress(text);
|
|
1516
|
+
if (addr) {
|
|
1517
|
+
if (!addr.complete) return false;
|
|
1518
|
+
this.setTarget(addr.to);
|
|
1519
|
+
text = addr.text;
|
|
1520
|
+
if (!text) {
|
|
1521
|
+
this.clearDraft();
|
|
1522
|
+
return false;
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
if (!text) return false;
|
|
1526
|
+
const to = this._target === ROOT_NODE ? void 0 : this._target;
|
|
1527
|
+
const source = draftSource(this._draft, this.pure);
|
|
1528
|
+
if (!this.onSend?.(text, to, source ? { source } : void 0)) {
|
|
1529
|
+
this.offline = true;
|
|
1530
|
+
this.renderStatus();
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
this.offline = false;
|
|
1534
|
+
this.clearDraft();
|
|
1535
|
+
return true;
|
|
1536
|
+
}
|
|
1537
|
+
clearDraft() {
|
|
1538
|
+
this._draft = "";
|
|
1539
|
+
this.pure = null;
|
|
1540
|
+
this.settledLen = 0;
|
|
1541
|
+
const ta = this.els.input;
|
|
1542
|
+
if (ta) ta.value = "";
|
|
1543
|
+
this.fitInput();
|
|
1544
|
+
this.renderPartial();
|
|
1545
|
+
this.renderStatus();
|
|
1546
|
+
this.renderAim();
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* The input grows with its text up to INPUT_MAX_PX, then scrolls inside
|
|
1550
|
+
* itself. Empty, it is one line (nothing measured: a placeholder measured
|
|
1551
|
+
* in a narrow slot wraps to several lines).
|
|
1552
|
+
*/
|
|
1553
|
+
fitInput() {
|
|
1554
|
+
const el = this.els.input;
|
|
1555
|
+
if (!el) return;
|
|
1556
|
+
el.style.height = "";
|
|
1557
|
+
if (!el.value || el.clientWidth < 40) return;
|
|
1558
|
+
el.style.height = "auto";
|
|
1559
|
+
const border = el.offsetHeight - el.clientHeight;
|
|
1560
|
+
el.style.height = `${Math.min(el.scrollHeight + border, INPUT_MAX_PX)}px`;
|
|
1561
|
+
}
|
|
1562
|
+
/** The draft's "@/…" while it is typed: the listening nodes under it. */
|
|
1563
|
+
completions() {
|
|
1564
|
+
const a = /^@\/\S*(?:\s*›\s*\S*)*$/.test(this._draft) ? parseAddress(this._draft) : null;
|
|
1565
|
+
return a && !a.complete ? completeNode(this._draft, this._nodes) : [];
|
|
1566
|
+
}
|
|
1567
|
+
pick(n) {
|
|
1568
|
+
this.setTarget(n);
|
|
1569
|
+
this.clearDraft();
|
|
1570
|
+
this.els.input.focus();
|
|
1571
|
+
}
|
|
1572
|
+
renderPlaceholder() {
|
|
1573
|
+
const ta = this.els.input;
|
|
1574
|
+
if (!ta) return;
|
|
1575
|
+
ta.placeholder = this.holding ? DRAG_HINT : this._partial ? "" : this._target !== ROOT_NODE ? `Message ${pathLabel(this._target)}…` : this._placeholder || (this._live && !this._listeners ? "Agent offline — your message waits for it…" : "Message the agent…");
|
|
1576
|
+
}
|
|
1577
|
+
/** The live transcript: a mirror of the draft, invisible, then the partial text dimmed. */
|
|
1578
|
+
renderPartial() {
|
|
1579
|
+
const mirror = this.els.mirror;
|
|
1580
|
+
if (!mirror) return;
|
|
1581
|
+
const tail = partialTail(this._draft, this._partial);
|
|
1582
|
+
mirror.hidden = !tail;
|
|
1583
|
+
this.els.ghost.textContent = tail ? this._draft : "";
|
|
1584
|
+
this.els.partial.textContent = tail;
|
|
1585
|
+
mirror.scrollTop = this.els.input.scrollTop;
|
|
1586
|
+
}
|
|
1587
|
+
/** The target chip (× back to the main thread) and the "@/…" completions. */
|
|
1588
|
+
renderAim() {
|
|
1589
|
+
const aim = this.els.aim;
|
|
1590
|
+
if (!aim) return;
|
|
1591
|
+
aim.replaceChildren();
|
|
1592
|
+
const list = this.completions();
|
|
1593
|
+
const typed = parseAddress(this._draft)?.complete ? parseAddress(this._draft) : null;
|
|
1594
|
+
const shown = typed ? typed.to : this._target;
|
|
1595
|
+
if (list.length) {
|
|
1596
|
+
const ul = document.createElement("ul");
|
|
1597
|
+
ul.className = "nodes";
|
|
1598
|
+
ul.setAttribute("role", "listbox");
|
|
1599
|
+
ul.setAttribute("aria-label", "Nodes to address");
|
|
1600
|
+
for (const n of list) {
|
|
1601
|
+
const li = document.createElement("li");
|
|
1602
|
+
li.setAttribute("role", "option");
|
|
1603
|
+
const b = document.createElement("button");
|
|
1604
|
+
b.type = "button";
|
|
1605
|
+
b.textContent = pathLabel(n);
|
|
1606
|
+
b.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
1607
|
+
b.addEventListener("click", () => this.pick(n));
|
|
1608
|
+
li.append(b);
|
|
1609
|
+
ul.append(li);
|
|
1610
|
+
}
|
|
1611
|
+
aim.append(ul);
|
|
1612
|
+
}
|
|
1613
|
+
if (shown !== ROOT_NODE) {
|
|
1614
|
+
const chip = document.createElement("span");
|
|
1615
|
+
chip.className = typed ? "target preview" : "target";
|
|
1616
|
+
chip.dataset.node = shown;
|
|
1617
|
+
const border = lineNode({
|
|
1618
|
+
from: "owner",
|
|
1619
|
+
to: shown
|
|
1620
|
+
})?.border;
|
|
1621
|
+
if (border) chip.style.setProperty("--node", border);
|
|
1622
|
+
const label = document.createElement("span");
|
|
1623
|
+
label.textContent = `→ ${pathLabel(shown)}`;
|
|
1624
|
+
chip.append(label);
|
|
1625
|
+
if (!typed) {
|
|
1626
|
+
const x = document.createElement("button");
|
|
1627
|
+
x.type = "button";
|
|
1628
|
+
x.textContent = "×";
|
|
1629
|
+
x.setAttribute("aria-label", "Send to the main thread instead");
|
|
1630
|
+
x.addEventListener("pointerdown", (e) => e.preventDefault());
|
|
1631
|
+
x.addEventListener("click", () => this.setTarget(ROOT_NODE));
|
|
1632
|
+
chip.append(x);
|
|
1633
|
+
}
|
|
1634
|
+
aim.append(chip);
|
|
1635
|
+
}
|
|
1636
|
+
aim.hidden = !this.open || !aim.childElementCount;
|
|
1637
|
+
}
|
|
1638
|
+
setOpen(v) {
|
|
1639
|
+
if (v) this.atEnd = true;
|
|
1640
|
+
this.open = v;
|
|
1641
|
+
this.write("open", v ? "1" : "0");
|
|
1642
|
+
this.messagesChanged();
|
|
1643
|
+
this.renderAll();
|
|
1644
|
+
requestAnimationFrame?.(() => this.fit());
|
|
1645
|
+
}
|
|
1646
|
+
setPanel(p) {
|
|
1647
|
+
if (p === this.panel) return;
|
|
1648
|
+
const opening = p === "tree" && this.panel !== "tree";
|
|
1649
|
+
this.panel = p;
|
|
1650
|
+
if (opening) this.loadTree();
|
|
1651
|
+
this.renderLayout();
|
|
1652
|
+
this.renderMenu();
|
|
1653
|
+
if (opening && this.open) this.els.column.scrollTop = 0;
|
|
1654
|
+
requestAnimationFrame?.(() => this.fit());
|
|
1655
|
+
}
|
|
1656
|
+
/** The log opens to 30vh while the input has focus, a mic is on, or the tree is open. */
|
|
1657
|
+
wide() {
|
|
1658
|
+
return this.typing || this._micActive || this.panel === "tree";
|
|
1659
|
+
}
|
|
1660
|
+
status() {
|
|
1661
|
+
if (this._notice) return "chat unavailable";
|
|
1662
|
+
if (this.offline) return "not connected — your draft is kept";
|
|
1663
|
+
if (!this._live) return "reconnecting…";
|
|
1664
|
+
return this._listeners ? `agent listening (${this._listeners})` : "agent offline · messages wait until it is back";
|
|
1665
|
+
}
|
|
1666
|
+
renderAll() {
|
|
1667
|
+
if (!this.root) return;
|
|
1668
|
+
this.style.transform = `translate(${this.pos.x}px, ${this.pos.y}px)`;
|
|
1669
|
+
this.renderLayout();
|
|
1670
|
+
this.renderAsks();
|
|
1671
|
+
this.renderEvals();
|
|
1672
|
+
this.renderBusy();
|
|
1673
|
+
this.renderLog();
|
|
1674
|
+
this.renderInput();
|
|
1675
|
+
this.renderAim();
|
|
1676
|
+
this.renderPlaceholder();
|
|
1677
|
+
this.renderPartial();
|
|
1678
|
+
this.renderTree();
|
|
1679
|
+
this.renderFrontier();
|
|
1680
|
+
this.renderStatus();
|
|
1681
|
+
this.renderMenu();
|
|
1682
|
+
}
|
|
1683
|
+
/** Open or minimized, which panel, the column's size: the box's frame. */
|
|
1684
|
+
renderLayout() {
|
|
1685
|
+
if (!this.root) return;
|
|
1686
|
+
const { box, column, slot, mark, treebtn, tree, tclose, ftree } = this.els;
|
|
1687
|
+
const ui = this.ui;
|
|
1688
|
+
box.classList.toggle("min", !this.open);
|
|
1689
|
+
box.classList.toggle("on", this._micActive || this.panel !== "none");
|
|
1690
|
+
column.hidden = !this.open;
|
|
1691
|
+
slot.toggleAttribute("inert", !this.open);
|
|
1692
|
+
if (this.open) slot.removeAttribute("aria-hidden");
|
|
1693
|
+
else slot.setAttribute("aria-hidden", "true");
|
|
1694
|
+
mark.setAttribute("aria-expanded", String(this.open));
|
|
1695
|
+
treebtn.setAttribute("aria-expanded", String(this.panel === "tree"));
|
|
1696
|
+
treebtn.setAttribute("aria-label", ui.stateTree);
|
|
1697
|
+
tree.setAttribute("aria-label", ui.stateTree);
|
|
1698
|
+
tclose.setAttribute("aria-label", ui.closeTree);
|
|
1699
|
+
if (this.panel === "tree") {
|
|
1700
|
+
const host = this.open ? column : ftree;
|
|
1701
|
+
if (tree.parentElement !== host) host.prepend(tree);
|
|
1702
|
+
} else tree.remove();
|
|
1703
|
+
const wide = this.wide();
|
|
1704
|
+
column.classList.toggle("wide", wide);
|
|
1705
|
+
this.els.tbody.classList.toggle("wide", wide);
|
|
1706
|
+
this.renderAim();
|
|
1707
|
+
this.renderFrontier();
|
|
1708
|
+
this.onColumnScroll();
|
|
1709
|
+
this.treeFade();
|
|
1710
|
+
if (!wide && this.panel !== "tree") this.toEnd();
|
|
1711
|
+
this.placeFloat();
|
|
1712
|
+
}
|
|
1713
|
+
renderStatus() {
|
|
1714
|
+
if (!this.root) return;
|
|
1715
|
+
const dot = this._live && this._listeners ? "oklch(0.68 0.16 150)" : this._live ? "oklch(0.75 0.13 75)" : "oklch(0.62 0.02 70)";
|
|
1716
|
+
this.els.dot.style.background = dot;
|
|
1717
|
+
const status = this.status();
|
|
1718
|
+
this.els.bar.title = status;
|
|
1719
|
+
const unread = this.open ? 0 : countUnread(this._messages, (id) => this.arrivals.seqOf(id), this.readSeq);
|
|
1720
|
+
const badge = this.els.unread;
|
|
1721
|
+
badge.hidden = unread === 0;
|
|
1722
|
+
badge.textContent = String(unread);
|
|
1723
|
+
this.els.mark.setAttribute("aria-label", this.open ? "Minimize chat" : `Restore chat${unread ? `, ${unread} new` : ""}`);
|
|
1724
|
+
this.renderPlaceholder();
|
|
1725
|
+
if (this.panel === "menu") this.renderMenu();
|
|
1726
|
+
}
|
|
1727
|
+
/** The eval/preview sessions on the tree button: their count, pulsing when code just ran. */
|
|
1728
|
+
renderEvals() {
|
|
1729
|
+
if (!this.root) return;
|
|
1730
|
+
const evals = this._sessions.filter((x) => x.scope !== "chat");
|
|
1731
|
+
const b = this.els.evals;
|
|
1732
|
+
b.hidden = !evals.length;
|
|
1733
|
+
b.textContent = String(evals.length);
|
|
1734
|
+
b.classList.toggle("ran", this._evalRan);
|
|
1735
|
+
const kind = this._friend ? "preview" : "eval";
|
|
1736
|
+
this.els.treebtn.title = evals.length ? `${TREE_HINT}\n${evals.map((x) => `${kind}: ${x.label} · ${x.code}`).join("\n")}` : TREE_HINT;
|
|
1737
|
+
}
|
|
1738
|
+
/** The spinner in the input while the agent works. */
|
|
1739
|
+
renderBusy() {
|
|
1740
|
+
if (!this.root) return;
|
|
1741
|
+
const s = this.els.spin;
|
|
1742
|
+
s.hidden = !this._busy;
|
|
1743
|
+
s.title = "the agent is working";
|
|
1744
|
+
s.setAttribute("aria-label", "the agent is working");
|
|
1745
|
+
this.els.input.classList.toggle("pe", this._busy);
|
|
1746
|
+
}
|
|
1747
|
+
/** A pairing request above the bar. */
|
|
1748
|
+
renderAsks() {
|
|
1749
|
+
if (!this.root) return;
|
|
1750
|
+
const row = this.els.asks;
|
|
1751
|
+
row.replaceChildren();
|
|
1752
|
+
const p = this._pending;
|
|
1753
|
+
if (p) row.append(this.renderAsk(p));
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* A pairing request, compact: its code, what it asks for, Allow and ✕ —
|
|
1757
|
+
* and, for code on a user with more than one page open, a checkbox per
|
|
1758
|
+
* page (this one ticked) plus "all pages". Chat is always every page. An agent that did not say what
|
|
1759
|
+
* it wants (an older client) gets the two old answers: code (8h) and chat.
|
|
1760
|
+
*/
|
|
1761
|
+
renderAsk(p) {
|
|
1762
|
+
const ask = document.createElement("span");
|
|
1763
|
+
ask.className = "ask";
|
|
1764
|
+
ask.setAttribute("role", "alertdialog");
|
|
1765
|
+
const what = this._friend ? "to run code on the chosen pages, for 1 hour" : p.scope === "eval" ? "to run code on the chosen pages and chat, for 8 hours" : p.scope === "chat" ? "to chat with you, for 30 days" : "to run code on your pages, or to chat";
|
|
1766
|
+
const sentence = `${p.label} asks ${what}. Allow only if the agent shows the same code: ${p.code}.`;
|
|
1767
|
+
ask.setAttribute("aria-label", sentence);
|
|
1768
|
+
ask.title = sentence;
|
|
1769
|
+
ask.innerHTML = ICONS.lock;
|
|
1770
|
+
const code = document.createElement("b");
|
|
1771
|
+
code.textContent = p.code;
|
|
1772
|
+
ask.append(code);
|
|
1773
|
+
if (p.scope) {
|
|
1774
|
+
const tag = document.createElement("span");
|
|
1775
|
+
tag.className = "tag";
|
|
1776
|
+
tag.textContent = this._friend ? "code · 1h" : p.scope === "eval" ? "code · 8h" : "chat · 30d";
|
|
1777
|
+
ask.append(tag);
|
|
1778
|
+
}
|
|
1779
|
+
if (!this.picked.has(p.pair)) this.picked.set(p.pair, new Set(this._self ? [this._self] : []));
|
|
1780
|
+
const picked = this.picked.get(p.pair);
|
|
1781
|
+
const pages = () => {
|
|
1782
|
+
const cur = this.picked.get(p.pair);
|
|
1783
|
+
if (cur === "all") return "all";
|
|
1784
|
+
return cur && cur.size ? [...cur] : void 0;
|
|
1785
|
+
};
|
|
1786
|
+
const btn = (cls, text, title, fn) => {
|
|
1787
|
+
const b = document.createElement("button");
|
|
1788
|
+
b.type = "button";
|
|
1789
|
+
b.className = cls;
|
|
1790
|
+
b.textContent = text;
|
|
1791
|
+
b.title = title;
|
|
1792
|
+
b.setAttribute("aria-label", title);
|
|
1793
|
+
b.addEventListener("click", fn);
|
|
1794
|
+
ask.append(b);
|
|
1795
|
+
};
|
|
1796
|
+
if (p.scope || this._friend) btn("y", "Allow", "Allow on the ticked pages", () => this.onAnswer?.("allow", p, { pages: pages() }));
|
|
1797
|
+
else {
|
|
1798
|
+
btn("y", "8h", "Allow code and chat for 8 hours", () => this.onAnswer?.("allow", p, { pages: pages() }));
|
|
1799
|
+
btn("c", "chat", "Chat only, for 30 days", () => this.onAnswer?.("allow", p, {
|
|
1800
|
+
pages: pages(),
|
|
1801
|
+
scope: "chat"
|
|
1802
|
+
}));
|
|
1803
|
+
}
|
|
1804
|
+
btn("n", "✕", "Deny", () => this.onAnswer?.("deny", p, {}));
|
|
1805
|
+
if (this._pages.length < 2 || p.scope === "chat" && !this._friend) return ask;
|
|
1806
|
+
const wrap = document.createElement("div");
|
|
1807
|
+
wrap.className = "askwrap";
|
|
1808
|
+
const list = document.createElement("div");
|
|
1809
|
+
list.className = "pagelist";
|
|
1810
|
+
const box = (label, checked, on) => {
|
|
1811
|
+
const l = document.createElement("label");
|
|
1812
|
+
const c = document.createElement("input");
|
|
1813
|
+
c.type = "checkbox";
|
|
1814
|
+
c.checked = checked;
|
|
1815
|
+
c.addEventListener("change", () => on(c.checked));
|
|
1816
|
+
l.append(c, document.createTextNode(label));
|
|
1817
|
+
list.append(l);
|
|
1818
|
+
};
|
|
1819
|
+
const all = picked === "all";
|
|
1820
|
+
for (const pg of this._pages) box(`${pageHint(pg.label)}${pg.id === this._self ? " (this page)" : ""}`, all || picked.has(pg.id), (v) => {
|
|
1821
|
+
const cur = this.picked.get(p.pair);
|
|
1822
|
+
const set = cur === "all" ? new Set(this._pages.map((x) => x.id)) : new Set(cur);
|
|
1823
|
+
if (v) set.add(pg.id);
|
|
1824
|
+
else set.delete(pg.id);
|
|
1825
|
+
this.picked.set(p.pair, set);
|
|
1826
|
+
this.renderAsks();
|
|
1827
|
+
});
|
|
1828
|
+
box("all pages, and pages opened later", all, (v) => {
|
|
1829
|
+
this.picked.set(p.pair, v ? "all" : new Set(this._self ? [this._self] : []));
|
|
1830
|
+
this.renderAsks();
|
|
1831
|
+
});
|
|
1832
|
+
wrap.append(ask, list);
|
|
1833
|
+
return wrap;
|
|
1834
|
+
}
|
|
1835
|
+
/** A polished line's body (its text, or tapped, the raw STT text) and its sparkle. */
|
|
1836
|
+
lineBody(m, onToggle) {
|
|
1837
|
+
if (!m.edited || m.raw === void 0) return [document.createTextNode(m.text)];
|
|
1838
|
+
const raw = this.rawIds.has(m.id);
|
|
1839
|
+
const body = document.createElement("span");
|
|
1840
|
+
if (raw) body.className = "raw";
|
|
1841
|
+
body.textContent = raw ? m.raw : m.text;
|
|
1842
|
+
const mark = document.createElement("button");
|
|
1843
|
+
mark.type = "button";
|
|
1844
|
+
mark.className = "polished";
|
|
1845
|
+
mark.title = this.ui.polished;
|
|
1846
|
+
mark.setAttribute("aria-label", this.ui.polished);
|
|
1847
|
+
mark.setAttribute("aria-pressed", String(raw));
|
|
1848
|
+
mark.innerHTML = ICONS.sparkle;
|
|
1849
|
+
mark.addEventListener("click", (ev) => {
|
|
1850
|
+
ev.stopPropagation();
|
|
1851
|
+
if (!this.rawIds.delete(m.id)) this.rawIds.add(m.id);
|
|
1852
|
+
onToggle();
|
|
1853
|
+
});
|
|
1854
|
+
return [body, mark];
|
|
1855
|
+
}
|
|
1856
|
+
renderLog() {
|
|
1857
|
+
if (!this.root) return;
|
|
1858
|
+
const log = this.els.log;
|
|
1859
|
+
log.replaceChildren();
|
|
1860
|
+
if (this._messages.length === 0) {
|
|
1861
|
+
const li = document.createElement("li");
|
|
1862
|
+
li.className = "hint";
|
|
1863
|
+
li.textContent = `Type here; it is sent ${DEBOUNCE_MS / 1e3} s after you stop.`;
|
|
1864
|
+
log.append(li);
|
|
1865
|
+
}
|
|
1866
|
+
for (const m of this._messages) {
|
|
1867
|
+
const li = document.createElement("li");
|
|
1868
|
+
li.dataset.from = m.from;
|
|
1869
|
+
const who = lineAvatar(m, {
|
|
1870
|
+
friend: this._friend,
|
|
1871
|
+
user: this._user,
|
|
1872
|
+
agentAvatar: this._agentAvatar,
|
|
1873
|
+
avatarFor: this.avatarFor
|
|
1874
|
+
});
|
|
1875
|
+
const node = lineNode(m);
|
|
1876
|
+
li.title = node ? `${when(m.at)} · ${who.name} · ${node.subtitle}` : when(m.at);
|
|
1877
|
+
const text = document.createElement("span");
|
|
1878
|
+
text.className = "text";
|
|
1879
|
+
if (node) {
|
|
1880
|
+
li.classList.add("node");
|
|
1881
|
+
li.dataset.node = node.node;
|
|
1882
|
+
li.style.setProperty("--node", node.border);
|
|
1883
|
+
const sub = document.createElement("span");
|
|
1884
|
+
sub.className = "sub";
|
|
1885
|
+
sub.setAttribute("aria-hidden", "true");
|
|
1886
|
+
sub.textContent = node.subtitle;
|
|
1887
|
+
text.append(sub);
|
|
1888
|
+
}
|
|
1889
|
+
const sr = document.createElement("span");
|
|
1890
|
+
sr.className = "sr";
|
|
1891
|
+
sr.textContent = node ? `${who.name} (${node.subtitle}): ` : `${who.name}: `;
|
|
1892
|
+
text.append(sr, ...this.lineBody(m, () => {
|
|
1893
|
+
this.renderLog();
|
|
1894
|
+
this.renderFloatTexts();
|
|
1895
|
+
}));
|
|
1896
|
+
li.append(avatarEl(who.srcs, who.node, who.name, initialOf(who.initialFrom), who.seed), text);
|
|
1897
|
+
log.append(li);
|
|
1898
|
+
}
|
|
1899
|
+
if (this.open && this.atEnd) this.toEnd();
|
|
1900
|
+
this.onColumnScroll();
|
|
1901
|
+
this.renderStatus();
|
|
1902
|
+
}
|
|
1903
|
+
toEnd() {
|
|
1904
|
+
const c = this.els.column;
|
|
1905
|
+
if (!c || !this.open) return;
|
|
1906
|
+
c.scrollTop = c.scrollHeight;
|
|
1907
|
+
this.atEnd = true;
|
|
1908
|
+
}
|
|
1909
|
+
onColumnScroll() {
|
|
1910
|
+
const c = this.els.column;
|
|
1911
|
+
if (!c) return;
|
|
1912
|
+
c.classList.toggle("fade", c.scrollHeight > c.clientHeight + 1 || c.scrollTop > 0);
|
|
1913
|
+
this.atEnd = c.scrollHeight - c.scrollTop - c.clientHeight < 4;
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* After the messages (or open/minimized) changed: what arrived (by id, on
|
|
1917
|
+
* this device's clock), what is read, and what floats. Expanded, everything
|
|
1918
|
+
* is read and nothing floats; just minimized, the latest floats again;
|
|
1919
|
+
* minimized, every new line floats — never the history loading in.
|
|
1920
|
+
*/
|
|
1921
|
+
messagesChanged(mine = false) {
|
|
1922
|
+
const now = Date.now();
|
|
1923
|
+
const { fresh, history } = this.arrivals.see(this._messages, now);
|
|
1924
|
+
if (this.open || history) this.readSeq = this.arrivals.seq();
|
|
1925
|
+
const minimizing = this.wasOpen && !this.open;
|
|
1926
|
+
this.wasOpen = this.open;
|
|
1927
|
+
if (mine) this.atEnd = true;
|
|
1928
|
+
this.renderLog();
|
|
1929
|
+
if (!this.root) return;
|
|
1930
|
+
if (this.open) return this.clearFloats();
|
|
1931
|
+
if (minimizing) {
|
|
1932
|
+
this.clearFloats();
|
|
1933
|
+
this.frontierOpen = null;
|
|
1934
|
+
const last = minimizeFloat(this._messages, now, this.arrivals.at);
|
|
1935
|
+
if (last) this.addFloat(last);
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
if (history) return;
|
|
1939
|
+
for (const m of fresh) this.addFloat(m);
|
|
1940
|
+
}
|
|
1941
|
+
clearFloats() {
|
|
1942
|
+
for (const f of this.floats.values()) f.life.cancel();
|
|
1943
|
+
this.floats.clear();
|
|
1944
|
+
this.els.floats?.replaceChildren();
|
|
1945
|
+
}
|
|
1946
|
+
addFloat(m) {
|
|
1947
|
+
const old = this.floats.get(m.id);
|
|
1948
|
+
if (old) {
|
|
1949
|
+
old.life.cancel();
|
|
1950
|
+
old.li.remove();
|
|
1951
|
+
this.floats.delete(m.id);
|
|
1952
|
+
}
|
|
1953
|
+
const li = document.createElement("li");
|
|
1954
|
+
li.className = "float";
|
|
1955
|
+
li.dataset.from = m.from;
|
|
1956
|
+
const f = {
|
|
1957
|
+
m,
|
|
1958
|
+
li,
|
|
1959
|
+
life: createFloatLife({
|
|
1960
|
+
ms: dwellMs(m.text),
|
|
1961
|
+
reducedMotion: reducedMotion(),
|
|
1962
|
+
paused: this.floatHover,
|
|
1963
|
+
onPhase: (p) => {
|
|
1964
|
+
if (p === "gone") this.dropFloat(m.id);
|
|
1965
|
+
else li.classList.toggle("fading", p === "fade");
|
|
1966
|
+
}
|
|
1967
|
+
})
|
|
1968
|
+
};
|
|
1969
|
+
this.floats.set(m.id, f);
|
|
1970
|
+
this.fillFloat(f.li, m);
|
|
1971
|
+
this.els.floats.append(li);
|
|
1972
|
+
while (this.floats.size > FLOAT_MAX) this.dropFloat(this.floats.keys().next().value);
|
|
1973
|
+
this.placeFloat();
|
|
1974
|
+
}
|
|
1975
|
+
dropFloat(id) {
|
|
1976
|
+
const f = this.floats.get(id);
|
|
1977
|
+
if (!f) return;
|
|
1978
|
+
f.life.cancel();
|
|
1979
|
+
f.li.remove();
|
|
1980
|
+
this.floats.delete(id);
|
|
1981
|
+
this.placeFloat();
|
|
1982
|
+
}
|
|
1983
|
+
/** "who: text" (the latest copy: an edit changes it in place), and a polished line's sparkle. */
|
|
1984
|
+
fillFloat(li, m0) {
|
|
1985
|
+
const m = this._messages.find((x) => x.id === m0.id) ?? m0;
|
|
1986
|
+
const who = document.createElement("span");
|
|
1987
|
+
who.className = "who";
|
|
1988
|
+
who.style.color = m.from !== "agent" ? "oklch(0.55 0.13 250)" : "oklch(0.55 0.14 150)";
|
|
1989
|
+
who.textContent = `${m.from !== "agent" ? "you" : m.label || "agent"}:`;
|
|
1990
|
+
li.replaceChildren(who, ...this.lineBody(m, () => this.renderFloatTexts()));
|
|
1991
|
+
}
|
|
1992
|
+
renderFloatTexts() {
|
|
1993
|
+
for (const f of this.floats.values()) this.fillFloat(f.li, f.m);
|
|
1994
|
+
}
|
|
1995
|
+
/** Hovered or touched: every line's timer waits; released, they go on. */
|
|
1996
|
+
holdFloats(v) {
|
|
1997
|
+
if (this.floatHover === v) return;
|
|
1998
|
+
this.floatHover = v;
|
|
1999
|
+
for (const f of this.floats.values()) (v ? f.life.pause : f.life.resume)();
|
|
2000
|
+
}
|
|
2001
|
+
renderFrontier() {
|
|
2002
|
+
if (!this.root) return;
|
|
2003
|
+
const ul = this.els.frontier;
|
|
2004
|
+
ul.replaceChildren();
|
|
2005
|
+
if (this.open || !this.treeRoot) return this.placeFloat();
|
|
2006
|
+
const lang = globalThis.document?.documentElement.lang || void 0;
|
|
2007
|
+
const view = frontierView(frontier(this.treeRoot, { locale: lang }));
|
|
2008
|
+
const ui = this.ui;
|
|
2009
|
+
for (const r of FRONTIER_ROWS) {
|
|
2010
|
+
const line = view?.[r.key];
|
|
2011
|
+
if (!line?.text) continue;
|
|
2012
|
+
const items = line.items ?? [];
|
|
2013
|
+
const group = items.length > 0;
|
|
2014
|
+
const expanded = group && this.frontierOpen === r.key;
|
|
2015
|
+
const id = r.key === "doing" ? shownAgentId(line.assignee) : null;
|
|
2016
|
+
const count = countBadge(line.count);
|
|
2017
|
+
const li = document.createElement("li");
|
|
2018
|
+
li.dataset.status = r.key;
|
|
2019
|
+
if (expanded) li.dataset.expanded = "";
|
|
2020
|
+
const row = document.createElement("div");
|
|
2021
|
+
row.className = "fl";
|
|
2022
|
+
const b = document.createElement("button");
|
|
2023
|
+
b.type = "button";
|
|
2024
|
+
b.className = "fb";
|
|
2025
|
+
const name = r.key === "done" ? ui.lastDone : r.key === "doing" ? ui.nowDoing : ui.upNext;
|
|
2026
|
+
b.setAttribute("aria-label", `${name}: ${line.text}${count ? `, ${line.count} ${ui.items}` : ""}`);
|
|
2027
|
+
if (group) b.setAttribute("aria-expanded", String(expanded));
|
|
2028
|
+
b.addEventListener("mousedown", (e) => e.preventDefault());
|
|
2029
|
+
b.addEventListener("click", () => {
|
|
2030
|
+
if (!group) return;
|
|
2031
|
+
this.frontierOpen = expanded ? null : r.key;
|
|
2032
|
+
this.renderFrontier();
|
|
2033
|
+
});
|
|
2034
|
+
const mk = document.createElement("span");
|
|
2035
|
+
mk.className = `m${r.key === "doing" ? " blink" : ""}`;
|
|
2036
|
+
mk.setAttribute("aria-hidden", "true");
|
|
2037
|
+
mk.textContent = r.mark;
|
|
2038
|
+
b.append(mk);
|
|
2039
|
+
if (r.key === "doing" && line.assignee) {
|
|
2040
|
+
const av = assigneeAvatar(line.assignee);
|
|
2041
|
+
av.classList.add("blink");
|
|
2042
|
+
b.append(av);
|
|
2043
|
+
}
|
|
2044
|
+
const t = document.createElement("span");
|
|
2045
|
+
t.className = "t";
|
|
2046
|
+
t.textContent = line.text;
|
|
2047
|
+
b.append(t);
|
|
2048
|
+
if (count) {
|
|
2049
|
+
const n = document.createElement("span");
|
|
2050
|
+
n.className = "n";
|
|
2051
|
+
n.textContent = count;
|
|
2052
|
+
b.append(n);
|
|
2053
|
+
}
|
|
2054
|
+
row.append(b);
|
|
2055
|
+
if (id) row.append(agentIdEl(id));
|
|
2056
|
+
li.append(row);
|
|
2057
|
+
if (expanded) li.append(frontierItems(items));
|
|
2058
|
+
ul.append(li);
|
|
2059
|
+
}
|
|
2060
|
+
this.placeFloat();
|
|
2061
|
+
}
|
|
2062
|
+
/** The float box on the bar's side of the screen, inside the viewport and its safe areas (anchor.ts). */
|
|
2063
|
+
placeFloat() {
|
|
2064
|
+
const fb = this.els.floatbox;
|
|
2065
|
+
if (!fb) return;
|
|
2066
|
+
const show = !this.open && this.panel !== "menu" && (this.floats.size > 0 || this.els.frontier.childElementCount > 0 || this.panel === "tree");
|
|
2067
|
+
fb.hidden = !show;
|
|
2068
|
+
if (!show) return;
|
|
2069
|
+
const bar = this.els.bar.getBoundingClientRect();
|
|
2070
|
+
const row = this.els.rowbox.getBoundingClientRect();
|
|
2071
|
+
const ins = safeInsets();
|
|
2072
|
+
const p = placeBox({
|
|
2073
|
+
barLeft: bar.left,
|
|
2074
|
+
barRight: bar.right,
|
|
2075
|
+
boxWidth: fb.getBoundingClientRect().width,
|
|
2076
|
+
vw: document.documentElement.clientWidth || innerWidth,
|
|
2077
|
+
insetLeft: ins.left,
|
|
2078
|
+
insetRight: ins.right
|
|
2079
|
+
});
|
|
2080
|
+
const next = {
|
|
2081
|
+
side: p.side,
|
|
2082
|
+
left: p.left - row.left
|
|
2083
|
+
};
|
|
2084
|
+
if (next.side === this.place.side && next.left === this.place.left && fb.dataset.side) return;
|
|
2085
|
+
this.place = next;
|
|
2086
|
+
fb.dataset.side = next.side;
|
|
2087
|
+
fb.style.left = `${next.left}px`;
|
|
2088
|
+
}
|
|
2089
|
+
renderInput() {
|
|
2090
|
+
if (!this.root) return;
|
|
2091
|
+
const warn = this.els.warn;
|
|
2092
|
+
warn.hidden = !this._warning || !this.open;
|
|
2093
|
+
warn.textContent = this._warning;
|
|
2094
|
+
const notice = this.els.notice;
|
|
2095
|
+
notice.hidden = !this._notice;
|
|
2096
|
+
notice.textContent = this._notice;
|
|
2097
|
+
this.els.input.hidden = !!this._notice;
|
|
2098
|
+
this.renderStatus();
|
|
2099
|
+
}
|
|
2100
|
+
renderMenu() {
|
|
2101
|
+
if (!this.root) return;
|
|
2102
|
+
const menu = this.els.menu;
|
|
2103
|
+
menu.hidden = this.panel !== "menu";
|
|
2104
|
+
if (menu.hidden) return;
|
|
2105
|
+
menu.replaceChildren();
|
|
2106
|
+
const status = document.createElement("span");
|
|
2107
|
+
status.className = "status";
|
|
2108
|
+
status.setAttribute("aria-live", "polite");
|
|
2109
|
+
status.textContent = this.status();
|
|
2110
|
+
const speak = document.createElement("button");
|
|
2111
|
+
speak.type = "button";
|
|
2112
|
+
speak.setAttribute("role", "menuitemcheckbox");
|
|
2113
|
+
const on = this.speaking || this._micActive;
|
|
2114
|
+
speak.setAttribute("aria-checked", String(on));
|
|
2115
|
+
speak.innerHTML = ICONS.speaker;
|
|
2116
|
+
speak.append(this._micActive ? "Replies read aloud (mic on)" : on ? "Replies read aloud" : "Replies silent");
|
|
2117
|
+
speak.addEventListener("click", () => {
|
|
2118
|
+
if (this._micActive) return;
|
|
2119
|
+
const v = !this.speaking;
|
|
2120
|
+
this.write("speak", v ? "1" : "0");
|
|
2121
|
+
if (!v) this.stopSpeech("off");
|
|
2122
|
+
this.renderMenu();
|
|
2123
|
+
});
|
|
2124
|
+
const rate = document.createElement("div");
|
|
2125
|
+
rate.className = "rate";
|
|
2126
|
+
rate.setAttribute("role", "radiogroup");
|
|
2127
|
+
rate.setAttribute("aria-label", "Reading speed");
|
|
2128
|
+
const lab = document.createElement("span");
|
|
2129
|
+
lab.textContent = "Speed";
|
|
2130
|
+
rate.append(lab);
|
|
2131
|
+
const cur = this.speechRate;
|
|
2132
|
+
for (const r of SPEECH_RATES) {
|
|
2133
|
+
const b = document.createElement("button");
|
|
2134
|
+
b.type = "button";
|
|
2135
|
+
b.setAttribute("role", "radio");
|
|
2136
|
+
b.setAttribute("aria-checked", String(r === cur));
|
|
2137
|
+
b.dataset.rate = String(r);
|
|
2138
|
+
b.textContent = `${r}×`;
|
|
2139
|
+
b.addEventListener("click", () => {
|
|
2140
|
+
setSpeechRate(r, this.rateStore);
|
|
2141
|
+
this.renderMenu();
|
|
2142
|
+
});
|
|
2143
|
+
rate.append(b);
|
|
2144
|
+
}
|
|
2145
|
+
menu.append(status, speak, rate);
|
|
2146
|
+
if (this._treeSource) {
|
|
2147
|
+
const tree = document.createElement("button");
|
|
2148
|
+
tree.type = "button";
|
|
2149
|
+
tree.setAttribute("role", "menuitem");
|
|
2150
|
+
tree.textContent = `☰ ${this.treeText.tree}`;
|
|
2151
|
+
tree.addEventListener("click", () => this.setPanel("tree"));
|
|
2152
|
+
menu.append(tree);
|
|
2153
|
+
}
|
|
2154
|
+
const min = document.createElement("button");
|
|
2155
|
+
min.type = "button";
|
|
2156
|
+
min.setAttribute("role", "menuitem");
|
|
2157
|
+
min.textContent = this.open ? "– Minimize" : "+ Restore";
|
|
2158
|
+
min.addEventListener("click", () => {
|
|
2159
|
+
this.setPanel("none");
|
|
2160
|
+
this.setOpen(!this.open);
|
|
2161
|
+
});
|
|
2162
|
+
menu.append(min);
|
|
2163
|
+
for (const x of this._sessions) {
|
|
2164
|
+
const row = document.createElement("div");
|
|
2165
|
+
row.className = "sess";
|
|
2166
|
+
const label = document.createElement("span");
|
|
2167
|
+
const where = x.pages === void 0 || x.pages === "all" ? "all pages" : `${x.pages.length} page${x.pages.length === 1 ? "" : "s"}`;
|
|
2168
|
+
label.textContent = `${x.scope === "chat" ? "chat" : this._friend ? "preview" : "eval"}: ${x.label} · ${x.code} · ${where}`;
|
|
2169
|
+
const end = document.createElement("button");
|
|
2170
|
+
end.type = "button";
|
|
2171
|
+
end.textContent = "End";
|
|
2172
|
+
end.addEventListener("click", () => {
|
|
2173
|
+
this.setPanel("none");
|
|
2174
|
+
this.onEnd?.(x);
|
|
2175
|
+
});
|
|
2176
|
+
row.append(label, end);
|
|
2177
|
+
menu.append(row);
|
|
2178
|
+
}
|
|
2179
|
+
if (this._pages.length < 2) return;
|
|
2180
|
+
const head = document.createElement("span");
|
|
2181
|
+
head.className = "head";
|
|
2182
|
+
head.textContent = "Your open pages";
|
|
2183
|
+
menu.append(head);
|
|
2184
|
+
for (const pg of this._pages) {
|
|
2185
|
+
const row = document.createElement("div");
|
|
2186
|
+
row.className = "pg";
|
|
2187
|
+
const label = document.createElement("span");
|
|
2188
|
+
const mine = pg.id === this._self;
|
|
2189
|
+
label.textContent = `${pageHint(pg.label)}${mine ? " (this page)" : ""}`;
|
|
2190
|
+
label.title = pg.label;
|
|
2191
|
+
const end = document.createElement("button");
|
|
2192
|
+
end.type = "button";
|
|
2193
|
+
end.textContent = mine && this.confirmEnd === pg.id ? "End this page?" : "End";
|
|
2194
|
+
end.addEventListener("click", () => {
|
|
2195
|
+
if (mine && this.confirmEnd !== pg.id) {
|
|
2196
|
+
this.confirmEnd = pg.id;
|
|
2197
|
+
return this.renderMenu();
|
|
2198
|
+
}
|
|
2199
|
+
this.confirmEnd = "";
|
|
2200
|
+
this.onEndPage?.(pg.id);
|
|
2201
|
+
});
|
|
2202
|
+
row.append(label, end);
|
|
2203
|
+
menu.append(row);
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
};
|
|
2207
|
+
/** The same avatar value: one reference, or lists with the same URLs. */
|
|
2208
|
+
function same(a, b) {
|
|
2209
|
+
if (a === b) return true;
|
|
2210
|
+
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => x === b[i]);
|
|
2211
|
+
}
|
|
2212
|
+
/** An assignee's avatar in the frontier, 16px: their initial on their oklch colour. */
|
|
2213
|
+
function assigneeAvatar(a) {
|
|
2214
|
+
const el = document.createElement("span");
|
|
2215
|
+
el.className = "fav";
|
|
2216
|
+
el.setAttribute("role", "img");
|
|
2217
|
+
el.setAttribute("aria-label", `${a.type === "agent" ? "Agent" : "Person"}: ${a.name}`);
|
|
2218
|
+
el.title = a.name;
|
|
2219
|
+
const { bg, fg } = avatarColors(`${a.type}:${a.name}`);
|
|
2220
|
+
el.style.background = bg;
|
|
2221
|
+
el.style.color = fg;
|
|
2222
|
+
el.textContent = initialOf(a.name);
|
|
2223
|
+
return el;
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* An agent's id at the line's far right: its last characters, monospace and
|
|
2227
|
+
* muted. The full id is in the title and label, and a long press on a touch
|
|
2228
|
+
* screen shows it in a small popover for 2 s.
|
|
2229
|
+
*/
|
|
2230
|
+
function agentIdEl(id) {
|
|
2231
|
+
const el = document.createElement("span");
|
|
2232
|
+
el.className = "id";
|
|
2233
|
+
el.title = `agent id: ${id}`;
|
|
2234
|
+
el.setAttribute("aria-label", `agent id ${id}`);
|
|
2235
|
+
el.textContent = shortId(id);
|
|
2236
|
+
let hide;
|
|
2237
|
+
const full = document.createElement("span");
|
|
2238
|
+
full.className = "full";
|
|
2239
|
+
full.setAttribute("role", "tooltip");
|
|
2240
|
+
full.textContent = id;
|
|
2241
|
+
const press = createLongPress({
|
|
2242
|
+
ms: LONG_PRESS_MS,
|
|
2243
|
+
onLong: () => {
|
|
2244
|
+
clearTimeout(hide);
|
|
2245
|
+
el.append(full);
|
|
2246
|
+
}
|
|
2247
|
+
});
|
|
2248
|
+
el.addEventListener("pointerdown", (e) => e.pointerType !== "mouse" && press.down());
|
|
2249
|
+
el.addEventListener("pointerup", () => {
|
|
2250
|
+
if (press.up()) hide = setTimeout(() => full.remove(), 2e3);
|
|
2251
|
+
});
|
|
2252
|
+
el.addEventListener("pointerleave", () => press.cancel());
|
|
2253
|
+
el.addEventListener("pointercancel", () => press.cancel());
|
|
2254
|
+
el.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
2255
|
+
return el;
|
|
2256
|
+
}
|
|
2257
|
+
/** A frontier group's items: title and breadcrumb, scrolling past about six. */
|
|
2258
|
+
function frontierItems(items) {
|
|
2259
|
+
const ul = document.createElement("ul");
|
|
2260
|
+
ul.className = "items";
|
|
2261
|
+
for (const it of items) {
|
|
2262
|
+
const li = document.createElement("li");
|
|
2263
|
+
if (it.assignee) li.append(assigneeAvatar(it.assignee));
|
|
2264
|
+
const t = document.createElement("span");
|
|
2265
|
+
t.className = "t";
|
|
2266
|
+
t.textContent = it.title;
|
|
2267
|
+
if (it.path) {
|
|
2268
|
+
const p = document.createElement("span");
|
|
2269
|
+
p.className = "p";
|
|
2270
|
+
p.textContent = it.path;
|
|
2271
|
+
t.append(p);
|
|
2272
|
+
}
|
|
2273
|
+
li.append(t);
|
|
2274
|
+
ul.append(li);
|
|
2275
|
+
}
|
|
2276
|
+
return ul;
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* One avatar: each picture in turn (no referrer, which Google's need; lazy),
|
|
2280
|
+
* then the element, then the initial on an oklch circle. Labelled with the
|
|
2281
|
+
* name (alt / aria-label) and titled with it, and aria-hidden: the line's
|
|
2282
|
+
* screen-reader text already says who.
|
|
2283
|
+
*/
|
|
2284
|
+
function avatarEl(srcs, node, name, letter, seed, failed = 0) {
|
|
2285
|
+
let el;
|
|
2286
|
+
if (failed < srcs.length) {
|
|
2287
|
+
const img = document.createElement("img");
|
|
2288
|
+
img.alt = name;
|
|
2289
|
+
img.referrerPolicy = "no-referrer";
|
|
2290
|
+
img.loading = "lazy";
|
|
2291
|
+
img.decoding = "async";
|
|
2292
|
+
img.draggable = false;
|
|
2293
|
+
img.addEventListener("error", () => img.replaceWith(avatarEl(srcs, node, name, letter, seed, failed + 1)), { once: true });
|
|
2294
|
+
img.src = srcs[failed];
|
|
2295
|
+
el = img;
|
|
2296
|
+
} else {
|
|
2297
|
+
el = document.createElement("span");
|
|
2298
|
+
el.setAttribute("role", "img");
|
|
2299
|
+
el.setAttribute("aria-label", name);
|
|
2300
|
+
if (node) el.append(node.cloneNode(true));
|
|
2301
|
+
else {
|
|
2302
|
+
const { bg, fg } = avatarColors(seed);
|
|
2303
|
+
el.style.background = bg;
|
|
2304
|
+
el.style.color = fg;
|
|
2305
|
+
el.textContent = letter;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
el.className = "av";
|
|
2309
|
+
el.title = name;
|
|
2310
|
+
el.setAttribute("aria-hidden", "true");
|
|
2311
|
+
return el;
|
|
2312
|
+
}
|
|
2313
|
+
function defineChatBox(tag = "liagent-chat") {
|
|
2314
|
+
if (typeof customElements !== "undefined" && !customElements.get(tag)) customElements.define(tag, ChatBox);
|
|
2315
|
+
return ChatBox;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
//#endregion
|
|
2319
|
+
export { avatarColors as a, initialOf as c, when as i, lineAvatar as l, defineChatBox as n, avatarSrcs as o, pageHint as r, hashId as s, ChatBox as t, DEBOUNCE_MS as u };
|
|
2320
|
+
//# sourceMappingURL=box-BSIrRRc_.js.map
|