bom-pi 0.0.0-stage → 0.0.2
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 +33 -2
- package/apps/README.md +7 -0
- package/apps/web/README.md +21 -0
- package/apps/web/pocket-adapter.mjs +323 -0
- package/apps/web/public/LICENSE.pocket +21 -0
- package/apps/web/public/activity-fold.js +28 -0
- package/apps/web/public/agent-now.js +37 -0
- package/apps/web/public/agent-tile.js +79 -0
- package/apps/web/public/app.js +975 -0
- package/apps/web/public/apple-touch-icon.png +0 -0
- package/apps/web/public/artifact-bridge.js +687 -0
- package/apps/web/public/avatar.js +87 -0
- package/apps/web/public/back.js +554 -0
- package/apps/web/public/box-keys.js +216 -0
- package/apps/web/public/browser-panel.js +1097 -0
- package/apps/web/public/browser.js +68 -0
- package/apps/web/public/calls.js +139 -0
- package/apps/web/public/chat.js +919 -0
- package/apps/web/public/clock.js +34 -0
- package/apps/web/public/commands.js +578 -0
- package/apps/web/public/composer.js +2068 -0
- package/apps/web/public/diff-parse.js +672 -0
- package/apps/web/public/diff.js +1646 -0
- package/apps/web/public/dock.js +299 -0
- package/apps/web/public/extension-ui.js +2734 -0
- package/apps/web/public/files-panel.js +775 -0
- package/apps/web/public/files.js +368 -0
- package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
- package/apps/web/public/fonts/Geist.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
- package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
- package/apps/web/public/fonts/SOURCES.md +69 -0
- package/apps/web/public/gestures.js +610 -0
- package/apps/web/public/highlight.js +480 -0
- package/apps/web/public/history.js +70 -0
- package/apps/web/public/home.js +169 -0
- package/apps/web/public/icon-192.png +0 -0
- package/apps/web/public/icon-512.png +0 -0
- package/apps/web/public/icon-maskable-512.png +0 -0
- package/apps/web/public/icon.svg +16 -0
- package/apps/web/public/index.html +71 -0
- package/apps/web/public/launcher.js +1293 -0
- package/apps/web/public/linked-sessions.js +55 -0
- package/apps/web/public/live.js +39 -0
- package/apps/web/public/manifest.webmanifest +32 -0
- package/apps/web/public/model-refresh.js +73 -0
- package/apps/web/public/notify.js +251 -0
- package/apps/web/public/peeks.js +920 -0
- package/apps/web/public/pip-attachments.js +182 -0
- package/apps/web/public/pip-window.js +141 -0
- package/apps/web/public/pip.js +1217 -0
- package/apps/web/public/quote-reply.js +109 -0
- package/apps/web/public/quote-text.js +31 -0
- package/apps/web/public/rich.js +403 -0
- package/apps/web/public/run-frame.js +106 -0
- package/apps/web/public/safe-mode.js +71 -0
- package/apps/web/public/seen.js +169 -0
- package/apps/web/public/session-tree.js +117 -0
- package/apps/web/public/sessions.js +2695 -0
- package/apps/web/public/share.js +126 -0
- package/apps/web/public/sheets/appearance.js +820 -0
- package/apps/web/public/sheets/branch.js +353 -0
- package/apps/web/public/sheets/extensions.js +818 -0
- package/apps/web/public/sheets/file.js +318 -0
- package/apps/web/public/sheets/find.js +250 -0
- package/apps/web/public/sheets/folder.js +680 -0
- package/apps/web/public/sheets/gallery.js +2340 -0
- package/apps/web/public/sheets/hooks.js +350 -0
- package/apps/web/public/sheets/memory.js +531 -0
- package/apps/web/public/sheets/menu.js +580 -0
- package/apps/web/public/sheets/message.js +220 -0
- package/apps/web/public/sheets/model.js +412 -0
- package/apps/web/public/sheets/people.js +414 -0
- package/apps/web/public/sheets/pi-sessions.js +264 -0
- package/apps/web/public/sheets/providers.js +525 -0
- package/apps/web/public/sheets/running.js +130 -0
- package/apps/web/public/sheets/schedules.js +71 -0
- package/apps/web/public/sheets/session.js +249 -0
- package/apps/web/public/sheets/shortcuts.js +101 -0
- package/apps/web/public/sheets/spend.js +150 -0
- package/apps/web/public/sheets/text.js +53 -0
- package/apps/web/public/sheets/viewers.js +363 -0
- package/apps/web/public/sheets/worktree.js +50 -0
- package/apps/web/public/sheets.js +410 -0
- package/apps/web/public/signin.js +57 -0
- package/apps/web/public/slots.js +208 -0
- package/apps/web/public/store.js +1760 -0
- package/apps/web/public/style.css +18625 -0
- package/apps/web/public/subagents-board.js +879 -0
- package/apps/web/public/subagents.js +273 -0
- package/apps/web/public/sw.js +169 -0
- package/apps/web/public/taps.js +26 -0
- package/apps/web/public/theme-surface.d.ts +4 -0
- package/apps/web/public/theme-surface.js +266 -0
- package/apps/web/public/theme.js +845 -0
- package/apps/web/public/themes/lantern/colors.toml +25 -0
- package/apps/web/public/themes/lantern/direction.css +60 -0
- package/apps/web/public/themes/lantern/frame.css +254 -0
- package/apps/web/public/themes/lantern/theme.css +10 -0
- package/apps/web/public/themes/lantern/theme.json +7 -0
- package/apps/web/public/themes/pocket/theme.css +189 -0
- package/apps/web/public/themes/pocket/theme.json +6 -0
- package/apps/web/public/themes.js +556 -0
- package/apps/web/public/transcript.js +3348 -0
- package/apps/web/public/trust.js +295 -0
- package/apps/web/public/turns.js +507 -0
- package/apps/web/public/ui.js +1910 -0
- package/apps/web/public/voice.js +345 -0
- package/apps/web/server.mjs +774 -0
- package/package.json +43 -4
- package/packages/cli/src/bom-pi.mjs +452 -0
- package/packages/cli/templates/blank.factory.json +7 -0
- package/packages/cli/templates/dev.factory.json +11 -0
- package/packages/cli/templates/docs.factory.json +12 -0
- package/packages/cli/templates/ops.factory.json +14 -0
- package/packages/cli/templates/triage.factory.json +10 -0
- package/packages/core/src/daemon.mjs +2047 -0
- package/packages/core/src/limit-gate.mjs +192 -0
- package/packages/core/src/rpc-client.mjs +66 -0
- package/systemd/README.md +11 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
// Peek tiles: other sessions' live work beside the open one, off until turned on (the top bar's tiles button, Alt+P,
|
|
2
|
+
// /peek, the launcher, or Appearance). Wide screens show a column of tiles where the People and Browser panels dock
|
|
3
|
+
// (when neither is open), narrower ones a strip under the top bar. Tiles are the sessions working, waiting for
|
|
4
|
+
// approval, with subagents at work, finished since this browser last looked, and pinned, plus the one just left. A tile
|
|
5
|
+
// lists the first few subagents at work in its session, a row each with what it does this moment, and counts the rest.
|
|
6
|
+
// Only tiles on screen are live: once scrolling settles, the tab tells the server which (`POST /api/peeks`), subagent
|
|
7
|
+
// rows too, and the rest keep the lines they last had. Their state marks stay current from the session list, which
|
|
8
|
+
// covers every session.
|
|
9
|
+
import { Component } from "preact";
|
|
10
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
11
|
+
import { AgentTile } from "./agent-tile.js";
|
|
12
|
+
import { folderColor } from "./avatar.js";
|
|
13
|
+
import { describeCall } from "./calls.js";
|
|
14
|
+
import { finishedUnseen, markSeen, seenSince } from "./seen.js";
|
|
15
|
+
import {
|
|
16
|
+
actions,
|
|
17
|
+
api,
|
|
18
|
+
attempt,
|
|
19
|
+
canSteer,
|
|
20
|
+
navigate,
|
|
21
|
+
notify,
|
|
22
|
+
store,
|
|
23
|
+
workspaceOrder,
|
|
24
|
+
} from "./store.js";
|
|
25
|
+
import { prefs, setPrefs } from "./theme.js";
|
|
26
|
+
import { html, Icon, railState, shortPath, usePresence } from "./ui.js";
|
|
27
|
+
|
|
28
|
+
/** Wide enough for the column beside the conversation: where People and the Browser panel dock too. */
|
|
29
|
+
export const PEEK_WIDE = matchMedia("(min-width: 1100px)");
|
|
30
|
+
/** How long the tiles on screen must stay put before the server hears of them: scrolling past is not looking. */
|
|
31
|
+
const SETTLE_MS = 250;
|
|
32
|
+
/** As many as the server keeps live for a tab (`MAX_PEEKS`): the tiles and the subagent rows in them. */
|
|
33
|
+
const MAX_LIVE = 20;
|
|
34
|
+
/** The subagent rows a tile shows, the rest counted under them: a tile stays a glance, however many work. */
|
|
35
|
+
const AGENT_ROWS = 3;
|
|
36
|
+
/** In the strip, which a phone shares with the conversation: one row and the count. */
|
|
37
|
+
const AGENT_ROWS_COMPACT = 1;
|
|
38
|
+
/** A call may be allowed from a tile only when its whole command shows there: this long at most, on one line. */
|
|
39
|
+
const ALLOW_LENGTH = 80;
|
|
40
|
+
/** Waits before sending the list again after a send failed, growing with each failure. */
|
|
41
|
+
const RETRY_MS = [1000, 3000, 10_000];
|
|
42
|
+
|
|
43
|
+
PEEK_WIDE.addEventListener("change", () => store.set({}));
|
|
44
|
+
|
|
45
|
+
const hidden = () => document.visibilityState === "hidden";
|
|
46
|
+
|
|
47
|
+
store.set({ peeks: {} });
|
|
48
|
+
|
|
49
|
+
// ─── Which sessions are tiles, in a steady order ─────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** The tiles' order: kept as sessions come and go, so a tile stays where it was until it no longer belongs. */
|
|
52
|
+
let slots = [];
|
|
53
|
+
/** The session open before this one: it stays a tile, in the place of the one that was opened. */
|
|
54
|
+
let lastLeft = null;
|
|
55
|
+
let previous = store.state.conversationId;
|
|
56
|
+
|
|
57
|
+
// The session left stays a tile, in the place of the one opened. `seen.js` marks both seen.
|
|
58
|
+
store.subscribe((state) => {
|
|
59
|
+
if (state.conversationId !== previous) {
|
|
60
|
+
const left = previous;
|
|
61
|
+
const at = slots.indexOf(state.conversationId);
|
|
62
|
+
|
|
63
|
+
previous = state.conversationId;
|
|
64
|
+
|
|
65
|
+
if (left !== null) {
|
|
66
|
+
lastLeft = left;
|
|
67
|
+
|
|
68
|
+
if (at >= 0) {
|
|
69
|
+
slots[at] = left;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A session's subagents that work or wait: a tile shows them, and the session counts as working while one does.
|
|
77
|
+
* `subagents` is the session list's count by state.
|
|
78
|
+
*/
|
|
79
|
+
const busyAgents = (session) =>
|
|
80
|
+
(session.subagents?.working ?? 0) + (session.subagents?.waiting ?? 0);
|
|
81
|
+
|
|
82
|
+
const rank = (session, state, pinned) =>
|
|
83
|
+
session.waiting || (session.subagents?.waiting ?? 0) > 0
|
|
84
|
+
? 0
|
|
85
|
+
: session.busy || (session.subagents?.working ?? 0) > 0
|
|
86
|
+
? 1
|
|
87
|
+
: finishedUnseen(session, state)
|
|
88
|
+
? 2
|
|
89
|
+
: pinned.has(session.id)
|
|
90
|
+
? 3
|
|
91
|
+
: 4;
|
|
92
|
+
|
|
93
|
+
/** The last choice of tiles, and what it was made from: most renders (a streaming answer's) change none of it. */
|
|
94
|
+
let chosen = { from: [], tiles: [] };
|
|
95
|
+
|
|
96
|
+
/** The sessions shown as tiles now, in tile order. */
|
|
97
|
+
export function peekTiles(state = store.state) {
|
|
98
|
+
const from = [
|
|
99
|
+
state.sessions,
|
|
100
|
+
state.conversationId,
|
|
101
|
+
state.pinned,
|
|
102
|
+
state.peekSeen,
|
|
103
|
+
lastLeft,
|
|
104
|
+
seenSince(),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
if (from.every((value, index) => value === chosen.from[index])) {
|
|
108
|
+
return chosen.tiles;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const pinned = new Set(state.pinned);
|
|
112
|
+
const shown = state.sessions.filter(
|
|
113
|
+
(session) =>
|
|
114
|
+
!session.archived &&
|
|
115
|
+
session.id !== state.conversationId &&
|
|
116
|
+
(session.busy ||
|
|
117
|
+
session.waiting ||
|
|
118
|
+
busyAgents(session) > 0 ||
|
|
119
|
+
finishedUnseen(session, state) ||
|
|
120
|
+
pinned.has(session.id) ||
|
|
121
|
+
session.id === lastLeft),
|
|
122
|
+
);
|
|
123
|
+
const ids = new Set(shown.map((session) => session.id));
|
|
124
|
+
|
|
125
|
+
slots = slots.filter((id) => ids.has(id));
|
|
126
|
+
const placed = new Set(slots);
|
|
127
|
+
const fresh = shown
|
|
128
|
+
.filter((session) => !placed.has(session.id))
|
|
129
|
+
.sort((a, b) => rank(a, state, pinned) - rank(b, state, pinned));
|
|
130
|
+
|
|
131
|
+
slots.push(...fresh.map((session) => session.id));
|
|
132
|
+
const byId = new Map(shown.map((session) => [session.id, session]));
|
|
133
|
+
|
|
134
|
+
chosen = { from, tiles: slots.map((id) => byId.get(id)) };
|
|
135
|
+
|
|
136
|
+
return chosen.tiles;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Peek tiles are turned on in this browser. */
|
|
140
|
+
export const peeksOn = () => prefs().peeks === true;
|
|
141
|
+
|
|
142
|
+
/** Peeks show in this session: turned on, a server that sends them, and a session open. */
|
|
143
|
+
export function peeksWanted(state = store.state) {
|
|
144
|
+
return (
|
|
145
|
+
peeksOn() && state.server?.peeks === true && state.conversationId !== null && !state.missing
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Turn peek tiles on or off. Turned on where nothing would show yet, a notice says what will. */
|
|
150
|
+
export function togglePeeks() {
|
|
151
|
+
const on = !peeksOn();
|
|
152
|
+
|
|
153
|
+
setPrefs({ peeks: on });
|
|
154
|
+
|
|
155
|
+
if (on && !PEEK_WIDE.matches && peekTiles().length === 0) {
|
|
156
|
+
notify(
|
|
157
|
+
"info",
|
|
158
|
+
"Peek tiles on: sessions show here while they work, wait for you, or finish.",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The rail's switch, where the top bar's is out of room (a narrow pane). */
|
|
164
|
+
export function PeeksRailButton() {
|
|
165
|
+
if (store.state.server?.peeks !== true) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const on = peeksOn();
|
|
170
|
+
|
|
171
|
+
return html`<button
|
|
172
|
+
class=${`rail-button rail-peeks ${on ? "open" : ""}`}
|
|
173
|
+
data-part="rail-button"
|
|
174
|
+
data-state=${railState(on)}
|
|
175
|
+
title=${on ? "Hide peek tiles (Alt+P)" : "Peek at other sessions (Alt+P)"}
|
|
176
|
+
aria-label="Peek tiles"
|
|
177
|
+
aria-pressed=${on}
|
|
178
|
+
onClick=${togglePeeks}
|
|
179
|
+
>
|
|
180
|
+
<${Icon} name="tiles" size=${19} />
|
|
181
|
+
</button>`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The top bar's switch. While off, a dot says another session waits for you. */
|
|
185
|
+
export function PeeksButton() {
|
|
186
|
+
const { server, sessions, conversationId } = store.state;
|
|
187
|
+
|
|
188
|
+
if (server?.peeks !== true) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const on = peeksOn();
|
|
193
|
+
const waiting =
|
|
194
|
+
!on &&
|
|
195
|
+
sessions.some(
|
|
196
|
+
(session) => session.waiting && !session.archived && session.id !== conversationId,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return html`<button
|
|
200
|
+
class=${`icon-button badge-host top-toggle ${on ? "on" : ""}`}
|
|
201
|
+
aria-label="Peek tiles"
|
|
202
|
+
aria-pressed=${on}
|
|
203
|
+
title=${on ? "Hide peek tiles (Alt+P)" : "Peek at other sessions (Alt+P)"}
|
|
204
|
+
onClick=${togglePeeks}
|
|
205
|
+
>
|
|
206
|
+
<${Icon} name="tiles" size=${18} />
|
|
207
|
+
${waiting && html`<span class="peek-dot" aria-hidden="true"></span>`}
|
|
208
|
+
</button>`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── What is on screen, and telling the server ───────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/** Each tile element the observers watch, and whether it is on screen. */
|
|
214
|
+
const onScreen = new Map();
|
|
215
|
+
let settleTimer = 0;
|
|
216
|
+
/** The connection and list last sent, so the same list is not sent twice. */
|
|
217
|
+
let sentKey = null;
|
|
218
|
+
let lastStream;
|
|
219
|
+
/** Numbers each list, so the server drops one that arrives after a newer one. */
|
|
220
|
+
let seq = 0;
|
|
221
|
+
let failures = 0;
|
|
222
|
+
|
|
223
|
+
function screenIds() {
|
|
224
|
+
// A hidden tab looks at nothing: its tiles stop until it shows again.
|
|
225
|
+
if (hidden()) {
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The tiles first, so a tile's subagents cannot take the room a lower tile needs; the rows after them.
|
|
230
|
+
const tiles = [];
|
|
231
|
+
const agents = [];
|
|
232
|
+
|
|
233
|
+
for (const [element, visible] of onScreen) {
|
|
234
|
+
if (!element.isConnected) {
|
|
235
|
+
onScreen.delete(element);
|
|
236
|
+
} else if (visible) {
|
|
237
|
+
(element.dataset.peekKind === "agent" ? agents : tiles).push(
|
|
238
|
+
Number(element.dataset.peek),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return [...new Set([...tiles, ...agents])].slice(0, MAX_LIVE);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function settle(delay = SETTLE_MS) {
|
|
247
|
+
clearTimeout(settleTimer);
|
|
248
|
+
settleTimer = setTimeout(sendScreen, delay);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function sendScreen() {
|
|
252
|
+
const { server, streamId } = store.state;
|
|
253
|
+
|
|
254
|
+
if (server?.peeks !== true || !streamId) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const ids = screenIds();
|
|
259
|
+
const key = `${streamId}:${ids.join(",")}`;
|
|
260
|
+
|
|
261
|
+
if (key === sentKey) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// A connection starts without a list: no tiles yet (or turned off) needs nothing sent.
|
|
266
|
+
if (ids.length === 0 && !sentKey?.startsWith(`${streamId}:`)) {
|
|
267
|
+
sentKey = key;
|
|
268
|
+
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
sentKey = key;
|
|
273
|
+
api("peeks", { connection: streamId, seq: ++seq, ids }).then(
|
|
274
|
+
() => {
|
|
275
|
+
failures = 0;
|
|
276
|
+
},
|
|
277
|
+
() => {
|
|
278
|
+
// Try again soon: a moment without the network must not leave the tiles stale.
|
|
279
|
+
sentKey = null;
|
|
280
|
+
settle(RETRY_MS[Math.min(failures++, RETRY_MS.length - 1)]);
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Each connection to the server starts without tiles (a session switch reconnects, as does a restart): say again
|
|
286
|
+
// which are on screen.
|
|
287
|
+
store.subscribe((state) => {
|
|
288
|
+
if (state.streamId !== lastStream) {
|
|
289
|
+
lastStream = state.streamId;
|
|
290
|
+
settle(0);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
document.addEventListener("visibilitychange", () => {
|
|
295
|
+
settle(hidden() ? 0 : SETTLE_MS);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Watch a scrolling list's tiles (its `[data-peek]` elements) for being on screen, and tell the server once they
|
|
300
|
+
* settle. The subagents bar watches its rows this way too.
|
|
301
|
+
*/
|
|
302
|
+
export function useOnScreen(list) {
|
|
303
|
+
const observer = useRef(null);
|
|
304
|
+
const watched = useRef(new Set());
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
const watcher = new IntersectionObserver(
|
|
308
|
+
(entries) => {
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (watched.current.has(entry.target)) {
|
|
311
|
+
onScreen.set(entry.target, entry.isIntersecting);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
settle();
|
|
316
|
+
},
|
|
317
|
+
{ root: list.current },
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
observer.current = watcher;
|
|
321
|
+
|
|
322
|
+
return () => {
|
|
323
|
+
watcher.disconnect();
|
|
324
|
+
|
|
325
|
+
for (const element of watched.current) {
|
|
326
|
+
onScreen.delete(element);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
watched.current.clear();
|
|
330
|
+
settle();
|
|
331
|
+
};
|
|
332
|
+
}, []);
|
|
333
|
+
|
|
334
|
+
// New tiles join the watch; tiles that went leave it.
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
let gone = false;
|
|
337
|
+
|
|
338
|
+
for (const element of list.current.querySelectorAll("[data-peek]")) {
|
|
339
|
+
if (!watched.current.has(element)) {
|
|
340
|
+
watched.current.add(element);
|
|
341
|
+
observer.current.observe(element);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const element of watched.current) {
|
|
346
|
+
if (!element.isConnected) {
|
|
347
|
+
observer.current.unobserve(element);
|
|
348
|
+
watched.current.delete(element);
|
|
349
|
+
onScreen.delete(element);
|
|
350
|
+
gone = true;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (gone) {
|
|
355
|
+
settle();
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Tiles waiting for approval that are scrolled out of the column, above and below. */
|
|
361
|
+
function useAwayWaiting(list, key) {
|
|
362
|
+
const [away, setAway] = useState({ up: [], down: [] });
|
|
363
|
+
|
|
364
|
+
const measure = () => {
|
|
365
|
+
const box = list.current;
|
|
366
|
+
|
|
367
|
+
if (!box) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const up = [];
|
|
372
|
+
const down = [];
|
|
373
|
+
|
|
374
|
+
for (const tile of box.querySelectorAll(".peek.waiting")) {
|
|
375
|
+
const id = Number(tile.dataset.peek);
|
|
376
|
+
|
|
377
|
+
if (tile.offsetTop + tile.offsetHeight <= box.scrollTop + 4) {
|
|
378
|
+
up.push(id);
|
|
379
|
+
} else if (tile.offsetTop >= box.scrollTop + box.clientHeight - 4) {
|
|
380
|
+
down.push(id);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
setAway((before) =>
|
|
385
|
+
before.up.join() === up.join() && before.down.join() === down.join()
|
|
386
|
+
? before
|
|
387
|
+
: { up, down },
|
|
388
|
+
);
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// Only when the tiles or their states change, or the column scrolls or resizes: not on every update.
|
|
392
|
+
useEffect(measure, [key]);
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
const box = list.current;
|
|
395
|
+
let frame = 0;
|
|
396
|
+
|
|
397
|
+
const later = () => {
|
|
398
|
+
cancelAnimationFrame(frame);
|
|
399
|
+
frame = requestAnimationFrame(measure);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const resized = new ResizeObserver(later);
|
|
403
|
+
|
|
404
|
+
box.addEventListener("scroll", later, { passive: true });
|
|
405
|
+
resized.observe(box);
|
|
406
|
+
|
|
407
|
+
return () => {
|
|
408
|
+
cancelAnimationFrame(frame);
|
|
409
|
+
box.removeEventListener("scroll", later);
|
|
410
|
+
resized.disconnect();
|
|
411
|
+
};
|
|
412
|
+
}, []);
|
|
413
|
+
|
|
414
|
+
return away;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ─── Tiles ──────────────────────────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
function Status({ status }) {
|
|
420
|
+
if (status === "running") {
|
|
421
|
+
return html`<span class="peek-run" title="Running">…</span>`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return status === "error" ? html`<span class="err" title="Failed">✕</span>` : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** A line of a tile: a mark column (who or what), then the words, two lines at most. */
|
|
428
|
+
function PeekLine({ line }) {
|
|
429
|
+
switch (line.kind) {
|
|
430
|
+
case "user":
|
|
431
|
+
return html`<div class="peek-line user">
|
|
432
|
+
<span class="peek-mark">›</span>
|
|
433
|
+
<span class="peek-text">${line.from && html`<b>${line.from}</b> `}${line.text}</span>
|
|
434
|
+
</div>`;
|
|
435
|
+
case "text":
|
|
436
|
+
return html`<div class="peek-line text">
|
|
437
|
+
<span class="peek-mark pi">π</span>
|
|
438
|
+
<span class="peek-text">${line.text}</span>
|
|
439
|
+
</div>`;
|
|
440
|
+
|
|
441
|
+
case "tool": {
|
|
442
|
+
const call = describeCall({ name: line.name, args: line.args });
|
|
443
|
+
|
|
444
|
+
return html`<div class="peek-line cmd">
|
|
445
|
+
<span class="peek-mark">
|
|
446
|
+
${call.iconPath ? html`<${Icon} path=${call.iconPath} size=${12} />` : call.icon}
|
|
447
|
+
</span>
|
|
448
|
+
<span class="peek-text">
|
|
449
|
+
${call.label} ${call.subject}${" "}
|
|
450
|
+
<${Status} status=${line.status} />
|
|
451
|
+
</span>
|
|
452
|
+
</div>`;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
case "shell":
|
|
456
|
+
return html`<div class="peek-line cmd">
|
|
457
|
+
<span class="peek-mark">${">_"}</span>
|
|
458
|
+
<span class="peek-text">${line.command}${" "}<${Status} status=${line.status} /></span>
|
|
459
|
+
</div>`;
|
|
460
|
+
case "note":
|
|
461
|
+
return html`<div class="peek-line faint">
|
|
462
|
+
<span class="peek-mark">⑂</span>
|
|
463
|
+
<span class="peek-text">${line.name}: ${line.text}</span>
|
|
464
|
+
</div>`;
|
|
465
|
+
case "error":
|
|
466
|
+
return html`<div class="peek-line err">
|
|
467
|
+
<span class="peek-mark">×</span>
|
|
468
|
+
<span class="peek-text">${line.text}</span>
|
|
469
|
+
</div>`;
|
|
470
|
+
default:
|
|
471
|
+
return html`<div class="peek-line faint">
|
|
472
|
+
<span class="peek-mark"></span>
|
|
473
|
+
<span class="peek-text">${line.text}</span>
|
|
474
|
+
</div>`;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Whether a call's command is short enough to show whole on its tile: only then may it be allowed from there. Shown
|
|
480
|
+
* whole, it is never clamped; the tile also measures that it fits (`PeekTile`), as wide characters take more room.
|
|
481
|
+
*/
|
|
482
|
+
export const wholeOnTile = (approval) =>
|
|
483
|
+
approval.subject.length <= ALLOW_LENGTH && !/[\r\n]/.test(approval.subject);
|
|
484
|
+
|
|
485
|
+
/** The call a session waits on, in full when it is short, and why the guard asks. */
|
|
486
|
+
function PeekAsk({ approval, compact, who }) {
|
|
487
|
+
return html`<div class="peek-ask" title=${`${approval.subject}\n\n${approval.reason}`}>
|
|
488
|
+
<span class="peek-ask-tool mono">
|
|
489
|
+
<${Icon} name="shield" size=${12} />${approval.tool}
|
|
490
|
+
${who !== undefined && html`<span class="peek-ask-who">${who} asks</span>`}
|
|
491
|
+
</span>
|
|
492
|
+
<div class=${`peek-ask-what mono ${wholeOnTile(approval) ? "whole" : ""}`}>
|
|
493
|
+
${approval.subject}
|
|
494
|
+
</div>
|
|
495
|
+
${
|
|
496
|
+
// A question (a form) is in its reason, its title alone says little: a compact tile shows it too.
|
|
497
|
+
(!compact || approval.form) &&
|
|
498
|
+
approval.reason &&
|
|
499
|
+
html`<div class="peek-ask-why">${approval.reason}</div>`
|
|
500
|
+
}
|
|
501
|
+
</div>`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Allow or deny a call from its tile, as from its card: the same rules, the same request. A command that does not show
|
|
506
|
+
* whole is not allowed from here: Open shows it on its card, in the conversation that asks (a subagent's, maybe).
|
|
507
|
+
*/
|
|
508
|
+
function PeekApproval({ approval, me, rule, whole }) {
|
|
509
|
+
const [busy, setBusy] = useState(false);
|
|
510
|
+
|
|
511
|
+
const answer = (allow) => {
|
|
512
|
+
setBusy(true);
|
|
513
|
+
attempt(() => actions.approve(approval.id, allow)).finally(() => setBusy(false));
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const ownCall = rule === "others" && me?.role !== "owner" && approval.requestedBy === me?.id;
|
|
517
|
+
|
|
518
|
+
// An extension's question (`host.ask`) is answered on its card, where its fields are: from here, open it or decline.
|
|
519
|
+
if (approval.form) {
|
|
520
|
+
return html`<span class="peek-actions">
|
|
521
|
+
<button class="button small" disabled=${busy} onClick=${() => answer(false)}>
|
|
522
|
+
Decline
|
|
523
|
+
</button>
|
|
524
|
+
<button
|
|
525
|
+
class="button small primary"
|
|
526
|
+
title="Answer it where it waits"
|
|
527
|
+
onClick=${() => navigate(approval.conversationId)}
|
|
528
|
+
>
|
|
529
|
+
Open
|
|
530
|
+
</button>
|
|
531
|
+
</span>`;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return html`<span class="peek-actions">
|
|
535
|
+
<button class="button small" disabled=${busy} onClick=${() => answer(false)}>Deny</button>
|
|
536
|
+
${
|
|
537
|
+
whole
|
|
538
|
+
? !ownCall &&
|
|
539
|
+
html`<button
|
|
540
|
+
class="button small primary"
|
|
541
|
+
disabled=${busy}
|
|
542
|
+
onClick=${() => answer(true)}
|
|
543
|
+
>
|
|
544
|
+
Allow
|
|
545
|
+
</button>`
|
|
546
|
+
: html`<button
|
|
547
|
+
class="button small primary"
|
|
548
|
+
title="See the whole command where it waits"
|
|
549
|
+
onClick=${() => navigate(approval.conversationId)}
|
|
550
|
+
>
|
|
551
|
+
Open
|
|
552
|
+
</button>`
|
|
553
|
+
}
|
|
554
|
+
</span>`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The active subagents a tile shows, each with its own peek. Rows keep their identity until one or its peek changes,
|
|
559
|
+
* so a tile renders again only when what it shows did.
|
|
560
|
+
*/
|
|
561
|
+
const agentRows = new Map();
|
|
562
|
+
|
|
563
|
+
function agentsOf(session, peek, peeks) {
|
|
564
|
+
const list = peek?.agents ?? [];
|
|
565
|
+
const from = [peek, session.cwd, ...list.map((agent) => peeks?.[agent.conversationId])];
|
|
566
|
+
const kept = agentRows.get(session.id);
|
|
567
|
+
|
|
568
|
+
if (
|
|
569
|
+
kept !== undefined &&
|
|
570
|
+
kept.from.length === from.length &&
|
|
571
|
+
from.every((value, index) => value === kept.from[index])
|
|
572
|
+
) {
|
|
573
|
+
return kept.rows;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// The session's peek carries its subagents' waiting calls too: those rows say so at once, and come first.
|
|
577
|
+
const rows = list
|
|
578
|
+
.map((agent) => {
|
|
579
|
+
const index =
|
|
580
|
+
peek?.approvals?.findIndex(
|
|
581
|
+
(request) => request.conversationId === agent.conversationId,
|
|
582
|
+
) ?? -1;
|
|
583
|
+
const approval = index === -1 ? undefined : peek.approvals[index];
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
...agent,
|
|
587
|
+
peek: peeks?.[agent.conversationId],
|
|
588
|
+
folder: folderColor(session.cwd),
|
|
589
|
+
...(approval === undefined
|
|
590
|
+
? {}
|
|
591
|
+
: {
|
|
592
|
+
waiting: true,
|
|
593
|
+
asks: index,
|
|
594
|
+
approval: { tool: approval.tool, subject: approval.subject },
|
|
595
|
+
}),
|
|
596
|
+
};
|
|
597
|
+
})
|
|
598
|
+
// In the order their calls wait, as the tile's approval shows the first; the rest as the server lists them.
|
|
599
|
+
.sort((a, b) => (a.asks ?? Number.MAX_SAFE_INTEGER) - (b.asks ?? Number.MAX_SAFE_INTEGER));
|
|
600
|
+
|
|
601
|
+
agentRows.set(session.id, { from, rows });
|
|
602
|
+
|
|
603
|
+
return rows;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* A tile's subagents at work: the first few as rows (those waiting for an approval first), each opening its subagent,
|
|
608
|
+
* and how many more, which opens the board at the first of them.
|
|
609
|
+
*/
|
|
610
|
+
function AgentRows({ agents, compact }) {
|
|
611
|
+
const shown = agents.slice(0, compact ? AGENT_ROWS_COMPACT : AGENT_ROWS);
|
|
612
|
+
const more = agents.slice(shown.length);
|
|
613
|
+
const waitingMore = more.filter((agent) => agent.waiting === true).length;
|
|
614
|
+
const waitWords = `${waitingMore} ${waitingMore === 1 ? "needs" : "need"} approval`;
|
|
615
|
+
|
|
616
|
+
return html`<div class="agent-tiles" role="group" aria-label="Subagents at work">
|
|
617
|
+
${shown.map(
|
|
618
|
+
(agent) =>
|
|
619
|
+
html`<${AgentTile}
|
|
620
|
+
key=${agent.conversationId}
|
|
621
|
+
agent=${agent}
|
|
622
|
+
folder=${agent.folder}
|
|
623
|
+
onActivate=${() => navigate(agent.conversationId)}
|
|
624
|
+
/>`,
|
|
625
|
+
)}
|
|
626
|
+
${
|
|
627
|
+
more.length > 0 &&
|
|
628
|
+
html`<button
|
|
629
|
+
class=${`agent-more ${waitingMore > 0 ? "waiting" : ""}`}
|
|
630
|
+
title="See them all on the subagents board"
|
|
631
|
+
onClick=${() =>
|
|
632
|
+
store.set({
|
|
633
|
+
board: true,
|
|
634
|
+
drawer: false,
|
|
635
|
+
page: null,
|
|
636
|
+
boardFocus: more[0].conversationId,
|
|
637
|
+
})}
|
|
638
|
+
>
|
|
639
|
+
<${Icon} name="fork" size=${13} />
|
|
640
|
+
<span class="agent-more-count">${more.length} more</span>
|
|
641
|
+
${waitingMore > 0 && html`<span class="agent-more-wait">${waitWords}</span>`}
|
|
642
|
+
<${Icon} name="chevron" size=${14} />
|
|
643
|
+
</button>`
|
|
644
|
+
}
|
|
645
|
+
</div>`;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* A tile's square, in the shape the session list gives the same state: its folder's color.
|
|
650
|
+
*/
|
|
651
|
+
const SHAPES = {
|
|
652
|
+
waiting: "waiting",
|
|
653
|
+
limited: "limited",
|
|
654
|
+
working: "working",
|
|
655
|
+
done: "fresh",
|
|
656
|
+
idle: "seen",
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* One tile. The whole app renders on every update while a session streams; a tile renders again only when what it
|
|
661
|
+
* shows changed (`tileProps` makes its props, which keep their identity until then).
|
|
662
|
+
*/
|
|
663
|
+
class PeekTile extends Component {
|
|
664
|
+
state = { clipped: false };
|
|
665
|
+
|
|
666
|
+
shouldComponentUpdate(next, state) {
|
|
667
|
+
return (
|
|
668
|
+
state.clipped !== this.state.clipped ||
|
|
669
|
+
Object.keys(next).some((key) => next[key] !== this.props[key])
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
componentDidMount() {
|
|
674
|
+
this.measure();
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
componentDidUpdate() {
|
|
678
|
+
this.measure();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Whether the command waiting on this tile is cut off anywhere, by a clamp or by the tile's edge. */
|
|
682
|
+
measure() {
|
|
683
|
+
const what = this.base?.querySelector(".peek-ask-what");
|
|
684
|
+
let clipped = false;
|
|
685
|
+
|
|
686
|
+
if (what) {
|
|
687
|
+
const box = what.getBoundingClientRect();
|
|
688
|
+
const tile = this.base.getBoundingClientRect();
|
|
689
|
+
|
|
690
|
+
clipped =
|
|
691
|
+
what.scrollHeight > what.clientHeight + 1 ||
|
|
692
|
+
what.scrollWidth > what.clientWidth + 1 ||
|
|
693
|
+
box.bottom > tile.bottom + 1 ||
|
|
694
|
+
box.right > tile.right + 1;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (clipped !== this.state.clipped) {
|
|
698
|
+
this.setState({ clipped });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
render({ session, peek, agents, status, label, number, compact, steer, home, me, rule }) {
|
|
703
|
+
const title = session.title ?? "New session";
|
|
704
|
+
const open = () => navigate(session.id);
|
|
705
|
+
// A call answered elsewhere can stay on a tile scrolled away: the session list says whether one still waits.
|
|
706
|
+
const approval = session.waiting ? peek?.approvals?.[0] : undefined;
|
|
707
|
+
// A subagent's call names the subagent that asks.
|
|
708
|
+
const asker = agents.find(
|
|
709
|
+
(agent) => agent.conversationId === approval?.conversationId,
|
|
710
|
+
)?.name;
|
|
711
|
+
const lines = peek?.lines;
|
|
712
|
+
// The last few lines, which the tile gives two lines of text each.
|
|
713
|
+
const shown = compact ? (lines ?? []).slice(-1) : lines?.slice(-3);
|
|
714
|
+
|
|
715
|
+
return html`<div
|
|
716
|
+
class=${`peek ${status}`}
|
|
717
|
+
data-part="peek-tile"
|
|
718
|
+
data-state=${status}
|
|
719
|
+
data-peek=${session.id}
|
|
720
|
+
>
|
|
721
|
+
<button class="peek-head" title=${`Open ${title}`} onClick=${open}>
|
|
722
|
+
<span class="peek-name">
|
|
723
|
+
<span
|
|
724
|
+
class=${`state-mark ${SHAPES[status]}`}
|
|
725
|
+
style=${`--folder:${folderColor(session.cwd)}`}
|
|
726
|
+
aria-hidden="true"
|
|
727
|
+
></span>
|
|
728
|
+
<span class="peek-title">${title}</span>
|
|
729
|
+
${
|
|
730
|
+
number !== undefined &&
|
|
731
|
+
number < 9 &&
|
|
732
|
+
html`<kbd class="session-num">${number + 1}</kbd>`
|
|
733
|
+
}
|
|
734
|
+
</span>
|
|
735
|
+
<span class="peek-cwd mono">${shortPath(session.cwd, home)}</span>
|
|
736
|
+
</button>
|
|
737
|
+
<div class="peek-lines" onClick=${open}>
|
|
738
|
+
${
|
|
739
|
+
shown === undefined
|
|
740
|
+
? html`<span class="peek-skeleton"><i></i><i></i><i></i></span>`
|
|
741
|
+
: shown.map(
|
|
742
|
+
(line, index) => html`<${PeekLine} key=${index} line=${line} />`,
|
|
743
|
+
)
|
|
744
|
+
}
|
|
745
|
+
</div>
|
|
746
|
+
${agents.length > 0 && html`<${AgentRows} agents=${agents} compact=${compact} />`}
|
|
747
|
+
${
|
|
748
|
+
approval &&
|
|
749
|
+
html`<div class="peek-ask-host" onClick=${open}>
|
|
750
|
+
<${PeekAsk} approval=${approval} compact=${compact} who=${asker} />
|
|
751
|
+
</div>`
|
|
752
|
+
}
|
|
753
|
+
<div class="peek-foot">
|
|
754
|
+
<span class="peek-status">${label}</span>
|
|
755
|
+
${
|
|
756
|
+
approval &&
|
|
757
|
+
steer &&
|
|
758
|
+
html`<${PeekApproval}
|
|
759
|
+
key=${approval.id}
|
|
760
|
+
approval=${approval}
|
|
761
|
+
me=${me}
|
|
762
|
+
rule=${rule}
|
|
763
|
+
whole=${wholeOnTile(approval) && !this.state.clipped}
|
|
764
|
+
/>`
|
|
765
|
+
}
|
|
766
|
+
${
|
|
767
|
+
status === "done" &&
|
|
768
|
+
html`<button
|
|
769
|
+
class="icon-button peek-seen"
|
|
770
|
+
title="Mark as seen"
|
|
771
|
+
aria-label="Mark as seen"
|
|
772
|
+
onClick=${() => markSeen(session.id)}
|
|
773
|
+
>
|
|
774
|
+
<${Icon} name="check" size=${16} />
|
|
775
|
+
</button>`
|
|
776
|
+
}
|
|
777
|
+
</div>
|
|
778
|
+
</div>`;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** "a subagent", or how many. */
|
|
783
|
+
const someAgents = (count) => (count === 1 ? "a subagent" : `${count} subagents`);
|
|
784
|
+
|
|
785
|
+
/** What each tile shows, from the state: plain values and objects that keep their identity until they change. */
|
|
786
|
+
function tileProps(tiles, compact) {
|
|
787
|
+
const state = store.state;
|
|
788
|
+
const steer = canSteer();
|
|
789
|
+
const order = new Map(workspaceOrder().map((session, index) => [session.id, index]));
|
|
790
|
+
|
|
791
|
+
return tiles.map((session) => {
|
|
792
|
+
const peek = state.peeks?.[session.id];
|
|
793
|
+
const status =
|
|
794
|
+
session.waiting || (session.subagents?.waiting ?? 0) > 0
|
|
795
|
+
? "waiting"
|
|
796
|
+
: session.limited !== undefined
|
|
797
|
+
? "limited"
|
|
798
|
+
: session.busy || (session.subagents?.working ?? 0) > 0
|
|
799
|
+
? "working"
|
|
800
|
+
: finishedUnseen(session, state)
|
|
801
|
+
? "done"
|
|
802
|
+
: "idle";
|
|
803
|
+
// Where only its subagents keep a session waiting or working, the column's label says so. A session waits
|
|
804
|
+
// while one of its subagents does too: its peek says whose calls wait.
|
|
805
|
+
const waitingAgents = session.subagents?.waiting ?? 0;
|
|
806
|
+
const workingAgents = session.subagents?.working ?? 0;
|
|
807
|
+
// The strip's tiles are too narrow for more than a word or two beside Deny and Allow.
|
|
808
|
+
const waitsItself =
|
|
809
|
+
compact ||
|
|
810
|
+
waitingAgents === 0 ||
|
|
811
|
+
peek === undefined ||
|
|
812
|
+
peek.approvals.some((request) => request.conversationId === session.id);
|
|
813
|
+
const label =
|
|
814
|
+
status === "waiting"
|
|
815
|
+
? waitsItself
|
|
816
|
+
? steer
|
|
817
|
+
? "needs you"
|
|
818
|
+
: "waiting"
|
|
819
|
+
: steer
|
|
820
|
+
? `${someAgents(waitingAgents)} ${waitingAgents === 1 ? "needs" : "need"} you`
|
|
821
|
+
: `${someAgents(waitingAgents)} waiting`
|
|
822
|
+
: status === "limited"
|
|
823
|
+
? "waiting for a limit"
|
|
824
|
+
: status === "working"
|
|
825
|
+
? session.busy || compact
|
|
826
|
+
? "working"
|
|
827
|
+
: `${someAgents(workingAgents)} working`
|
|
828
|
+
: status === "done"
|
|
829
|
+
? "done · new"
|
|
830
|
+
: session.id === lastLeft
|
|
831
|
+
? "just left"
|
|
832
|
+
: "pinned";
|
|
833
|
+
|
|
834
|
+
return {
|
|
835
|
+
key: session.id,
|
|
836
|
+
session,
|
|
837
|
+
peek,
|
|
838
|
+
agents: agentsOf(session, peek, state.peeks),
|
|
839
|
+
status,
|
|
840
|
+
label,
|
|
841
|
+
number: order.get(session.id),
|
|
842
|
+
compact,
|
|
843
|
+
steer,
|
|
844
|
+
home: state.server?.home,
|
|
845
|
+
me: state.me,
|
|
846
|
+
rule: state.server?.approvalRule,
|
|
847
|
+
};
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* The column while it shows, and a moment after, so it can slide out. Not when People or the Browser panel takes its
|
|
853
|
+
* place: that one slides in instead. While it leaves it keeps the tiles it had.
|
|
854
|
+
*/
|
|
855
|
+
export function PeekHost({ shown, tiles, replaced }) {
|
|
856
|
+
const [kept, leaving] = usePresence(shown ? tiles : null, 180);
|
|
857
|
+
|
|
858
|
+
if (shown) {
|
|
859
|
+
return html`<${PeekColumn} tiles=${tiles} />`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return kept === null || !leaving || replaced
|
|
863
|
+
? null
|
|
864
|
+
: html`<${PeekColumn} tiles=${kept} leaving=${true} />`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Wide screens: the tiles as a column beside the conversation, scrolling, with the way to calls waiting out of view. */
|
|
868
|
+
function PeekColumn({ tiles, leaving = false }) {
|
|
869
|
+
const list = useRef(null);
|
|
870
|
+
const props = tileProps(tiles, false);
|
|
871
|
+
const away = useAwayWaiting(list, props.map((tile) => `${tile.key}:${tile.status}`).join());
|
|
872
|
+
const reveal = (id) =>
|
|
873
|
+
list.current
|
|
874
|
+
?.querySelector(`[data-peek="${id}"]`)
|
|
875
|
+
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
876
|
+
const verb = canSteer() ? ["needs you", "need you"] : ["waiting", "waiting"];
|
|
877
|
+
const jump = (ids, arrow, pick) =>
|
|
878
|
+
ids.length > 0 &&
|
|
879
|
+
html`<button
|
|
880
|
+
class=${`peeks-jump ${arrow === "▲" ? "up" : "down"}`}
|
|
881
|
+
onClick=${() => reveal(pick(ids))}
|
|
882
|
+
>
|
|
883
|
+
${arrow} ${ids.length} ${verb[ids.length === 1 ? 0 : 1]}
|
|
884
|
+
</button>`;
|
|
885
|
+
|
|
886
|
+
useOnScreen(list);
|
|
887
|
+
|
|
888
|
+
return html`<aside
|
|
889
|
+
class=${`peeks window ${leaving ? "leaving" : ""}`}
|
|
890
|
+
data-part="peeks"
|
|
891
|
+
data-state=${leaving ? "closing" : undefined}
|
|
892
|
+
aria-label="Peeks"
|
|
893
|
+
inert=${leaving}
|
|
894
|
+
>
|
|
895
|
+
<div class="peeks-list" ref=${list}>
|
|
896
|
+
${props.map((each) => html`<${PeekTile} ...${each} />`)}
|
|
897
|
+
${
|
|
898
|
+
props.length === 0 &&
|
|
899
|
+
html`<div class="peeks-empty">
|
|
900
|
+
<${Icon} name="tiles" size=${28} />
|
|
901
|
+
<p>Other sessions show here while they work, wait for you, or finish.</p>
|
|
902
|
+
<p class="muted small">Pinned sessions stay here. Alt+P hides this.</p>
|
|
903
|
+
</div>`
|
|
904
|
+
}
|
|
905
|
+
</div>
|
|
906
|
+
${jump(away.up, "▲", (ids) => ids.at(-1))}
|
|
907
|
+
${jump(away.down, "▼", (ids) => ids[0])}
|
|
908
|
+
</aside>`;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** Narrow screens, or with a panel beside the conversation: the tiles as a strip under the top bar, swiped sideways. */
|
|
912
|
+
export function PeekStrip({ tiles }) {
|
|
913
|
+
const list = useRef(null);
|
|
914
|
+
|
|
915
|
+
useOnScreen(list);
|
|
916
|
+
|
|
917
|
+
return html`<div class="peek-strip" ref=${list} role="region" aria-label="Peeks">
|
|
918
|
+
${tileProps(tiles, true).map((each) => html`<${PeekTile} ...${each} />`)}
|
|
919
|
+
</div>`;
|
|
920
|
+
}
|