@oharness/server 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/files.d.ts +40 -1
- package/dist/files.js +94 -3
- package/dist/files.js.map +1 -1
- package/dist/i18n.js +190 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +404 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +906 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1714 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/files.d.ts +7 -0
- package/dist/page/client/files.js +412 -0
- package/dist/page/client/files.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +50 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +928 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +700 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1283 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/terminal.d.ts +7 -0
- package/dist/page/client/terminal.js +347 -0
- package/dist/page/client/terminal.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +866 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +3016 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +96 -13871
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +492 -23
- package/dist/server.js.map +1 -1
- package/dist/terminal.d.ts +34 -0
- package/dist/terminal.js +211 -0
- package/dist/terminal.js.map +1 -0
- package/package.json +9 -8
|
@@ -0,0 +1,1283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session state: what the server says is true, the sidebar list it paints,
|
|
3
|
+
* and the row menu.
|
|
4
|
+
*
|
|
5
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
6
|
+
*/
|
|
7
|
+
export const STATE = String.raw `
|
|
8
|
+
// --- state ----------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
let currentCwd = "";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A token count at the precision anyone reads one at.
|
|
14
|
+
*
|
|
15
|
+
* Exact below a thousand, because early in a session the difference between
|
|
16
|
+
* 300 and 900 is the whole story. Rounded after that: nobody is checking the
|
|
17
|
+
* hundreds column of a number that changes every turn, and a line under the
|
|
18
|
+
* composer that grows a digit every message is a line that moves.
|
|
19
|
+
*/
|
|
20
|
+
function formatTokens(n) {
|
|
21
|
+
if (n < 1000) return String(n);
|
|
22
|
+
if (n < 1000000) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + "k";
|
|
23
|
+
return (n / 1000000).toFixed(1) + "M";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How many tokens this session has moved, and how many of them were free.
|
|
28
|
+
*
|
|
29
|
+
* The cache figure is a share of what went *in* — reads over reads plus
|
|
30
|
+
* writes plus fresh input — because that is the number someone can act on: a
|
|
31
|
+
* low one means the prompt is being rebuilt every turn and the bill says so.
|
|
32
|
+
* Output is not in that denominator; nothing generated can be cached.
|
|
33
|
+
*
|
|
34
|
+
* Both stay empty until there is something to say. A session that has not
|
|
35
|
+
* sent a message yet showing "0 tokens · 0% cached" reads as a failure.
|
|
36
|
+
*/
|
|
37
|
+
function paintTokens(usage) {
|
|
38
|
+
const u = usage || {};
|
|
39
|
+
const sent = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
|
|
40
|
+
const all = sent + (u.output || 0);
|
|
41
|
+
tokensEl.textContent = all ? t("usage.tokens", { n: formatTokens(all) }) : "";
|
|
42
|
+
cacheEl.textContent = u.cacheRead
|
|
43
|
+
? t("usage.cached", { pct: Math.round((u.cacheRead / sent) * 100) })
|
|
44
|
+
: "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyState(s) {
|
|
48
|
+
if (!s) return;
|
|
49
|
+
if (s.model) modelPicker.set(modelPicker.options || [], s.model);
|
|
50
|
+
if (s.mode) modePicker.set(MODES, s.mode);
|
|
51
|
+
effortPicker.set(s);
|
|
52
|
+
if (s.cwd) {
|
|
53
|
+
// Kept so the approval dialog can show a path the way the user thinks of
|
|
54
|
+
// it — src/web/page.ts, not the whole absolute path they already know.
|
|
55
|
+
currentCwd = s.cwd.replace(/\/+$/, "");
|
|
56
|
+
// The transcript is drawn before the state arrives, so a chip painted then
|
|
57
|
+
// still names the tree of the tab this one replaced.
|
|
58
|
+
refreshWorkspace();
|
|
59
|
+
}
|
|
60
|
+
metaEl.textContent = s.costUsd > 0 ? "$" + s.costUsd.toFixed(4) : "";
|
|
61
|
+
paintTokens(s.usage);
|
|
62
|
+
const meta = document.getElementById("meta");
|
|
63
|
+
// A meter reading 0% is noise: it says nothing and takes the eye.
|
|
64
|
+
// Shown from half a window on, not from half a percent. Below that there is
|
|
65
|
+
// nothing to act on, and a bar sitting at 2% all day is a permanent piece of
|
|
66
|
+
// furniture that says the same thing every time you look at it.
|
|
67
|
+
meta.classList.toggle("has-ctx", typeof s.contextUsed === "number" && s.contextUsed >= 0.5);
|
|
68
|
+
if (typeof s.contextUsed === "number") {
|
|
69
|
+
const pct = Math.round(s.contextUsed * 100);
|
|
70
|
+
ctxBar.style.width = pct + "%";
|
|
71
|
+
ctxBar.style.background = pct >= 90 ? "var(--err)" : pct >= 70 ? "var(--warn)" : "var(--ok)";
|
|
72
|
+
ctxText.textContent = pct + "%";
|
|
73
|
+
}
|
|
74
|
+
setBusy(Boolean(s.busy));
|
|
75
|
+
// Addressed by session: a reload and a tab switch both land here, and the
|
|
76
|
+
// pane this state describes is not always the one an event last touched.
|
|
77
|
+
if (s.sessionId) pane(s.sessionId).queued = s.queued || [];
|
|
78
|
+
renderQueued();
|
|
79
|
+
// The state knows the session's name; a tab opened before it had one would
|
|
80
|
+
// otherwise keep the placeholder until the page reloaded.
|
|
81
|
+
if (s.sessionId && s.title && pane(s.sessionId).title !== s.title) {
|
|
82
|
+
pane(s.sessionId).title = s.title;
|
|
83
|
+
paintTabs();
|
|
84
|
+
}
|
|
85
|
+
// The side panel keeps the conversation it shows in step with the person's
|
|
86
|
+
// tab groups, so it needs to know which one that is.
|
|
87
|
+
if (s.sessionId && s.sessionId === activeId) embedTell({ type: "session", id: s.sessionId, cwd: currentCwd });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let workingNode = null;
|
|
91
|
+
let workingSince = 0;
|
|
92
|
+
let workingTimer = null;
|
|
93
|
+
let workingLabel = "Thinking";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The gap between sending and the first token.
|
|
97
|
+
*
|
|
98
|
+
* A model can spend twenty seconds reasoning before it emits anything, and
|
|
99
|
+
* until now the page showed nothing at all in that window — indistinguishable
|
|
100
|
+
* from a request that never left. The row appears immediately and reports what
|
|
101
|
+
* the agent is doing and for how long.
|
|
102
|
+
*/
|
|
103
|
+
function startWorking(label) {
|
|
104
|
+
workingLabel = label || t("work.thinking");
|
|
105
|
+
if (workingNode) return void paintWorking();
|
|
106
|
+
workingSince = Date.now();
|
|
107
|
+
|
|
108
|
+
workingNode = el("div", "working");
|
|
109
|
+
workingNode.appendChild(el("span", "spinner"));
|
|
110
|
+
workingNode.appendChild(el("span", "working-label"));
|
|
111
|
+
workingNode.appendChild(el("span", "working-time"));
|
|
112
|
+
assistantTurn().appendChild(workingNode);
|
|
113
|
+
|
|
114
|
+
workingTimer = setInterval(paintWorking, 1000);
|
|
115
|
+
paintWorking();
|
|
116
|
+
follow();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function paintWorking() {
|
|
120
|
+
if (!workingNode) return;
|
|
121
|
+
const seconds = Math.round((Date.now() - workingSince) / 1000);
|
|
122
|
+
workingNode.querySelector(".working-label").textContent = workingLabel + "…";
|
|
123
|
+
// Elapsed time, because "is it stuck?" is the question this row answers.
|
|
124
|
+
workingNode.querySelector(".working-time").textContent = seconds >= 2 ? seconds + "s" : "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function stopWorking() {
|
|
128
|
+
if (workingTimer) clearInterval(workingTimer);
|
|
129
|
+
workingTimer = null;
|
|
130
|
+
if (workingNode && workingNode.parentNode) workingNode.remove();
|
|
131
|
+
workingNode = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setBusy(busy) {
|
|
135
|
+
if (P) P.busy = busy;
|
|
136
|
+
paintTabs();
|
|
137
|
+
// While a turn runs, the send arrow becomes a stop square in the same spot.
|
|
138
|
+
sendBtn.disabled = busy;
|
|
139
|
+
sendBtn.hidden = busy;
|
|
140
|
+
stopBtn.hidden = !busy;
|
|
141
|
+
if (!busy) stopWorking();
|
|
142
|
+
// The row itself does not change, but whether the queue can be released by
|
|
143
|
+
// hand does, and that is drawn on the send button.
|
|
144
|
+
renderQueued();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** "just now", "12m", "3h", "yesterday", "6 Aug" — scannable at a glance. */
|
|
148
|
+
function ago(ms) {
|
|
149
|
+
const seconds = Math.max(0, (Date.now() - ms) / 1000);
|
|
150
|
+
if (seconds < 60) return t("time.now");
|
|
151
|
+
if (seconds < 3600) return Math.floor(seconds / 60) + "m";
|
|
152
|
+
if (seconds < 86400) return Math.floor(seconds / 3600) + "h";
|
|
153
|
+
if (seconds < 172800) return t("time.yesterday");
|
|
154
|
+
return new Date(ms).toLocaleDateString(undefined, { day: "numeric", month: "short" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Which projects the user opened or closed, remembered across reloads.
|
|
159
|
+
*
|
|
160
|
+
* Only what was actually clicked is stored, so a project nobody touched keeps
|
|
161
|
+
* following the default — the one being worked in is open, the rest are shut.
|
|
162
|
+
* A sidebar that reopens with every tree expanded makes closing one pointless,
|
|
163
|
+
* and one that reopens fully collapsed hides the sessions it exists to show.
|
|
164
|
+
*/
|
|
165
|
+
const projectOpen = readProjectOpen();
|
|
166
|
+
|
|
167
|
+
function readProjectOpen() {
|
|
168
|
+
try {
|
|
169
|
+
const raw = JSON.parse(localStorage.getItem("oharness.projects") || "{}");
|
|
170
|
+
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
171
|
+
} catch {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toggleProject(cwd, open) {
|
|
177
|
+
projectOpen[cwd] = !open;
|
|
178
|
+
try {
|
|
179
|
+
localStorage.setItem("oharness.projects", JSON.stringify(projectOpen));
|
|
180
|
+
} catch {
|
|
181
|
+
// Private browsing, a full quota: opening still works for this page load.
|
|
182
|
+
}
|
|
183
|
+
paintSessions();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The key the routine group's open state is kept under, beside the projects'. */
|
|
187
|
+
const ROUTINE_GROUP = "\u0000routines";
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The header over routine runs. A fold like a project's, with the clock the
|
|
191
|
+
* Routines page uses, and none of a project's actions: there is no new session
|
|
192
|
+
* to start in it, and deleting runs is done row by row.
|
|
193
|
+
*/
|
|
194
|
+
function routineHead(count, open) {
|
|
195
|
+
const head = el("button", "fold" + (open ? " open" : ""));
|
|
196
|
+
head.type = "button";
|
|
197
|
+
head.appendChild(navIcon("nav-routines"));
|
|
198
|
+
head.appendChild(el("span", "fold-name", t("session.routines")));
|
|
199
|
+
head.appendChild(el("span", "count", String(count)));
|
|
200
|
+
head.addEventListener("click", () => toggleProject(ROUTINE_GROUP, open));
|
|
201
|
+
return head;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A project header: a folder, a name, and how many sessions it holds. */
|
|
205
|
+
function projectHead(cwd, name, count, open) {
|
|
206
|
+
const head = el("button", "fold" + (open ? " open" : ""));
|
|
207
|
+
head.type = "button";
|
|
208
|
+
// The folder itself says which state it is in — it is the only thing on the
|
|
209
|
+
// row that can, now that there is no arrow beside it.
|
|
210
|
+
head.appendChild(svgIcon(open ? "folderOpen" : "folder"));
|
|
211
|
+
head.appendChild(el("span", "fold-name", name));
|
|
212
|
+
if ((projects.find((project) => project.path === cwd) || {}).pinned) {
|
|
213
|
+
head.appendChild(svgIcon("pin", "pin-mark"));
|
|
214
|
+
}
|
|
215
|
+
head.addEventListener("click", () => toggleProject(cwd, open));
|
|
216
|
+
|
|
217
|
+
// A fresh session in this project without switching to it first. Where the
|
|
218
|
+
// count used to sit: the count said how much was there, and this is the one
|
|
219
|
+
// thing people came to the header to do about it.
|
|
220
|
+
const add = el("button", "more fold-new");
|
|
221
|
+
add.type = "button";
|
|
222
|
+
add.title = t("nav.new");
|
|
223
|
+
add.setAttribute("aria-label", t("nav.new"));
|
|
224
|
+
add.appendChild(navIcon("new"));
|
|
225
|
+
add.addEventListener("click", (e) => {
|
|
226
|
+
e.stopPropagation();
|
|
227
|
+
void switchTo({ action: "new", cwd });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/*
|
|
231
|
+
* Deleting a project is deleting its transcripts, and it lives here rather
|
|
232
|
+
* than beside "remove from list" in the picker because this header is the
|
|
233
|
+
* only place the two can be told apart. The sidebar groups by the tree a
|
|
234
|
+
* session was recorded in, not by the list of projects — so forgetting a
|
|
235
|
+
* path leaves this header exactly where it was, and somebody who reached
|
|
236
|
+
* for "delete" and watched nothing happen would be right to think the app
|
|
237
|
+
* was broken. What removes a project from here is removing its sessions.
|
|
238
|
+
*/
|
|
239
|
+
const more = el("button", "more");
|
|
240
|
+
more.type = "button";
|
|
241
|
+
more.title = t("session.more");
|
|
242
|
+
more.setAttribute("aria-label", t("projects.delete", { n: count }));
|
|
243
|
+
more.appendChild(svgIcon("more"));
|
|
244
|
+
more.addEventListener("click", (e) => {
|
|
245
|
+
e.stopPropagation();
|
|
246
|
+
if (menuRow === head) return closeRowMenu();
|
|
247
|
+
openMenu(more);
|
|
248
|
+
});
|
|
249
|
+
// The same menu under the pointer: a right-click on a project is asking for
|
|
250
|
+
// it, and the browser's own (Back, Reload, View Source) is about the page.
|
|
251
|
+
head.addEventListener("contextmenu", (e) => {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
openMenu(pointAnchor(e));
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
function openMenu(anchor) {
|
|
258
|
+
// Armed once before it fires, like the session row it borrows from: this
|
|
259
|
+
// is a modal's worth of consequence reached from a list row, and the
|
|
260
|
+
// consequence is spelled out where a dialog would put it.
|
|
261
|
+
const del = menuItem(t("projects.delete", { n: count }), { key: "d", danger: true, run: () => {
|
|
262
|
+
if (!del.classList.contains("armed")) {
|
|
263
|
+
del.classList.add("armed");
|
|
264
|
+
del.querySelector(".menu-label").textContent = t("projects.deleteConfirm");
|
|
265
|
+
rowMenu.replaceChildren(el("div", "menu-note", t("projects.deleteNote")), del);
|
|
266
|
+
placeRowMenu(anchor);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
closeRowMenu();
|
|
270
|
+
void deleteProject(cwd, name);
|
|
271
|
+
} });
|
|
272
|
+
|
|
273
|
+
const pinned = (projects.find((project) => project.path === cwd) || {}).pinned === true;
|
|
274
|
+
const items = [
|
|
275
|
+
menuItem(t("projects.open"), { key: "o", run: () => {
|
|
276
|
+
closeRowMenu();
|
|
277
|
+
void chooseProject(cwd);
|
|
278
|
+
} }),
|
|
279
|
+
menuItem(t(pinned ? "projects.unpin" : "projects.pin"), { key: "p", run: () => {
|
|
280
|
+
closeRowMenu();
|
|
281
|
+
void togglePin("project", cwd, !pinned);
|
|
282
|
+
} }),
|
|
283
|
+
menuItem(t("projects.files"), { key: "i", run: () => {
|
|
284
|
+
closeRowMenu();
|
|
285
|
+
void openExplorer(cwd, true);
|
|
286
|
+
} }),
|
|
287
|
+
menuItem(t("projects.terminal"), { key: "t", run: () => {
|
|
288
|
+
closeRowMenu();
|
|
289
|
+
void openTerminalIn(cwd, true);
|
|
290
|
+
} }),
|
|
291
|
+
menuItem(t(IS_MAC ? "projects.revealMac" : IS_WIN ? "projects.revealWin" : "projects.reveal"), { key: "f", run: () => {
|
|
292
|
+
closeRowMenu();
|
|
293
|
+
void api("/reveal", { path: cwd }).then((body) => {
|
|
294
|
+
if (body && body.error) addNotice(body.error, "err");
|
|
295
|
+
});
|
|
296
|
+
} }),
|
|
297
|
+
menuItem(t("projects.export"), { key: "e", run: () => {
|
|
298
|
+
closeRowMenu();
|
|
299
|
+
exportProjectUi(cwd);
|
|
300
|
+
} }),
|
|
301
|
+
menuItem(t("projects.rename"), { key: "n", run: () => {
|
|
302
|
+
closeRowMenu();
|
|
303
|
+
void renameProjectUi(cwd, name);
|
|
304
|
+
} }),
|
|
305
|
+
];
|
|
306
|
+
// Forgetting a path is only offered where it can do something: this row
|
|
307
|
+
// stays on screen either way, because the sidebar groups by the tree a
|
|
308
|
+
// transcript was recorded in — but the switcher's list is the one place
|
|
309
|
+
// this project would stop appearing, and reaching that from here saves
|
|
310
|
+
// opening the switcher to find it.
|
|
311
|
+
if (projects.some((project) => project.path === cwd && !project.current)) {
|
|
312
|
+
items.push(menuItem(t("projects.forget"), { key: "r", run: () => {
|
|
313
|
+
closeRowMenu();
|
|
314
|
+
void api("/project", { path: cwd, remove: true }).then((body) => {
|
|
315
|
+
if (body && body.error) return void addNotice(body.error, "err");
|
|
316
|
+
addNotice(t("projects.forgot", { name }));
|
|
317
|
+
return loadProjects();
|
|
318
|
+
});
|
|
319
|
+
} }));
|
|
320
|
+
}
|
|
321
|
+
items.push(menuSep(), del);
|
|
322
|
+
openRowMenu(anchor, head, items);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// The header is a button, so the menu control cannot live inside it.
|
|
326
|
+
const wrap = el("div", "fold-row");
|
|
327
|
+
wrap.appendChild(head);
|
|
328
|
+
wrap.appendChild(add);
|
|
329
|
+
wrap.appendChild(more);
|
|
330
|
+
|
|
331
|
+
draggable(wrap, {
|
|
332
|
+
kind: "project",
|
|
333
|
+
key: cwd,
|
|
334
|
+
group: (projects.find((project) => project.path === cwd) || {}).pinned ? "pinned" : "",
|
|
335
|
+
siblings: () => [...sessionList.querySelectorAll(".fold-row")].map((row) => row.dataset.cwd),
|
|
336
|
+
commit: (paths) => {
|
|
337
|
+
projectOrder = paths;
|
|
338
|
+
paintSessions();
|
|
339
|
+
void api("/order", { kind: "project", ids: paths }).then((body) => {
|
|
340
|
+
if (body && body.error) addNotice(body.error, "err");
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
wrap.dataset.cwd = cwd;
|
|
345
|
+
return wrap;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Pin or unpin, and repaint from what the server confirms.
|
|
350
|
+
*
|
|
351
|
+
* The row is not moved optimistically: a pin changes where something sits in
|
|
352
|
+
* a list, and a row that jumped and then jumped back because the write failed
|
|
353
|
+
* is worse than one that waits for the answer it is about to reflect.
|
|
354
|
+
*/
|
|
355
|
+
async function togglePin(kind, id, pinned) {
|
|
356
|
+
const body = await api("/pin", { kind, id, pinned });
|
|
357
|
+
if (body && body.error) return void addNotice(body.error, "err");
|
|
358
|
+
if (kind === "project") await loadProjects();
|
|
359
|
+
else await loadSessions();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Delete everything recorded about one tree.
|
|
364
|
+
*
|
|
365
|
+
* The tabs go with it for the same reason a deleted session's does: a pane
|
|
366
|
+
* pointing at a transcript that is gone accepts a prompt and fails on the
|
|
367
|
+
* first write. The server decides where the window lands afterwards and says
|
|
368
|
+
* so; the page follows rather than guessing, because only the server knows
|
|
369
|
+
* whether anywhere else was left to go.
|
|
370
|
+
*/
|
|
371
|
+
async function deleteProject(cwd, name) {
|
|
372
|
+
const body = await api("/project", { path: cwd, purge: true });
|
|
373
|
+
// Said, but not returned on: deletion happens before the window is moved, so
|
|
374
|
+
// a failure here can still have thrown transcripts away. Leaving the stale
|
|
375
|
+
// rows on screen would be the one outcome worse than the error itself.
|
|
376
|
+
if (body.error) {
|
|
377
|
+
addNotice(body.error, "err");
|
|
378
|
+
sessionRows = sessionRows.filter((s) => s.cwd !== cwd);
|
|
379
|
+
paintSessions();
|
|
380
|
+
await loadSessions();
|
|
381
|
+
return void (await loadProjects());
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const open = new Set(body.open || []);
|
|
385
|
+
for (const [id, pane] of [...panes]) {
|
|
386
|
+
if (open.has(id)) continue;
|
|
387
|
+
pane.node.remove();
|
|
388
|
+
panes.delete(id);
|
|
389
|
+
}
|
|
390
|
+
paintTabs();
|
|
391
|
+
|
|
392
|
+
if (body.state) await adoptSession(body.state);
|
|
393
|
+
addNotice(t("projects.deleted", { name, n: body.deleted }));
|
|
394
|
+
|
|
395
|
+
sessionRows = sessionRows.filter((s) => s.cwd !== cwd);
|
|
396
|
+
paintSessions();
|
|
397
|
+
await loadSessions();
|
|
398
|
+
await loadProjects();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let sessionRows = [];
|
|
402
|
+
|
|
403
|
+
async function loadSessions() {
|
|
404
|
+
const body = await getJson("/sessions");
|
|
405
|
+
sessionRows = body.sessions || [];
|
|
406
|
+
sessionOrder = body.order || {};
|
|
407
|
+
paintSessions();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* How many sessions a project shows before it says there are more.
|
|
412
|
+
*
|
|
413
|
+
* Enough to recognise the work in progress, few enough that the project under
|
|
414
|
+
* it is still on screen — the sidebar is how every *other* project is reached,
|
|
415
|
+
* and one busy tree can hold dozens of transcripts.
|
|
416
|
+
*/
|
|
417
|
+
const PREVIEW = 5;
|
|
418
|
+
|
|
419
|
+
/** Projects the user asked to see in full, for this page load. */
|
|
420
|
+
const showingAll = new Set();
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Order somebody arranged by hand, and what they kept at the top.
|
|
424
|
+
*
|
|
425
|
+
* Read from the server with the lists themselves rather than kept only here:
|
|
426
|
+
* a sidebar that remembered an arrangement until the next reload would be
|
|
427
|
+
* offering a control that does not work, and the two windows a desktop app
|
|
428
|
+
* can have open would each hold their own idea of the order.
|
|
429
|
+
*/
|
|
430
|
+
let sessionOrder = {};
|
|
431
|
+
let projectOrder = [];
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Sort one list by pin, then by hand, then by whatever it came in as.
|
|
435
|
+
*
|
|
436
|
+
* The hand-made order is partial on purpose: it holds what was dragged, and a
|
|
437
|
+
* row nobody has touched keeps arriving where it belongs — which for sessions
|
|
438
|
+
* is the top, because the newest one is almost always the one being looked
|
|
439
|
+
* for. Ordering by an index taken once would send tomorrow's session to the
|
|
440
|
+
* bottom of a list arranged today.
|
|
441
|
+
*/
|
|
442
|
+
function arrange(rows, keyOf, order, pinnedOf) {
|
|
443
|
+
const rank = new Map((order || []).map((key, i) => [key, i]));
|
|
444
|
+
const placed = (row) => (rank.has(keyOf(row)) ? rank.get(keyOf(row)) : Infinity);
|
|
445
|
+
return rows
|
|
446
|
+
.map((row, i) => ({ row, i }))
|
|
447
|
+
.sort((a, b) => {
|
|
448
|
+
const pin = Number(pinnedOf(b.row)) - Number(pinnedOf(a.row));
|
|
449
|
+
if (pin) return pin;
|
|
450
|
+
const hand = placed(a.row) - placed(b.row);
|
|
451
|
+
if (hand) return hand;
|
|
452
|
+
// Both untouched, or both dragged to the same place: whatever order the
|
|
453
|
+
// list already had, which is recency for sessions.
|
|
454
|
+
return a.i - b.i;
|
|
455
|
+
})
|
|
456
|
+
.map((entry) => entry.row);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The session list: by project, and by nothing else.
|
|
461
|
+
*
|
|
462
|
+
* Sessions are stored per user directory, not per project, so this list spans
|
|
463
|
+
* every tree opened here, and flat it reads another project's transcripts as
|
|
464
|
+
* this one's history. Age is not a second cut: the rows already carry their
|
|
465
|
+
* age, they arrive newest first, and date headings split one project's work
|
|
466
|
+
* across four of them to say what "2h" says on the row itself.
|
|
467
|
+
*/
|
|
468
|
+
function paintSessions() {
|
|
469
|
+
// The rows are rebuilt, so the row an open menu points at is about to be
|
|
470
|
+
// replaced by a different node in a different place.
|
|
471
|
+
closeRowMenu();
|
|
472
|
+
sessionList.replaceChildren();
|
|
473
|
+
|
|
474
|
+
const active = sessionRows.find((s) => s.current);
|
|
475
|
+
const here = active ? active.cwd : (projects.find((p) => p.current) || {}).path;
|
|
476
|
+
|
|
477
|
+
// A routine's runs are kept together rather than mixed into the project's
|
|
478
|
+
// own work: they arrive on their own, often overnight, and the question
|
|
479
|
+
// someone opens them with is "what did my routines do", not "what was I
|
|
480
|
+
// doing in this project". Claude Code's desktop app draws the same line.
|
|
481
|
+
const scheduled = sessionRows.filter((s) => s.routine);
|
|
482
|
+
const byProject = new Map();
|
|
483
|
+
for (const s of sessionRows) {
|
|
484
|
+
if (s.routine) continue;
|
|
485
|
+
const key = s.cwd || "";
|
|
486
|
+
if (!byProject.has(key)) byProject.set(key, []);
|
|
487
|
+
byProject.get(key).push(s);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// The project being worked in comes first whether or not it holds the most
|
|
491
|
+
// recent session; the rest follow by how recently they were touched — and
|
|
492
|
+
// then a pin or a drag overrides both, because those were said out loud.
|
|
493
|
+
const pinnedProject = (cwd) => (projects.find((p) => p.path === cwd) || {}).pinned === true;
|
|
494
|
+
const order = arrange(
|
|
495
|
+
[...byProject.keys()].sort((a, b) => {
|
|
496
|
+
if (a === here) return -1;
|
|
497
|
+
if (b === here) return 1;
|
|
498
|
+
return byProject.get(b)[0].updatedAt - byProject.get(a)[0].updatedAt;
|
|
499
|
+
}),
|
|
500
|
+
(cwd) => cwd,
|
|
501
|
+
projectOrder,
|
|
502
|
+
pinnedProject,
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// Everything here belongs to the project already named at the top of the
|
|
506
|
+
// sidebar: a header repeating it is noise, and the rows do not need
|
|
507
|
+
// indenting under a heading that is not there.
|
|
508
|
+
const named = !(order.length === 1 && order[0] === here) || scheduled.length > 0;
|
|
509
|
+
|
|
510
|
+
if (scheduled.length) {
|
|
511
|
+
const open = ROUTINE_GROUP in projectOpen ? projectOpen[ROUTINE_GROUP] === true : true;
|
|
512
|
+
sessionList.appendChild(routineHead(scheduled.length, open));
|
|
513
|
+
if (open) {
|
|
514
|
+
const all = showingAll.has(ROUTINE_GROUP) || scheduled.length <= PREVIEW + 1;
|
|
515
|
+
for (const s of all ? scheduled : scheduled.slice(0, PREVIEW)) {
|
|
516
|
+
sessionList.appendChild(sessionRow(s, true));
|
|
517
|
+
}
|
|
518
|
+
if (!all) sessionList.appendChild(showMore(ROUTINE_GROUP, true));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
for (const cwd of order) {
|
|
523
|
+
const list = arrange(byProject.get(cwd), (s) => s.id, sessionOrder[cwd], (s) => s.pinned);
|
|
524
|
+
let open = true;
|
|
525
|
+
if (named) {
|
|
526
|
+
open = cwd in projectOpen ? projectOpen[cwd] === true : cwd === here;
|
|
527
|
+
sessionList.appendChild(projectHead(cwd, projectName(cwd), list.length, open));
|
|
528
|
+
}
|
|
529
|
+
if (!open) continue;
|
|
530
|
+
|
|
531
|
+
// A "Show more" that reveals one row costs a click to say what it already
|
|
532
|
+
// took a row to say, so the last one comes along rather than hiding.
|
|
533
|
+
// Pinned rows are never in the hidden tail: a pin that can be scrolled
|
|
534
|
+
// out of existence is not a pin.
|
|
535
|
+
const all = showingAll.has(cwd) || list.length <= PREVIEW + 1;
|
|
536
|
+
const shown = all
|
|
537
|
+
? list
|
|
538
|
+
: list.filter((s, i) => i < PREVIEW || s.pinned);
|
|
539
|
+
for (const s of shown) sessionList.appendChild(sessionRow(s, named));
|
|
540
|
+
if (!all) sessionList.appendChild(showMore(cwd, named));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Dragging a row to a new place in its list.
|
|
546
|
+
*
|
|
547
|
+
* One helper for both lists, because the two differ only in what a row is
|
|
548
|
+
* keyed by and where the new order is sent. A row can only be dropped among
|
|
549
|
+
* its own kind — and a session only within its own project, since a
|
|
550
|
+
* transcript belongs to the tree it was recorded in and moving it to another
|
|
551
|
+
* project's list would be claiming something untrue about where it ran.
|
|
552
|
+
*
|
|
553
|
+
* Pinned and unpinned are separate groups for the same reason: pinned rows
|
|
554
|
+
* sort first no matter what order they were given, so a drop that crossed the
|
|
555
|
+
* line would be recorded and then ignored. Nothing lights up there now, which
|
|
556
|
+
* is the truth — to move a row past the pinned ones, unpin them or pin it.
|
|
557
|
+
*
|
|
558
|
+
* The insertion point is drawn on the row being hovered rather than as a
|
|
559
|
+
* floating line: the list is rebuilt constantly, and a separate indicator
|
|
560
|
+
* element would have to be found and removed on every repaint.
|
|
561
|
+
*/
|
|
562
|
+
let dragging = null;
|
|
563
|
+
|
|
564
|
+
function draggable(node, { kind, key, group, siblings, commit }) {
|
|
565
|
+
node.draggable = true;
|
|
566
|
+
|
|
567
|
+
node.addEventListener("dragstart", (e) => {
|
|
568
|
+
dragging = { kind, key, group };
|
|
569
|
+
node.classList.add("dragging");
|
|
570
|
+
// Firefox refuses to start a drag without data on the transfer.
|
|
571
|
+
if (e.dataTransfer) {
|
|
572
|
+
e.dataTransfer.effectAllowed = "move";
|
|
573
|
+
e.dataTransfer.setData("text/plain", key);
|
|
574
|
+
}
|
|
575
|
+
closeRowMenu();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
node.addEventListener("dragend", () => {
|
|
579
|
+
dragging = null;
|
|
580
|
+
node.classList.remove("dragging");
|
|
581
|
+
for (const other of sessionList.querySelectorAll(".drop-before,.drop-after")) {
|
|
582
|
+
other.classList.remove("drop-before", "drop-after");
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
node.addEventListener("dragover", (e) => {
|
|
587
|
+
if (!dragging || dragging.kind !== kind || dragging.group !== group) return;
|
|
588
|
+
if (dragging.key === key) return;
|
|
589
|
+
e.preventDefault();
|
|
590
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
|
591
|
+
const box = node.getBoundingClientRect();
|
|
592
|
+
const after = e.clientY > box.top + box.height / 2;
|
|
593
|
+
node.classList.toggle("drop-before", !after);
|
|
594
|
+
node.classList.toggle("drop-after", after);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
node.addEventListener("dragleave", () => {
|
|
598
|
+
node.classList.remove("drop-before", "drop-after");
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
node.addEventListener("drop", (e) => {
|
|
602
|
+
if (!dragging || dragging.kind !== kind || dragging.group !== group) return;
|
|
603
|
+
e.preventDefault();
|
|
604
|
+
const after = node.classList.contains("drop-after");
|
|
605
|
+
node.classList.remove("drop-before", "drop-after");
|
|
606
|
+
const moved = dragging.key;
|
|
607
|
+
if (moved === key) return;
|
|
608
|
+
|
|
609
|
+
// The whole list, not just the rows on screen: a hidden tail that
|
|
610
|
+
// reordered itself the moment it was revealed would be the same bug as
|
|
611
|
+
// not recording the order at all.
|
|
612
|
+
const next = siblings().filter((one) => one !== moved);
|
|
613
|
+
const at = next.indexOf(key);
|
|
614
|
+
next.splice(after ? at + 1 : at, 0, moved);
|
|
615
|
+
commit(next);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** The row that unfolds the rest of a project's sessions. */
|
|
620
|
+
function showMore(cwd, nested) {
|
|
621
|
+
const more = el("button", "show-more" + (nested ? " nested" : ""), t("session.showMore"));
|
|
622
|
+
more.type = "button";
|
|
623
|
+
more.addEventListener("click", () => {
|
|
624
|
+
showingAll.add(cwd);
|
|
625
|
+
paintSessions();
|
|
626
|
+
});
|
|
627
|
+
return more;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The overflow menu, one node reused by every row.
|
|
632
|
+
*
|
|
633
|
+
* A menu per row would put hundreds of hidden popups in the sidebar, and each
|
|
634
|
+
* repaint of the list would rebuild them all.
|
|
635
|
+
*/
|
|
636
|
+
const rowMenu = el("div", "row-menu");
|
|
637
|
+
rowMenu.hidden = true;
|
|
638
|
+
document.body.appendChild(rowMenu);
|
|
639
|
+
/** The row the menu currently belongs to, so it can be un-highlighted. */
|
|
640
|
+
let menuRow = null;
|
|
641
|
+
|
|
642
|
+
function closeRowMenu() {
|
|
643
|
+
if (menuRow) menuRow.classList.remove("menu-open");
|
|
644
|
+
menuRow = null;
|
|
645
|
+
rowMenu.hidden = true;
|
|
646
|
+
rowMenu.replaceChildren();
|
|
647
|
+
rowMenu.classList.remove("fit-anchor", "account-menu");
|
|
648
|
+
rowMenu.style.width = "";
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Anchored under its button, nudged back inside the viewport if it overflows.
|
|
653
|
+
*
|
|
654
|
+
* Called again whenever the contents change: confirming grows the menu, and a
|
|
655
|
+
* menu near the bottom of the list would otherwise grow off the screen.
|
|
656
|
+
*/
|
|
657
|
+
function placeRowMenu(button) {
|
|
658
|
+
const anchor = button.getBoundingClientRect();
|
|
659
|
+
const box = rowMenu.getBoundingClientRect();
|
|
660
|
+
const left = Math.min(anchor.left, window.innerWidth - box.width - 8);
|
|
661
|
+
const gap = rowMenu.classList.contains("account-menu") ? 8 : 4;
|
|
662
|
+
const below = anchor.bottom + gap;
|
|
663
|
+
const top = below + box.height > window.innerHeight - 8 ? anchor.top - box.height - gap : below;
|
|
664
|
+
rowMenu.style.left = Math.max(8, left) + "px";
|
|
665
|
+
rowMenu.style.top = Math.max(8, top) + "px";
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* One line of a row menu: what it does, and the letter that does it.
|
|
670
|
+
*
|
|
671
|
+
* The letter is not a second way to reach a rare action — it is how the menu
|
|
672
|
+
* stops being a mouse-only detour for the three things done most often to a
|
|
673
|
+
* session. Held on the element rather than in a table, so the key handler
|
|
674
|
+
* reads the menu that is actually open instead of a list that has to be kept
|
|
675
|
+
* in step with it.
|
|
676
|
+
*/
|
|
677
|
+
/**
|
|
678
|
+
* The three themes, side by side and pressed rather than cycled.
|
|
679
|
+
*
|
|
680
|
+
* A cycling entry made the reader click twice to reach dark and told them the
|
|
681
|
+
* current state only by reading a word. Three targets say what the options are
|
|
682
|
+
* and which one is on, in one glance and one click — and it is the control the
|
|
683
|
+
* site already uses in the same menu, so the two are recognisably the same
|
|
684
|
+
* thing rather than two designs for one preference.
|
|
685
|
+
*
|
|
686
|
+
* Not a menuItem: this row is not a command, and closing the menu on click
|
|
687
|
+
* would hide the change the click was for.
|
|
688
|
+
*/
|
|
689
|
+
function themeRow() {
|
|
690
|
+
const row = el("div", "menu-theme");
|
|
691
|
+
row.appendChild(el("span", "menu-label", t("theme.label")));
|
|
692
|
+
const group = el("div", "theme-group");
|
|
693
|
+
group.setAttribute("role", "group");
|
|
694
|
+
group.setAttribute("aria-label", t("theme.label"));
|
|
695
|
+
THEMES.forEach((option) => {
|
|
696
|
+
const button = el("button", "theme-btn");
|
|
697
|
+
button.type = "button";
|
|
698
|
+
button.title = t("theme." + option);
|
|
699
|
+
button.setAttribute("aria-label", t("theme." + option));
|
|
700
|
+
button.setAttribute("aria-pressed", String(option === theme));
|
|
701
|
+
button.appendChild(svgIcon("theme" + option[0].toUpperCase() + option.slice(1)));
|
|
702
|
+
button.addEventListener("click", (e) => {
|
|
703
|
+
e.stopPropagation();
|
|
704
|
+
theme = option;
|
|
705
|
+
applyTheme();
|
|
706
|
+
// The menu stays open and repaints its own pressed states: the point of
|
|
707
|
+
// three buttons is that the next one is one click away.
|
|
708
|
+
[...group.children].forEach((node, i) => {
|
|
709
|
+
node.setAttribute("aria-pressed", String(THEMES[i] === theme));
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
group.appendChild(button);
|
|
713
|
+
});
|
|
714
|
+
row.appendChild(group);
|
|
715
|
+
return row;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function menuItem(label, opts) {
|
|
719
|
+
const item = el("button", "menu-item" + (opts.danger ? " danger" : "") + (opts.cls ? " " + opts.cls : ""));
|
|
720
|
+
item.type = "button";
|
|
721
|
+
item.appendChild(el("span", "menu-label", label));
|
|
722
|
+
if (opts.key) {
|
|
723
|
+
item.appendChild(el("kbd", "menu-key", opts.key.toUpperCase()));
|
|
724
|
+
item.dataset.key = opts.key.toLowerCase();
|
|
725
|
+
}
|
|
726
|
+
item.addEventListener("click", opts.run);
|
|
727
|
+
return item;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** The rule between what a menu does and what it destroys. */
|
|
731
|
+
function menuSep() {
|
|
732
|
+
return el("div", "menu-sep");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Something placeRowMenu can hang a menu from when there is no button: the
|
|
737
|
+
* point a right-click landed on, as a rectangle with no size.
|
|
738
|
+
*/
|
|
739
|
+
function pointAnchor(e) {
|
|
740
|
+
const rect = { left: e.clientX, right: e.clientX, top: e.clientY, bottom: e.clientY, width: 0, height: 0 };
|
|
741
|
+
return { getBoundingClientRect: () => rect };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function openRowMenu(button, row, items) {
|
|
745
|
+
closeRowMenu();
|
|
746
|
+
rowMenu.replaceChildren(...items);
|
|
747
|
+
rowMenu.hidden = false;
|
|
748
|
+
menuRow = row;
|
|
749
|
+
row.classList.add("menu-open");
|
|
750
|
+
placeRowMenu(button);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// The menu is positioned in viewport coordinates, so anything that moves the
|
|
754
|
+
// row it points at — scrolling the list, resizing — leaves it stranded.
|
|
755
|
+
document.addEventListener("click", (e) => {
|
|
756
|
+
if (!rowMenu.hidden && !rowMenu.contains(e.target)) closeRowMenu();
|
|
757
|
+
});
|
|
758
|
+
document.addEventListener("keydown", (e) => {
|
|
759
|
+
if (e.key === "Escape") return void closeRowMenu();
|
|
760
|
+
if (rowMenu.hidden || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
761
|
+
// Not while something is being typed — a menu open behind a rename field
|
|
762
|
+
// would eat the letters going into it.
|
|
763
|
+
const on = document.activeElement;
|
|
764
|
+
if (on && (on.tagName === "INPUT" || on.tagName === "TEXTAREA" || on.isContentEditable)) return;
|
|
765
|
+
const item = rowMenu.querySelector('[data-key="' + e.key.toLowerCase() + '"]');
|
|
766
|
+
if (!item) return;
|
|
767
|
+
e.preventDefault();
|
|
768
|
+
item.click();
|
|
769
|
+
});
|
|
770
|
+
window.addEventListener("resize", closeRowMenu);
|
|
771
|
+
sessionList.addEventListener("scroll", closeRowMenu);
|
|
772
|
+
// Nowhere in the sidebar is the browser's menu the one meant: a row answers
|
|
773
|
+
// with its own above, and the space between rows answers with nothing rather
|
|
774
|
+
// than with Back, Reload and View Source. Text fields keep theirs — paste.
|
|
775
|
+
sidebarEl.addEventListener("contextmenu", (e) => {
|
|
776
|
+
if (e.target.closest("input, textarea, [contenteditable]")) return;
|
|
777
|
+
e.preventDefault();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
function sessionRow(s, nested) {
|
|
781
|
+
const item = el("div", "session" + (s.current ? " active" : "") + (nested ? " nested" : ""));
|
|
782
|
+
// The id is a timestamp and a hash: true, and useless for picking one out
|
|
783
|
+
// of a list. The first thing asked is what a person remembers.
|
|
784
|
+
item.appendChild(el("div", "title", s.title || t("session.untitled")));
|
|
785
|
+
// Outside the title rather than in it: that line ellipsises, and a mark that
|
|
786
|
+
// vanishes with the end of a long name is not a mark.
|
|
787
|
+
if (s.pinned) item.appendChild(svgIcon("pin", "pin-mark"));
|
|
788
|
+
|
|
789
|
+
draggable(item, {
|
|
790
|
+
kind: "session",
|
|
791
|
+
key: s.id,
|
|
792
|
+
group: (s.cwd || "") + (s.pinned ? " pinned" : ""),
|
|
793
|
+
// Every session in this project, in the order they are currently shown —
|
|
794
|
+
// including the ones folded away, which are still in the list this order
|
|
795
|
+
// is about.
|
|
796
|
+
siblings: () =>
|
|
797
|
+
arrange(
|
|
798
|
+
sessionRows.filter((one) => (one.cwd || "") === (s.cwd || "")),
|
|
799
|
+
(one) => one.id,
|
|
800
|
+
sessionOrder[s.cwd || ""],
|
|
801
|
+
(one) => one.pinned,
|
|
802
|
+
).map((one) => one.id),
|
|
803
|
+
commit: (ids) => {
|
|
804
|
+
sessionOrder = { ...sessionOrder, [s.cwd || ""]: ids };
|
|
805
|
+
paintSessions();
|
|
806
|
+
void api("/order", { kind: "session", cwd: s.cwd || "", ids }).then((body) => {
|
|
807
|
+
if (body && body.error) addNotice(body.error, "err");
|
|
808
|
+
});
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// When it was, how long it ran, and where it came from — what picks a
|
|
813
|
+
// session out of a list. What it cost is not: it is the same three cents on
|
|
814
|
+
// every row, it answers a question nobody asks while looking for a
|
|
815
|
+
// transcript, and it now has a page and a line under the composer of its
|
|
816
|
+
// own, which is where a number worth comparing belongs.
|
|
817
|
+
//
|
|
818
|
+
// The turn count went the same way, and for a plainer reason: it is not how
|
|
819
|
+
// anybody picks a session out of a list. It says how long a conversation ran,
|
|
820
|
+
// not what it was about or whether this is the one being looked for.
|
|
821
|
+
const bits = [ago(s.updatedAt)];
|
|
822
|
+
if (s.forkedFrom) bits.push("fork");
|
|
823
|
+
item.appendChild(el("div", "sub", bits.join(" · ")));
|
|
824
|
+
|
|
825
|
+
const more = el("button", "more");
|
|
826
|
+
more.type = "button";
|
|
827
|
+
more.title = t("session.more");
|
|
828
|
+
more.setAttribute("aria-label", t("session.more"));
|
|
829
|
+
more.appendChild(svgIcon("more"));
|
|
830
|
+
more.addEventListener("click", (e) => {
|
|
831
|
+
e.stopPropagation();
|
|
832
|
+
if (menuRow === item) return closeRowMenu();
|
|
833
|
+
openMenu(more);
|
|
834
|
+
});
|
|
835
|
+
// Right-click opens the row's own menu where the pointer is, instead of the
|
|
836
|
+
// browser's, which has nothing to say about a session.
|
|
837
|
+
item.addEventListener("contextmenu", (e) => {
|
|
838
|
+
e.preventDefault();
|
|
839
|
+
e.stopPropagation();
|
|
840
|
+
openMenu(pointAnchor(e));
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
function openMenu(anchor) {
|
|
844
|
+
// A transcript cannot be recovered, so the item asks once rather than
|
|
845
|
+
// deleting on the click that opened the menu — but a modal for something
|
|
846
|
+
// reached from a list row is heavier than what it protects against. The
|
|
847
|
+
// consequence is spelled out above the button, the way a dialog would.
|
|
848
|
+
const del = menuItem(t("session.delete"), { key: "d", danger: true, run: () => {
|
|
849
|
+
if (!del.classList.contains("armed")) {
|
|
850
|
+
del.classList.add("armed");
|
|
851
|
+
del.querySelector(".menu-label").textContent = t("session.deleteConfirm");
|
|
852
|
+
rowMenu.replaceChildren(el("div", "menu-note", t("session.deleteNote")), del);
|
|
853
|
+
placeRowMenu(anchor);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
closeRowMenu();
|
|
857
|
+
void deleteSession(s.id);
|
|
858
|
+
} });
|
|
859
|
+
|
|
860
|
+
openRowMenu(anchor, item, [
|
|
861
|
+
menuItem(t(s.pinned ? "session.unpin" : "session.pin"), { key: "p", run: () => {
|
|
862
|
+
closeRowMenu();
|
|
863
|
+
void togglePin("session", s.id, !s.pinned);
|
|
864
|
+
} }),
|
|
865
|
+
menuItem(t("session.rename"), { key: "r", run: () => {
|
|
866
|
+
closeRowMenu();
|
|
867
|
+
renameInPlace(item, s);
|
|
868
|
+
} }),
|
|
869
|
+
// From where it stands, unlike the fork on a turn, which branches at
|
|
870
|
+
// that turn. Same verb, and the difference is which point it keeps.
|
|
871
|
+
menuItem(t("session.fork"), { key: "f", run: () => {
|
|
872
|
+
closeRowMenu();
|
|
873
|
+
void openSession(s.id, s.cwd).then(() => switchTo({ action: "fork" }));
|
|
874
|
+
} }),
|
|
875
|
+
menuSep(),
|
|
876
|
+
del,
|
|
877
|
+
]);
|
|
878
|
+
}
|
|
879
|
+
item.appendChild(more);
|
|
880
|
+
|
|
881
|
+
item.addEventListener("click", () => {
|
|
882
|
+
setNav(null);
|
|
883
|
+
if (s.current) return;
|
|
884
|
+
// A session belongs to the tree it was recorded in — resuming it under
|
|
885
|
+
// another project's harness would point its tools at files the transcript
|
|
886
|
+
// never mentions — so the row hands over the tree it names and the session
|
|
887
|
+
// opens on that one.
|
|
888
|
+
void openSession(s.id, s.cwd);
|
|
889
|
+
});
|
|
890
|
+
return item;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Rename a session where it sits.
|
|
895
|
+
*
|
|
896
|
+
* In the row rather than in a dialog, because the thing being edited is one
|
|
897
|
+
* short line that is already on screen and a modal would ask somebody to
|
|
898
|
+
* retype it while covering it up. The list is rebuilt on every repaint, so the
|
|
899
|
+
* field commits or cancels before anything else can pull the row out from
|
|
900
|
+
* under it.
|
|
901
|
+
*/
|
|
902
|
+
function renameInPlace(row, s) {
|
|
903
|
+
const title = row.querySelector(".title");
|
|
904
|
+
if (!title) return;
|
|
905
|
+
const field = el("input", "title-edit");
|
|
906
|
+
field.value = s.title || "";
|
|
907
|
+
field.placeholder = t("session.untitled");
|
|
908
|
+
|
|
909
|
+
let done = false;
|
|
910
|
+
const finish = async (save) => {
|
|
911
|
+
if (done) return;
|
|
912
|
+
done = true;
|
|
913
|
+
const next = field.value.trim();
|
|
914
|
+
field.replaceWith(title);
|
|
915
|
+
if (!save || !next || next === s.title) return;
|
|
916
|
+
const body = await api("/rename", { id: s.id, title: next });
|
|
917
|
+
if (body && body.error) return void addNotice(body.error, "err");
|
|
918
|
+
s.title = next;
|
|
919
|
+
title.textContent = next;
|
|
920
|
+
// The tab carries the title too, and the sidebar is not the only place
|
|
921
|
+
// this name appears.
|
|
922
|
+
paintTabs();
|
|
923
|
+
await loadSessions();
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
field.addEventListener("keydown", (e) => {
|
|
927
|
+
e.stopPropagation();
|
|
928
|
+
if (e.key === "Enter") void finish(true);
|
|
929
|
+
if (e.key === "Escape") void finish(false);
|
|
930
|
+
});
|
|
931
|
+
// Clicking away is a commit, not a cancel: the field looks like the row it
|
|
932
|
+
// replaced, and losing what was typed to a click elsewhere reads as a bug.
|
|
933
|
+
field.addEventListener("blur", () => void finish(true));
|
|
934
|
+
field.addEventListener("click", (e) => e.stopPropagation());
|
|
935
|
+
|
|
936
|
+
title.replaceWith(field);
|
|
937
|
+
field.focus();
|
|
938
|
+
field.select();
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Delete a session for good: transcript, archive and metadata.
|
|
943
|
+
*
|
|
944
|
+
* If it is open as a tab the tab has to go with it — a pane pointing at a
|
|
945
|
+
* deleted session would accept a prompt and fail on the first write.
|
|
946
|
+
*/
|
|
947
|
+
async function deleteSession(id) {
|
|
948
|
+
const body = await api("/session", { action: "delete", id });
|
|
949
|
+
if (body.error) return void addNotice(body.error, "err");
|
|
950
|
+
|
|
951
|
+
if (panes.has(id)) {
|
|
952
|
+
panes.get(id).node.remove();
|
|
953
|
+
panes.delete(id);
|
|
954
|
+
if (activeId === id) {
|
|
955
|
+
const next = (body.open || []).find((other) => panes.has(other)) || [...panes.keys()][0];
|
|
956
|
+
if (next) await focusTab(next);
|
|
957
|
+
else await switchTo({ action: "new" });
|
|
958
|
+
}
|
|
959
|
+
paintTabs();
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// Lineage is recorded on the branches, not the parent, so deleting a parent
|
|
963
|
+
// leaves them pointing at nothing. Said once, rather than blocked.
|
|
964
|
+
if (body.forks) addNotice(t("session.deletedForks", { n: body.forks }), "notice");
|
|
965
|
+
|
|
966
|
+
sessionRows = sessionRows.filter((s) => s.id !== id);
|
|
967
|
+
paintSessions();
|
|
968
|
+
await loadSessions();
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Reopen a session, on the harness for the tree it was recorded in. */
|
|
972
|
+
async function openSession(id, cwd) {
|
|
973
|
+
await switchTo({ action: "resume", id, ...(cwd ? { cwd } : {}) });
|
|
974
|
+
await loadProjects();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// The side panel asks for a conversation when the person moves to one of its
|
|
978
|
+
// tab groups. Only the extension page this one is framed in may ask, and all
|
|
979
|
+
// it can ask for is a conversation this page could open anyway.
|
|
980
|
+
if (IS_EMBED) {
|
|
981
|
+
window.addEventListener("message", (event) => {
|
|
982
|
+
if (event.source !== window.parent || !/^chrome-extension:/.test(event.origin)) return;
|
|
983
|
+
const data = event.data || {};
|
|
984
|
+
if (data.type === "open-session" && typeof data.id === "string" && data.id !== activeId) {
|
|
985
|
+
if (panes.has(data.id)) void focusTab(data.id);
|
|
986
|
+
else void openSession(data.id, typeof data.cwd === "string" ? data.cwd : undefined);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
// "Use this tab": the person pressed it in the panel, on the tab they
|
|
990
|
+
// are looking at, for the conversation the panel shows.
|
|
991
|
+
if (data.type === "adopt-tab" && (typeof data.tabId === "number" || typeof data.tabId === "string") && activeId) {
|
|
992
|
+
void api("/browser/adopt", { session: activeId, tabId: data.tabId }).then((result) => {
|
|
993
|
+
if (result && result.error) addNotice(result.error, "err");
|
|
994
|
+
else if (result && result.url) addNotice(t("embed.adopted", { url: result.url }));
|
|
995
|
+
embedTell({ type: "adopted", ok: !(result && result.error) });
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
let modelOptions = [];
|
|
1002
|
+
let providers = [];
|
|
1003
|
+
|
|
1004
|
+
async function loadModels() {
|
|
1005
|
+
const body = await getJson("/models");
|
|
1006
|
+
if (body.error) return void addNotice(body.error, "err");
|
|
1007
|
+
const models = body.models || [];
|
|
1008
|
+
providers = body.providers || [];
|
|
1009
|
+
// Kept for the connect dialog, which needs somewhere to send a person for a
|
|
1010
|
+
// key and is opened from places that have no response of their own to read.
|
|
1011
|
+
modelsSite = body.site || modelsSite;
|
|
1012
|
+
|
|
1013
|
+
// Grouped by vendor. Three things decide the order, in this order:
|
|
1014
|
+
//
|
|
1015
|
+
// the recommended vendor, which leads even unconnected — for someone with
|
|
1016
|
+
// nothing set up it is the answer to the question the list poses, and
|
|
1017
|
+
// burying it under the vendors they happen to hold keys for hides the
|
|
1018
|
+
// one row that would have saved them the trouble;
|
|
1019
|
+
// whether the vendor has anything to list, because a local server nobody
|
|
1020
|
+
// is running counts as connected — no key is needed to reach it — and
|
|
1021
|
+
// three empty stubs above Anthropic is not a ranking anyone asked for;
|
|
1022
|
+
// whether it is connected, so an unauthenticated model is still shown —
|
|
1023
|
+
// it explains why the list is short — but not above one that will
|
|
1024
|
+
// actually answer.
|
|
1025
|
+
//
|
|
1026
|
+
// Catalog order breaks the ties, which is where the head of the list is set.
|
|
1027
|
+
const populated = new Set(models.map((m) => m.provider));
|
|
1028
|
+
const order = new Map(
|
|
1029
|
+
providers.map((p, i) => [
|
|
1030
|
+
p.id,
|
|
1031
|
+
(p.recommended ? 0 : (populated.has(p.id) ? 0 : 2000) + (p.configured ? 0 : 1000)) + i,
|
|
1032
|
+
]),
|
|
1033
|
+
);
|
|
1034
|
+
// One closure per vendor, not one per model: four hundred rows repaint on
|
|
1035
|
+
// every keystroke in the search field.
|
|
1036
|
+
const vendorLabels = new Map(providers.map((p) => [p.id, p.label || p.id]));
|
|
1037
|
+
const markers = new Map(
|
|
1038
|
+
providers.map((p) => [
|
|
1039
|
+
p.id,
|
|
1040
|
+
(extra) => providerMark(p.id, vendorLabels.get(p.id) || p.id, extra),
|
|
1041
|
+
]),
|
|
1042
|
+
);
|
|
1043
|
+
const markFor = (id) =>
|
|
1044
|
+
markers.get(id) || ((extra) => providerMark(id, id, extra));
|
|
1045
|
+
|
|
1046
|
+
// No context window here. It is a number you consult when choosing a model
|
|
1047
|
+
// once, not every time you pick one off a list you already know, and printed
|
|
1048
|
+
// down the right of four hundred rows it competes with the only thing the
|
|
1049
|
+
// row is for — the name. The panel and the provider dialog still carry it,
|
|
1050
|
+
// where a model is being chosen rather than switched to.
|
|
1051
|
+
const listed = models.map((m) => ({
|
|
1052
|
+
value: m.id,
|
|
1053
|
+
// Without the provider: the group header above the row already names it,
|
|
1054
|
+
// and "official/" repeated down every row pushes the part that differs to
|
|
1055
|
+
// the right. An aggregator's own namespace stays — "~anthropic/…" under
|
|
1056
|
+
// OpenRouter is what tells its rows apart. The whole id is still what
|
|
1057
|
+
// search matches, so typing it as /model takes it works here too.
|
|
1058
|
+
label: m.id.indexOf(m.provider + "/") === 0 ? m.id.slice(m.provider.length + 1) : m.id,
|
|
1059
|
+
keywords: m.id,
|
|
1060
|
+
group: m.provider,
|
|
1061
|
+
mark: markFor(m.provider),
|
|
1062
|
+
// The id is left whole even where it already ends in ":free". That suffix
|
|
1063
|
+
// is OpenRouter's routing variant and part of the string somebody types
|
|
1064
|
+
// into /model, and trimming it to save the repetition would render
|
|
1065
|
+
// minimax-m2.7 and minimax-m2.7:free as the same row — two models that
|
|
1066
|
+
// differ in what they cost, told apart by a badge alone. A vendor whose
|
|
1067
|
+
// free models are not named for it gets the badge and nothing else, which
|
|
1068
|
+
// is the case this exists for.
|
|
1069
|
+
...(m.free ? { tag: t("models.free") } : {}),
|
|
1070
|
+
}));
|
|
1071
|
+
|
|
1072
|
+
// A vendor with nothing to list is still an answer. Local servers and the
|
|
1073
|
+
// gateways nobody bundles model names for only have a roster once they are
|
|
1074
|
+
// asked for one, and leaving them out of the picker entirely reads as "not
|
|
1075
|
+
// supported" rather than "not connected yet" — so each gets one row that
|
|
1076
|
+
// goes where that is fixed.
|
|
1077
|
+
const stubs = providers
|
|
1078
|
+
.filter((p) => !populated.has(p.id))
|
|
1079
|
+
.map((p) => ({
|
|
1080
|
+
value: "setup:" + p.id,
|
|
1081
|
+
label: t(p.configured ? "menu.discover" : "menu.setUp"),
|
|
1082
|
+
group: p.id,
|
|
1083
|
+
mark: markFor(p.id),
|
|
1084
|
+
sub: "",
|
|
1085
|
+
}));
|
|
1086
|
+
|
|
1087
|
+
// Sorted as one list rather than models-then-stubs. Appended, a vendor with
|
|
1088
|
+
// nothing discovered yet landed under four hundred rows of the vendors that
|
|
1089
|
+
// had — which put the recommended plan, whose whole roster arrives only
|
|
1090
|
+
// after signing in, at the very bottom of the menu meant to sell it.
|
|
1091
|
+
// Within a vendor, the server's order: newest first by family, variants
|
|
1092
|
+
// after their model (model-order.ts). A string sort here put claude-opus-4-5
|
|
1093
|
+
// above claude-opus-5 and scattered :batch rows through the list.
|
|
1094
|
+
const rank = new Map(models.map((m, i) => [m.id, i]));
|
|
1095
|
+
modelOptions = listed.concat(stubs).sort(
|
|
1096
|
+
(a, b) =>
|
|
1097
|
+
(order.get(a.group) ?? 9999) - (order.get(b.group) ?? 9999) ||
|
|
1098
|
+
(rank.get(a.value) ?? 0) - (rank.get(b.value) ?? 0),
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
const current = models.find((m) => m.current);
|
|
1102
|
+
const recommended = new Set(providers.filter((p) => p.recommended).map((p) => p.id));
|
|
1103
|
+
modelPicker.groupTag = (group) => (recommended.has(group) ? t("models.recommended") : null);
|
|
1104
|
+
modelPicker.groupMark = (group) => markFor(group)();
|
|
1105
|
+
modelPicker.options = modelOptions;
|
|
1106
|
+
modelPicker.set(modelOptions, current ? current.id : null);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const connectionCards = new Map();
|
|
1110
|
+
|
|
1111
|
+
function connectionCard(card) {
|
|
1112
|
+
connectionCards.set(card.id, card);
|
|
1113
|
+
learnCloudApp(card.app, card.label, card.logo);
|
|
1114
|
+
const target = pane(card.session);
|
|
1115
|
+
let box = target.node.querySelector('[data-connection-id="' + card.id + '"]');
|
|
1116
|
+
if (!box) {
|
|
1117
|
+
box = el("section", "row-card column connection-card");
|
|
1118
|
+
box.dataset.connectionId = card.id;
|
|
1119
|
+
box.setAttribute("aria-live", "polite");
|
|
1120
|
+
target.node.appendChild(box);
|
|
1121
|
+
}
|
|
1122
|
+
box.replaceChildren();
|
|
1123
|
+
const head = el("div", "connection-head");
|
|
1124
|
+
/*
|
|
1125
|
+
* The app's own mark, when Cloud sent one and it is https. A logo is the
|
|
1126
|
+
* fastest way to tell Gmail from a page pretending to be Gmail, which is
|
|
1127
|
+
* exactly why the URL is not taken on trust: it arrives from a vendor
|
|
1128
|
+
* catalogue, and an http or javascript: src would be a hole opened for
|
|
1129
|
+
* decoration. A card without one is a card with a blank square, never a
|
|
1130
|
+
* broken image.
|
|
1131
|
+
*/
|
|
1132
|
+
if (card.logo) {
|
|
1133
|
+
let safe;
|
|
1134
|
+
try { const parsed = new URL(card.logo); if (parsed.protocol === "https:") safe = parsed.href; } catch { safe = undefined; }
|
|
1135
|
+
if (safe) {
|
|
1136
|
+
const logo = el("img", "connection-logo");
|
|
1137
|
+
logo.src = safe; logo.alt = ""; logo.loading = "lazy"; logo.referrerPolicy = "no-referrer";
|
|
1138
|
+
logo.onerror = () => logo.remove();
|
|
1139
|
+
head.appendChild(logo);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
head.appendChild(el("span", "connection-name", card.label));
|
|
1143
|
+
box.appendChild(head);
|
|
1144
|
+
/*
|
|
1145
|
+
* The key when there is one, the text when there is not. The engine writes
|
|
1146
|
+
* both because the same card also reaches surfaces with no translation
|
|
1147
|
+
* table; here, where there is one, a card that spoke a different language
|
|
1148
|
+
* from the window around it was the bug.
|
|
1149
|
+
*/
|
|
1150
|
+
box.appendChild(el("p", "", card.messageKey ? t(card.messageKey) : card.message));
|
|
1151
|
+
if (card.state !== "required" && card.state !== "authorizing") return;
|
|
1152
|
+
const actions = el("div", "connection-actions");
|
|
1153
|
+
const start = el("button", "ghost", card.state === "authorizing" ? t("connect.open") : t("connect.action", { app: card.label }));
|
|
1154
|
+
const cancel = el("button", "ghost", t("connect.cancel"));
|
|
1155
|
+
actions.appendChild(start);
|
|
1156
|
+
actions.appendChild(cancel);
|
|
1157
|
+
box.appendChild(actions);
|
|
1158
|
+
start.onclick = async () => {
|
|
1159
|
+
start.disabled = true;
|
|
1160
|
+
// Reserve a window in the click gesture; credentials never enter its URL.
|
|
1161
|
+
const popup = window.open("about:blank", "_blank");
|
|
1162
|
+
if (popup) popup.opener = null;
|
|
1163
|
+
try {
|
|
1164
|
+
const result = await api("/cloud-connection", { id: card.id, action: "start" }, 60000);
|
|
1165
|
+
if (result.error) throw new Error(result.error);
|
|
1166
|
+
if (result.redirectUrl) {
|
|
1167
|
+
const url = new URL(result.redirectUrl);
|
|
1168
|
+
if (url.protocol !== "https:" || url.username || url.password) throw new Error("无效授权链接");
|
|
1169
|
+
if (popup && !popup.closed) popup.location.replace(url.href);
|
|
1170
|
+
else {
|
|
1171
|
+
const currentBox = target.node.querySelector('[data-connection-id="' + card.id + '"]');
|
|
1172
|
+
if (currentBox && connectionCards.get(card.id)?.state === "authorizing") {
|
|
1173
|
+
const link = el("a", "", t("connect.openLink"));
|
|
1174
|
+
link.href = url.href; link.target = "_blank"; link.rel = "noopener noreferrer";
|
|
1175
|
+
currentBox.appendChild(link);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
} else if (popup) popup.close();
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
if (popup) popup.close();
|
|
1181
|
+
box.appendChild(el("p", "err", error.message || t("connect.failed")));
|
|
1182
|
+
} finally { start.disabled = false; }
|
|
1183
|
+
};
|
|
1184
|
+
cancel.onclick = async () => {
|
|
1185
|
+
cancel.disabled = true;
|
|
1186
|
+
try {
|
|
1187
|
+
const result = await api("/cloud-connection", { id: card.id, action: "cancel" });
|
|
1188
|
+
if (result.error) throw new Error(result.error);
|
|
1189
|
+
} catch { box.appendChild(el("p", "err", t("connect.cancelFailed"))); }
|
|
1190
|
+
finally { cancel.disabled = false; }
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function renderHistory(entries, into) {
|
|
1195
|
+
P = into || P;
|
|
1196
|
+
const hasHistory = (entries || []).length > 0;
|
|
1197
|
+
P.node.replaceChildren();
|
|
1198
|
+
// A resumed session with nothing in it should still show the invitation
|
|
1199
|
+
// rather than a blank rectangle — and, since nothing has been said in it
|
|
1200
|
+
// yet, the choice of where it runs.
|
|
1201
|
+
if (!hasHistory && emptyEl) P.node.appendChild(emptyEl);
|
|
1202
|
+
if (P === panes.get(activeId)) paintWorkspace(!hasHistory);
|
|
1203
|
+
P.tools.clear();
|
|
1204
|
+
P.turn = null;
|
|
1205
|
+
P.streaming = null;
|
|
1206
|
+
for (const entry of entries || []) {
|
|
1207
|
+
if (entry.role === "user") {
|
|
1208
|
+
if (entry.text || (entry.images && entry.images.length)) {
|
|
1209
|
+
addUser(entry.text || "", entry.images);
|
|
1210
|
+
}
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
P.turn = null;
|
|
1214
|
+
// Folded shut and without a duration: the seconds are a property of the
|
|
1215
|
+
// stream, not of the message, so a reload has no honest number to show.
|
|
1216
|
+
if (entry.thinking) {
|
|
1217
|
+
const block = el("details", "thinking");
|
|
1218
|
+
const summary = el("summary");
|
|
1219
|
+
summary.appendChild(el("span", "t-label", t("work.thoughtAbout")));
|
|
1220
|
+
block.appendChild(summary);
|
|
1221
|
+
block.appendChild(el("div", "thinking-body", entry.thinking));
|
|
1222
|
+
assistantTurn().appendChild(block);
|
|
1223
|
+
}
|
|
1224
|
+
if (entry.text) assistantTurn().appendChild(renderMarkdown(entry.text));
|
|
1225
|
+
for (const call of entry.tools || []) toolCard(call.id, call.name, call.name, call.input);
|
|
1226
|
+
for (const result of entry.results || []) {
|
|
1227
|
+
finishTool(result.id, [{ type: "text", text: result.text }], 0, result.isError);
|
|
1228
|
+
}
|
|
1229
|
+
// The cards, again. Live, these come from the "artifact" event, which a
|
|
1230
|
+
// replay does not produce — so a resumed conversation had every message and
|
|
1231
|
+
// no way back to the things they made. The engine rebuilds the store from
|
|
1232
|
+
// the same tool calls; this rebuilds their cards from them.
|
|
1233
|
+
for (const call of entry.tools || []) {
|
|
1234
|
+
if (call.name !== "artifact_write") continue;
|
|
1235
|
+
const failed = (entry.results || []).some((r) => r.id === call.id && r.isError);
|
|
1236
|
+
if (failed) continue;
|
|
1237
|
+
const card = replayedArtifactCard(call.input);
|
|
1238
|
+
if (card) assistantTurn().appendChild(card);
|
|
1239
|
+
}
|
|
1240
|
+
if (P.turn) turnActions(P.turn, entry.keepMessages);
|
|
1241
|
+
}
|
|
1242
|
+
for (const card of connectionCards.values()) {
|
|
1243
|
+
if (pane(card.session) === P) connectionCard(card);
|
|
1244
|
+
}
|
|
1245
|
+
// A question still waiting, drawn again under the history that just
|
|
1246
|
+
// replaced the pane. Settled ones are already in it, as their tool card.
|
|
1247
|
+
for (const entry of questionCards.values()) {
|
|
1248
|
+
if (entry.status === "open" && pane(entry.session) === P) paintQuestion(entry);
|
|
1249
|
+
}
|
|
1250
|
+
atBottom = true;
|
|
1251
|
+
log.scrollTop = log.scrollHeight;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
async function switchTo(body) {
|
|
1255
|
+
const state = await api("/session", body);
|
|
1256
|
+
if (state.error) return void addNotice(state.error, "err");
|
|
1257
|
+
|
|
1258
|
+
// A tab per session, focused on arrival.
|
|
1259
|
+
const record = pane(state.sessionId);
|
|
1260
|
+
record.title = state.title || "";
|
|
1261
|
+
record.cwd = state.cwd || "";
|
|
1262
|
+
activeId = state.sessionId;
|
|
1263
|
+
P = record;
|
|
1264
|
+
for (const [id, entry] of panes) entry.node.hidden = id !== activeId;
|
|
1265
|
+
paintTabs();
|
|
1266
|
+
|
|
1267
|
+
const { entries } = await getJson("/history?session=" + encodeURIComponent(state.sessionId));
|
|
1268
|
+
renderHistory(entries, record);
|
|
1269
|
+
applyState(state);
|
|
1270
|
+
setNav(null);
|
|
1271
|
+
await loadSessions();
|
|
1272
|
+
await loadSidePanels();
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function addNotice(text, cls) {
|
|
1276
|
+
// Connecting a provider reports back while a panel is up; that answer has to
|
|
1277
|
+
// land where the user is looking, not in the transcript hidden behind it.
|
|
1278
|
+
if (!panelView.hidden) return void panelNotes.appendChild(el("div", cls || "notice", text));
|
|
1279
|
+
assistantTurn().appendChild(el("div", cls || "notice", text));
|
|
1280
|
+
follow();
|
|
1281
|
+
}
|
|
1282
|
+
`;
|
|
1283
|
+
//# sourceMappingURL=state.js.map
|