@xingjisoft/neutrino 0.2.0 → 0.3.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/README.md +124 -78
- package/assets/prompt.txt +3 -2
- package/dist/config.js +23 -1
- package/dist/llm/client.js +37 -2
- package/dist/llm/plan.js +49 -0
- package/dist/llm/subagent.js +3 -0
- package/dist/screens/chat.js +57 -32
- package/dist/web/hub.js +9 -0
- package/dist/web/server.js +353 -5
- package/dist/web/session.js +90 -4
- package/dist/web/settings.js +27 -1
- package/package.json +2 -2
- package/web/README.md +83 -15
- package/web/app.js +1309 -124
- package/web/i18n.js +345 -0
- package/web/index.html +210 -34
- package/web/markdown.js +710 -0
- package/web/settings.js +73 -19
- package/web/style.css +1124 -85
package/web/app.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createSettingsUI } from "./settings.js";
|
|
2
|
+
import { renderMarkdown as renderMarkdownHtml } from "./markdown.js";
|
|
2
3
|
import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
|
|
4
|
+
import { applyTranslations, getLanguage as currentLanguage, locale as currentLocale, setLanguage as setI18nLanguage, t as translate } from "./i18n.js";
|
|
3
5
|
|
|
4
6
|
// Neutrino web UI — a dependency-free single page app.
|
|
5
7
|
//
|
|
@@ -21,6 +23,43 @@ const REFRESH_MS = 4000;
|
|
|
21
23
|
// Login is remembered in an HttpOnly cookie shared by fetch and EventSource.
|
|
22
24
|
// Remove credentials left behind by older versions of the UI.
|
|
23
25
|
try { localStorage.removeItem("neutrino.token"); } catch { /* Storage may be blocked. */ }
|
|
26
|
+
|
|
27
|
+
// Translation lookup. `translate` is an ES-module import; the browser tests
|
|
28
|
+
// that evaluate this file in a sandbox strip imports, so the helper degrades to
|
|
29
|
+
// the English source text instead of throwing. Interpolation is kept here too.
|
|
30
|
+
const tr = (text, values) => {
|
|
31
|
+
if (typeof translate === "function") return translate(text, values);
|
|
32
|
+
return String(text ?? "").replace(/\{(\w+)\}/g, (match, name) =>
|
|
33
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function uiLanguage() {
|
|
37
|
+
return typeof currentLanguage === "function" ? currentLanguage() : "en";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setUiLanguage(value) {
|
|
41
|
+
return typeof setI18nLanguage === "function" ? setI18nLanguage(value) : "en";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function localeTag() {
|
|
45
|
+
return typeof currentLocale === "function" ? currentLocale() : "en-US";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function applyStaticTranslations() {
|
|
49
|
+
if (typeof applyTranslations === "function") applyTranslations(document);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readStorage(key) {
|
|
53
|
+
try {
|
|
54
|
+
return localStorage.getItem(key) || "";
|
|
55
|
+
} catch {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeStorage(key, value) {
|
|
61
|
+
try { localStorage.setItem(key, value); } catch { /* Private mode may block storage. */ }
|
|
62
|
+
}
|
|
24
63
|
async function loginWithPassword(password) {
|
|
25
64
|
const response = await fetch("/api/auth/login", {
|
|
26
65
|
method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
|
|
@@ -84,12 +123,12 @@ function showGate(message = "") {
|
|
|
84
123
|
gate.innerHTML =
|
|
85
124
|
'<form class="gate-card">' +
|
|
86
125
|
'<div class="gate-brand"><span class="logo">' + LOGO_MARK + "</span><strong>Neutrino</strong></div>" +
|
|
87
|
-
'<h2 id="login-title">Sign in</h2>
|
|
88
|
-
|
|
89
|
-
'<label for="login-password">Password</label>
|
|
126
|
+
'<h2 id="login-title">' + tr("Sign in") + "</h2>" +
|
|
127
|
+
"<p>" + tr("Enter your Web password to open this console. The default password is 114514.") + "</p>" +
|
|
128
|
+
'<label for="login-password">' + tr("Password") + "</label>" +
|
|
90
129
|
'<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="256" required autofocus>' +
|
|
91
130
|
'<p class="form-feedback error" role="alert"></p>' +
|
|
92
|
-
'<button class="primary" type="submit">Sign in</button>
|
|
131
|
+
'<button class="primary" type="submit">' + tr("Sign in") + "</button>" +
|
|
93
132
|
"</form>";
|
|
94
133
|
gate.querySelector(".form-feedback").textContent = message;
|
|
95
134
|
gate.addEventListener("cancel", event => event.preventDefault());
|
|
@@ -127,11 +166,61 @@ const el = {
|
|
|
127
166
|
input: document.getElementById("input"),
|
|
128
167
|
send: document.getElementById("send"),
|
|
129
168
|
approval: document.getElementById("approval"),
|
|
169
|
+
emptyNewChat: document.getElementById("empty-new-chat"),
|
|
170
|
+
newChat: document.getElementById("new-chat"),
|
|
171
|
+
workspaceButton: document.getElementById("workspace-button"),
|
|
172
|
+
workspaceLabel: document.getElementById("workspace-label"),
|
|
173
|
+
languageSelect: document.getElementById("language-select"),
|
|
174
|
+
planCard: document.getElementById("plan-card"),
|
|
175
|
+
workspaceDialog: document.getElementById("workspace-dialog"),
|
|
176
|
+
workspaceList: document.getElementById("workspace-list"),
|
|
177
|
+
workspaceAll: document.getElementById("workspace-all"),
|
|
178
|
+
workspaceAdd: document.getElementById("workspace-add"),
|
|
179
|
+
workspaceClose: document.getElementById("workspace-close"),
|
|
180
|
+
workspaceFeedback: document.getElementById("workspace-feedback"),
|
|
181
|
+
newChatDialog: document.getElementById("new-chat-dialog"),
|
|
182
|
+
newChatForm: document.getElementById("new-chat-form"),
|
|
183
|
+
newChatTitle: document.getElementById("new-chat-title"),
|
|
184
|
+
newChatClose: document.getElementById("new-chat-close"),
|
|
185
|
+
newChatCancel: document.getElementById("new-chat-cancel"),
|
|
186
|
+
newChatStart: document.getElementById("new-chat-start"),
|
|
187
|
+
newChatCwd: document.getElementById("new-chat-cwd"),
|
|
188
|
+
newChatBrowse: document.getElementById("new-chat-browse"),
|
|
189
|
+
newChatRecents: document.getElementById("new-chat-recents"),
|
|
190
|
+
newChatProvider: document.getElementById("new-chat-provider"),
|
|
191
|
+
newChatModel: document.getElementById("new-chat-model"),
|
|
192
|
+
newChatIntensity: document.getElementById("new-chat-intensity"),
|
|
193
|
+
newChatApproval: document.getElementById("new-chat-approval"),
|
|
194
|
+
newChatNote: document.getElementById("new-chat-note"),
|
|
195
|
+
newChatFeedback: document.getElementById("new-chat-feedback"),
|
|
196
|
+
folderDialog: document.getElementById("folder-dialog"),
|
|
197
|
+
folderTitle: document.getElementById("folder-title"),
|
|
198
|
+
folderClose: document.getElementById("folder-close"),
|
|
199
|
+
folderCancel: document.getElementById("folder-cancel"),
|
|
200
|
+
folderUse: document.getElementById("folder-use"),
|
|
201
|
+
folderUp: document.getElementById("folder-up"),
|
|
202
|
+
folderPath: document.getElementById("folder-path"),
|
|
203
|
+
folderGo: document.getElementById("folder-go"),
|
|
204
|
+
folderRoots: document.getElementById("folder-roots"),
|
|
205
|
+
folderList: document.getElementById("folder-list"),
|
|
206
|
+
folderNew: document.getElementById("folder-new"),
|
|
207
|
+
folderNewName: document.getElementById("folder-new-name"),
|
|
208
|
+
folderNewCreate: document.getElementById("folder-new-create"),
|
|
209
|
+
folderNewCancel: document.getElementById("folder-new-cancel"),
|
|
210
|
+
folderMkdir: document.getElementById("folder-mkdir"),
|
|
211
|
+
folderFeedback: document.getElementById("folder-feedback"),
|
|
212
|
+
agentsOpen: document.getElementById("agents-open"),
|
|
213
|
+
agentsDialog: document.getElementById("agents-dialog"),
|
|
214
|
+
agentsClose: document.getElementById("agents-close"),
|
|
215
|
+
agentsList: document.getElementById("agents-list"),
|
|
216
|
+
agentsFeedback: document.getElementById("agents-feedback"),
|
|
217
|
+
agentsCwd: document.getElementById("agents-cwd"),
|
|
130
218
|
};
|
|
131
219
|
|
|
132
220
|
const state = {
|
|
133
221
|
overview: null,
|
|
134
|
-
// { kind: "live", key } | { kind: "file", id } |
|
|
222
|
+
// { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
|
|
223
|
+
// { kind: "agent", agent, id, title } | null
|
|
135
224
|
selection: null,
|
|
136
225
|
snapshot: null,
|
|
137
226
|
eventSource: null,
|
|
@@ -142,10 +231,32 @@ const state = {
|
|
|
142
231
|
// Turns the reader expanded; a finished turn is folded unless listed here.
|
|
143
232
|
openTurns: new Set(),
|
|
144
233
|
openContext: null,
|
|
234
|
+
// Last loaded saved transcript, kept so a language switch can repaint it.
|
|
235
|
+
fileData: null,
|
|
236
|
+
// Last loaded read-only transcript from another agent.
|
|
237
|
+
agentData: null,
|
|
238
|
+
// { key, path } | null — the working directory the sidebar is scoped to.
|
|
239
|
+
workspace: storedWorkspace(),
|
|
240
|
+
// Plan / usage card visibility and the plan it was opened for.
|
|
241
|
+
planOpen: false,
|
|
242
|
+
planSeen: "",
|
|
243
|
+
composerMode: "live",
|
|
244
|
+
// Folder-picker state: where it is, what to do with the picked path.
|
|
245
|
+
folder: { path: "", parent: null, roots: [], onPick: null, mode: "workspace", loading: false },
|
|
246
|
+
workspaces: null,
|
|
247
|
+
// Translation key and colour class of the sidebar status line.
|
|
248
|
+
status: { key: "connecting…", kind: "" },
|
|
145
249
|
};
|
|
146
250
|
|
|
251
|
+
function storedWorkspace() {
|
|
252
|
+
const path = readStorage("neutrino.workspace");
|
|
253
|
+
if (!path) return null;
|
|
254
|
+
return { key: pathKey(path), path };
|
|
255
|
+
}
|
|
256
|
+
|
|
147
257
|
const preferences = createSettingsUI({
|
|
148
258
|
apiFetch,
|
|
259
|
+
onLanguageChange: (language) => changeInterfaceLanguage(language),
|
|
149
260
|
async applyModel(selection) {
|
|
150
261
|
const current = state.selection;
|
|
151
262
|
if (!current) throw new Error("Select a conversation first.");
|
|
@@ -258,6 +369,37 @@ function shortenPath(value, depth = 2) {
|
|
|
258
369
|
return `…${separator}${parts.slice(-depth).join(separator)}`;
|
|
259
370
|
}
|
|
260
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Canonical form of a working directory, matching the server's `normalizeCwd`.
|
|
374
|
+
* It is only used to decide which sidebar group a path belongs to.
|
|
375
|
+
*/
|
|
376
|
+
function pathKey(value) {
|
|
377
|
+
let text = String(value ?? "").trim();
|
|
378
|
+
if (!text) return "";
|
|
379
|
+
text = text.replace(/^\\\\\?\\/, "").replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
380
|
+
const wsl = /^\/(?:mnt|cygdrive)\/([a-zA-Z])(\/.*)?$/.exec(text);
|
|
381
|
+
const win = /^([a-zA-Z]):(\/.*)?$/.exec(text);
|
|
382
|
+
const drive = wsl ?? win;
|
|
383
|
+
if (drive) {
|
|
384
|
+
const rest = (drive[2] || "/").replace(/\/+$/, "") || "/";
|
|
385
|
+
return `${drive[1].toLowerCase()}:${rest.toLowerCase()}`;
|
|
386
|
+
}
|
|
387
|
+
return text.length > 1 ? text.replace(/\/+$/, "") : text;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Last path segment, used for compact workspace labels. */
|
|
391
|
+
function workspaceName(value) {
|
|
392
|
+
const parts = String(value ?? "").split(/[\\/]+/).filter(Boolean);
|
|
393
|
+
return parts.length ? parts[parts.length - 1] : String(value ?? "");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function setWorkspace(workspace, { persist = true } = {}) {
|
|
397
|
+
state.workspace = workspace ? { key: pathKey(workspace.path), path: workspace.path } : null;
|
|
398
|
+
if (persist) writeStorage("neutrino.workspace", state.workspace?.path ?? "");
|
|
399
|
+
renderWorkspaceChrome();
|
|
400
|
+
renderSidebar();
|
|
401
|
+
}
|
|
402
|
+
|
|
261
403
|
function safeJson(value) {
|
|
262
404
|
try {
|
|
263
405
|
return JSON.stringify(value ?? {});
|
|
@@ -272,44 +414,33 @@ function formatTime(value) {
|
|
|
272
414
|
if (Number.isNaN(date.getTime())) return "";
|
|
273
415
|
const now = Date.now();
|
|
274
416
|
const diff = now - date.getTime();
|
|
275
|
-
if (diff < 60_000) return "just now";
|
|
276
|
-
if (diff < 3_600_000) return
|
|
277
|
-
if (diff < 86_400_000) return
|
|
278
|
-
return date.toLocaleDateString(
|
|
417
|
+
if (diff < 60_000) return tr("just now");
|
|
418
|
+
if (diff < 3_600_000) return tr("{n}m ago", { n: Math.floor(diff / 60_000) });
|
|
419
|
+
if (diff < 86_400_000) return tr("{n}h ago", { n: Math.floor(diff / 3_600_000) });
|
|
420
|
+
return date.toLocaleDateString(localeTag(), { month: "short", day: "numeric" });
|
|
279
421
|
}
|
|
280
422
|
|
|
281
423
|
function formatDate(value) {
|
|
282
424
|
if (!value) return "";
|
|
283
425
|
const date = new Date(value);
|
|
284
|
-
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
|
|
426
|
+
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString(localeTag());
|
|
285
427
|
}
|
|
286
428
|
|
|
287
|
-
function setStatus(
|
|
288
|
-
|
|
429
|
+
function setStatus(key, kind) {
|
|
430
|
+
// Store the translation key, not the rendered text: switching languages must
|
|
431
|
+
// not leave a stale word in the sidebar.
|
|
432
|
+
state.status = { key, kind };
|
|
433
|
+
el.status.textContent = tr(key);
|
|
289
434
|
el.status.className = `status${kind ? ` ${kind}` : ""}`;
|
|
290
435
|
}
|
|
291
436
|
|
|
292
|
-
//
|
|
293
|
-
//
|
|
437
|
+
// Full markdown lives in web/markdown.js: headings, lists, tables, code,
|
|
438
|
+
// quotes, links, images and the inline emphasis family. The fallback keeps the
|
|
439
|
+
// sandboxed browser tests working when module imports are stripped; it still
|
|
440
|
+
// escapes all model output before adding line breaks.
|
|
294
441
|
function renderMarkdown(value) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
source = source.replace(/```[\w-]*\n?([\s\S]*?)```/g, (_, code) => {
|
|
298
|
-
const id = `\u0000CODE${placeholders.length}\u0000`;
|
|
299
|
-
placeholders.push(`<pre class="code"><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
|
|
300
|
-
return id;
|
|
301
|
-
});
|
|
302
|
-
source = escapeHtml(source);
|
|
303
|
-
source = source.replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
|
304
|
-
source = source.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
305
|
-
source = source.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>");
|
|
306
|
-
source = source.replace(
|
|
307
|
-
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
|
308
|
-
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
|
309
|
-
);
|
|
310
|
-
source = source.replace(/\n/g, "<br>");
|
|
311
|
-
source = source.replace(/\u0000CODE(\d+)\u0000/g, (_, index) => placeholders[Number(index)] ?? "");
|
|
312
|
-
return source;
|
|
442
|
+
if (typeof renderMarkdownHtml === "function") return renderMarkdownHtml(value);
|
|
443
|
+
return escapeHtml(String(value ?? "")).replace(/\n/g, "<br>");
|
|
313
444
|
}
|
|
314
445
|
|
|
315
446
|
// ------------------------------------------------------------------- status
|
|
@@ -324,6 +455,7 @@ async function loadOverview(options = {}) {
|
|
|
324
455
|
if (gateShown) return;
|
|
325
456
|
state.overview = overview;
|
|
326
457
|
setStatus(state.selection?.kind === "live" ? "live" : "online", state.selection?.kind === "live" ? "live" : "online");
|
|
458
|
+
renderWorkspaceChrome();
|
|
327
459
|
renderSidebar();
|
|
328
460
|
syncSelection();
|
|
329
461
|
} catch (error) {
|
|
@@ -350,35 +482,49 @@ function syncSelection() {
|
|
|
350
482
|
renderSidebar();
|
|
351
483
|
hideComposer();
|
|
352
484
|
el.controls.innerHTML = "";
|
|
353
|
-
el.title.textContent = "Session ended";
|
|
354
|
-
el.sub.textContent = "The terminal session was closed.";
|
|
355
|
-
el.messages.innerHTML =
|
|
485
|
+
el.title.textContent = tr("Session ended");
|
|
486
|
+
el.sub.textContent = tr("The terminal session was closed.");
|
|
487
|
+
el.messages.innerHTML = `<div class="empty">${escapeHtml(tr("This live session has ended."))}</div>`;
|
|
488
|
+
renderPlanCard(null);
|
|
356
489
|
}
|
|
357
490
|
|
|
358
491
|
// ------------------------------------------------------------------ sidebar
|
|
359
492
|
|
|
493
|
+
/** Sidebar groups for the current view: every folder, or only the open workspace. */
|
|
494
|
+
function viewGroups() {
|
|
495
|
+
if (!state.overview) return null;
|
|
496
|
+
if (!state.workspace) return state.overview.groups;
|
|
497
|
+
const found = state.overview.groups.find((group) => group.key === state.workspace.key);
|
|
498
|
+
return found ? [found] : [{ key: state.workspace.key, cwd: state.workspace.path, updated_at: "", sessions: [] }];
|
|
499
|
+
}
|
|
500
|
+
|
|
360
501
|
function renderSidebar() {
|
|
361
|
-
const overview = state.overview;
|
|
362
502
|
el.sessions.innerHTML = "";
|
|
503
|
+
const groups = viewGroups();
|
|
363
504
|
|
|
364
|
-
if (!
|
|
505
|
+
if (!groups || groups.length === 0) {
|
|
365
506
|
const empty = document.createElement("div");
|
|
366
507
|
empty.className = "empty-side";
|
|
367
|
-
empty.textContent = "No conversations yet.";
|
|
508
|
+
empty.textContent = state.workspace ? tr("No conversations in this folder yet.") : tr("No conversations yet.");
|
|
368
509
|
el.sessions.appendChild(empty);
|
|
369
510
|
return;
|
|
370
511
|
}
|
|
371
512
|
|
|
372
|
-
for (const group of
|
|
373
|
-
|
|
513
|
+
for (const group of groups) el.sessions.appendChild(renderGroup(group, { forceOpen: Boolean(state.workspace) }));
|
|
514
|
+
|
|
515
|
+
if (state.workspace && groups[0] && groups[0].sessions.length === 0) {
|
|
516
|
+
const note = document.createElement("div");
|
|
517
|
+
note.className = "empty-side workspace-hint";
|
|
518
|
+
note.textContent = tr("Type a message below to start a conversation in this folder.");
|
|
519
|
+
el.sessions.appendChild(note);
|
|
374
520
|
}
|
|
375
521
|
}
|
|
376
522
|
|
|
377
|
-
function renderGroup(group) {
|
|
523
|
+
function renderGroup(group, options = {}) {
|
|
378
524
|
const wrap = document.createElement("section");
|
|
379
525
|
wrap.className = "group";
|
|
380
526
|
|
|
381
|
-
const collapsed = state.collapsed.has(group.key);
|
|
527
|
+
const collapsed = !options.forceOpen && state.collapsed.has(group.key);
|
|
382
528
|
const header = document.createElement("button");
|
|
383
529
|
header.className = "group-header";
|
|
384
530
|
header.innerHTML =
|
|
@@ -402,11 +548,18 @@ function renderGroup(group) {
|
|
|
402
548
|
const visible = expanded ? group.sessions : group.sessions.slice(0, MAX_VISIBLE);
|
|
403
549
|
for (const session of visible) list.appendChild(renderSession(session));
|
|
404
550
|
|
|
551
|
+
if (group.sessions.length === 0) {
|
|
552
|
+
const empty = document.createElement("div");
|
|
553
|
+
empty.className = "empty-side";
|
|
554
|
+
empty.textContent = tr("No conversations in this folder yet.");
|
|
555
|
+
list.appendChild(empty);
|
|
556
|
+
}
|
|
557
|
+
|
|
405
558
|
const hiddenCount = group.sessions.length - MAX_VISIBLE;
|
|
406
559
|
if (hiddenCount > 0) {
|
|
407
560
|
const more = document.createElement("button");
|
|
408
561
|
more.className = "more";
|
|
409
|
-
more.textContent = expanded ? "Show less" :
|
|
562
|
+
more.textContent = expanded ? tr("Show less") : tr("Show {n} more", { n: hiddenCount });
|
|
410
563
|
more.addEventListener("click", () => {
|
|
411
564
|
if (state.expanded.has(group.key)) state.expanded.delete(group.key);
|
|
412
565
|
else state.expanded.add(group.key);
|
|
@@ -432,15 +585,15 @@ function renderSession(session) {
|
|
|
432
585
|
if (isSelected(session)) item.classList.add("selected");
|
|
433
586
|
if (session.live) item.classList.add("live");
|
|
434
587
|
|
|
435
|
-
const title = session.title || "New conversation";
|
|
588
|
+
const title = session.title || tr("New conversation");
|
|
436
589
|
const count = Number(session.message_count ?? 0);
|
|
437
590
|
item.title = title;
|
|
438
591
|
item.innerHTML =
|
|
439
592
|
`<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
|
|
440
|
-
`${session.live ?
|
|
593
|
+
`${session.live ? `<span class="dot" title="${escapeHtml(tr("Live"))}"></span>` : ""}</div>` +
|
|
441
594
|
`<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
|
|
442
595
|
`<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
|
|
443
|
-
`<span class="count">${count
|
|
596
|
+
`<span class="count">${escapeHtml(count === 1 ? tr("{n} message", { n: count }) : tr("{n} messages", { n: count }))}</span></div>`;
|
|
444
597
|
|
|
445
598
|
item.addEventListener("click", () => {
|
|
446
599
|
if (session.liveKey) selectLive(session.liveKey);
|
|
@@ -475,17 +628,22 @@ async function selectLive(key) {
|
|
|
475
628
|
closeStream();
|
|
476
629
|
state.selection = { kind: "live", key };
|
|
477
630
|
state.snapshot = null;
|
|
631
|
+
state.fileData = null;
|
|
632
|
+
state.agentData = null;
|
|
478
633
|
state.lastSessionId = null;
|
|
479
634
|
state.openTools.clear();
|
|
480
635
|
state.openTurns.clear();
|
|
481
636
|
state.openContext = null;
|
|
637
|
+
state.planOpen = false;
|
|
638
|
+
state.planSeen = "";
|
|
482
639
|
renderSidebar();
|
|
483
640
|
el.controls.innerHTML = "";
|
|
484
641
|
renderApproval(null);
|
|
485
|
-
|
|
642
|
+
renderPlanCard(null);
|
|
643
|
+
el.title.textContent = tr("Connecting…");
|
|
486
644
|
el.sub.textContent = "";
|
|
487
645
|
setComposerMode("live");
|
|
488
|
-
resetMessagePane("Connecting…");
|
|
646
|
+
resetMessagePane(tr("Connecting…"));
|
|
489
647
|
openStream(key);
|
|
490
648
|
}
|
|
491
649
|
|
|
@@ -534,16 +692,21 @@ async function selectFile(id) {
|
|
|
534
692
|
closeStream();
|
|
535
693
|
state.selection = { kind: "file", id };
|
|
536
694
|
state.snapshot = null;
|
|
695
|
+
state.fileData = null;
|
|
696
|
+
state.agentData = null;
|
|
537
697
|
state.openTools.clear();
|
|
538
698
|
state.openTurns.clear();
|
|
539
699
|
state.openContext = null;
|
|
700
|
+
state.planOpen = false;
|
|
701
|
+
state.planSeen = "";
|
|
540
702
|
renderSidebar();
|
|
541
703
|
el.controls.innerHTML = "";
|
|
542
704
|
renderApproval(null);
|
|
705
|
+
renderPlanCard(null);
|
|
543
706
|
setComposerMode("resume");
|
|
544
|
-
el.title.textContent = "Loading…";
|
|
707
|
+
el.title.textContent = tr("Loading…");
|
|
545
708
|
el.sub.textContent = "";
|
|
546
|
-
resetMessagePane("Loading…");
|
|
709
|
+
resetMessagePane(tr("Loading…"));
|
|
547
710
|
|
|
548
711
|
try {
|
|
549
712
|
const response = await apiFetch(`/api/session/${encodeURIComponent(id)}`, { cache: "no-store" });
|
|
@@ -551,8 +714,8 @@ async function selectFile(id) {
|
|
|
551
714
|
const data = await response.json();
|
|
552
715
|
renderFile(data);
|
|
553
716
|
} catch (error) {
|
|
554
|
-
el.title.textContent = "Conversation";
|
|
555
|
-
resetMessagePane(
|
|
717
|
+
el.title.textContent = tr("Conversation");
|
|
718
|
+
resetMessagePane(tr("Could not load this conversation: {error}", { error: error.message }));
|
|
556
719
|
}
|
|
557
720
|
}
|
|
558
721
|
|
|
@@ -560,11 +723,15 @@ async function selectFile(id) {
|
|
|
560
723
|
|
|
561
724
|
function renderFile(data) {
|
|
562
725
|
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
563
|
-
|
|
726
|
+
state.fileData = data;
|
|
727
|
+
state.agentData = null;
|
|
728
|
+
el.title.textContent = firstUserMessage(messages) || tr("Conversation");
|
|
564
729
|
el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
|
|
565
730
|
preferences.showConversation(data, `file:${data.id}`);
|
|
566
731
|
renderMessageList(messages, null, { readOnly: true, context: data.context, active: false });
|
|
567
732
|
renderFileControls(data);
|
|
733
|
+
maybeOpenPlan(data.plan);
|
|
734
|
+
renderPlanCard(data);
|
|
568
735
|
}
|
|
569
736
|
|
|
570
737
|
/**
|
|
@@ -573,26 +740,27 @@ function renderFile(data) {
|
|
|
573
740
|
*/
|
|
574
741
|
function renderFileControls(data) {
|
|
575
742
|
el.controls.innerHTML = "";
|
|
743
|
+
el.controls.appendChild(planToggleButton());
|
|
576
744
|
|
|
577
745
|
const badge = document.createElement("span");
|
|
578
746
|
badge.className = "badge readonly";
|
|
579
|
-
badge.textContent = "Read-only";
|
|
747
|
+
badge.textContent = tr("Read-only");
|
|
580
748
|
el.controls.appendChild(badge);
|
|
581
749
|
|
|
582
750
|
const button = document.createElement("button");
|
|
583
751
|
button.className = "control primary";
|
|
584
|
-
button.textContent = "Continue here";
|
|
585
|
-
button.title = "Resume this conversation in the browser and keep chatting";
|
|
752
|
+
button.textContent = tr("Continue here");
|
|
753
|
+
button.title = tr("Resume this conversation in the browser and keep chatting");
|
|
586
754
|
button.addEventListener("click", async () => {
|
|
587
755
|
button.disabled = true;
|
|
588
|
-
button.textContent = "Starting…";
|
|
756
|
+
button.textContent = tr("Starting…");
|
|
589
757
|
try {
|
|
590
758
|
await continueSession(data.id);
|
|
591
759
|
el.input.focus();
|
|
592
760
|
} catch (error) {
|
|
593
761
|
button.disabled = false;
|
|
594
|
-
button.textContent = "Continue here";
|
|
595
|
-
el.sub.textContent =
|
|
762
|
+
button.textContent = tr("Continue here");
|
|
763
|
+
el.sub.textContent = tr("Could not continue this conversation: {error}", { error: error.message });
|
|
596
764
|
}
|
|
597
765
|
});
|
|
598
766
|
el.controls.appendChild(button);
|
|
@@ -600,6 +768,13 @@ function renderFileControls(data) {
|
|
|
600
768
|
const key = await continueSession(data.id);
|
|
601
769
|
await changeApprovalMode(key, mode);
|
|
602
770
|
});
|
|
771
|
+
|
|
772
|
+
const remove = document.createElement("button");
|
|
773
|
+
remove.className = "control danger";
|
|
774
|
+
remove.textContent = tr("Delete");
|
|
775
|
+
remove.title = tr("Delete conversation");
|
|
776
|
+
remove.addEventListener("click", () => deleteConversation(data.id, remove));
|
|
777
|
+
el.controls.appendChild(remove);
|
|
603
778
|
}
|
|
604
779
|
|
|
605
780
|
/** Attach to a resumed conversation, creating it server-side when needed. */
|
|
@@ -616,11 +791,13 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
|
|
|
616
791
|
}
|
|
617
792
|
|
|
618
793
|
function setComposerMode(mode) {
|
|
794
|
+
state.composerMode = mode;
|
|
619
795
|
el.composer.classList.remove("hidden");
|
|
620
|
-
el.input.placeholder =
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
796
|
+
el.input.placeholder = mode === "resume"
|
|
797
|
+
? tr("Continue this conversation…")
|
|
798
|
+
: mode === "new"
|
|
799
|
+
? tr("Message Neutrino in this folder…")
|
|
800
|
+
: tr("Message Neutrino…");
|
|
624
801
|
}
|
|
625
802
|
|
|
626
803
|
function hideComposer() {
|
|
@@ -630,18 +807,24 @@ function hideComposer() {
|
|
|
630
807
|
}
|
|
631
808
|
|
|
632
809
|
function renderApprovalDetails(request, fallbackCwd = "") {
|
|
810
|
+
// The approval renderer is exercised on its own in the browser tests; fall
|
|
811
|
+
// back to the English source if the translation helper is out of scope.
|
|
812
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
813
|
+
? tr(text, values)
|
|
814
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
815
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
633
816
|
const details = document.createElement("div");
|
|
634
817
|
details.className = "approval-details";
|
|
635
818
|
const tool = document.createElement("div");
|
|
636
819
|
tool.className = "approval-tool";
|
|
637
|
-
tool.textContent = request.tool || "Tool call";
|
|
820
|
+
tool.textContent = request.tool || tx("Tool call");
|
|
638
821
|
details.appendChild(tool);
|
|
639
822
|
|
|
640
823
|
const cwd = request.cwd || fallbackCwd;
|
|
641
824
|
if (cwd) {
|
|
642
825
|
const location = document.createElement("div");
|
|
643
826
|
location.className = "approval-location";
|
|
644
|
-
location.textContent =
|
|
827
|
+
location.textContent = tx("Working directory: {path}", { path: cwd });
|
|
645
828
|
details.appendChild(location);
|
|
646
829
|
}
|
|
647
830
|
|
|
@@ -658,14 +841,14 @@ function renderApprovalDetails(request, fallbackCwd = "") {
|
|
|
658
841
|
// Literal text preserves the complete command and prevents tool arguments
|
|
659
842
|
// from becoming markup. Risk summaries may be shortened; arguments are not.
|
|
660
843
|
code.textContent = command !== null ? command
|
|
661
|
-
: callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || "Command details unavailable.";
|
|
844
|
+
: callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || tx("Command details unavailable.");
|
|
662
845
|
details.appendChild(code);
|
|
663
846
|
|
|
664
847
|
if (stated) {
|
|
665
848
|
const why = document.createElement("div");
|
|
666
849
|
why.className = "approval-why";
|
|
667
850
|
const label = document.createElement("strong");
|
|
668
|
-
label.textContent = "Why: ";
|
|
851
|
+
label.textContent = tx("Why: ");
|
|
669
852
|
const claim = document.createElement("span");
|
|
670
853
|
claim.textContent = stated;
|
|
671
854
|
why.append(label, claim);
|
|
@@ -676,7 +859,7 @@ function renderApprovalDetails(request, fallbackCwd = "") {
|
|
|
676
859
|
const argumentsBox = document.createElement("details");
|
|
677
860
|
argumentsBox.className = "approval-arguments";
|
|
678
861
|
const label = document.createElement("summary");
|
|
679
|
-
label.textContent = "All arguments";
|
|
862
|
+
label.textContent = tx("All arguments");
|
|
680
863
|
const parameters = document.createElement("pre");
|
|
681
864
|
parameters.className = "approval-code";
|
|
682
865
|
parameters.textContent = JSON.stringify(callArgs, null, 2);
|
|
@@ -697,33 +880,39 @@ function renderApprovalDetails(request, fallbackCwd = "") {
|
|
|
697
880
|
return details;
|
|
698
881
|
}
|
|
699
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Progress text for an automated review. It belongs in the status badge, not
|
|
885
|
+
* in the approval banner: automatic review needs no answer from the user, and
|
|
886
|
+
* reusing the approval card made the browser look like it was still asking for
|
|
887
|
+
* one while the reviewers were already deciding.
|
|
888
|
+
*/
|
|
889
|
+
function reviewStatus(review) {
|
|
890
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
891
|
+
? tr(text, values)
|
|
892
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
893
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
894
|
+
const rounds = Number(review && review.rounds) || 1;
|
|
895
|
+
const total = Number(review && review.total) || 0;
|
|
896
|
+
if (rounds > 1) {
|
|
897
|
+
return tx("reviewing round {round}/{rounds}", { round: (review && review.round) || 1, rounds });
|
|
898
|
+
}
|
|
899
|
+
if (total === 1) return tx("reviewing · {n} reviewer", { n: total });
|
|
900
|
+
if (total > 1) return tx("reviewing · {n} reviewers", { n: total });
|
|
901
|
+
return tx("reviewing");
|
|
902
|
+
}
|
|
903
|
+
|
|
700
904
|
function renderApproval(snapshot) {
|
|
905
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
906
|
+
? tr(text, values)
|
|
907
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
908
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
701
909
|
const pending = snapshot && snapshot.approval;
|
|
702
910
|
if (!pending) {
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
const head = document.createElement("div");
|
|
709
|
-
head.className = "approval-head";
|
|
710
|
-
const title = document.createElement("strong");
|
|
711
|
-
title.textContent = "Independent review";
|
|
712
|
-
const note = document.createElement("span");
|
|
713
|
-
note.className = "approval-risk";
|
|
714
|
-
// Delete-class calls report which of the three separate rounds is running.
|
|
715
|
-
const rounds = review.rounds || 1;
|
|
716
|
-
const which = rounds > 1 ? `round ${review.round || 1} of ${rounds}, ` : "";
|
|
717
|
-
note.textContent = `${which}${review.total} reviewer${review.total > 1 ? "s" : ""}`;
|
|
718
|
-
head.append(title, note);
|
|
719
|
-
if (review.request) {
|
|
720
|
-
el.approval.replaceChildren(head, renderApprovalDetails(review.request, snapshot.cwd));
|
|
721
|
-
} else {
|
|
722
|
-
el.approval.replaceChildren(head);
|
|
723
|
-
}
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
el.approval.classList.add("hidden");
|
|
911
|
+
// The banner is reserved for a call that is really waiting for a human.
|
|
912
|
+
// Automatic review progress lives in the controls badge (see renderControls),
|
|
913
|
+
// because otherwise it reads as a second approval request that then
|
|
914
|
+
// disappears by itself.
|
|
915
|
+
el.approval.className = "approval hidden";
|
|
727
916
|
el.approval.replaceChildren();
|
|
728
917
|
return;
|
|
729
918
|
}
|
|
@@ -733,10 +922,10 @@ function renderApproval(snapshot) {
|
|
|
733
922
|
const head = document.createElement("div");
|
|
734
923
|
head.className = "approval-head";
|
|
735
924
|
const title = document.createElement("strong");
|
|
736
|
-
title.textContent = "Approval required";
|
|
925
|
+
title.textContent = tx("Approval required");
|
|
737
926
|
const risk = document.createElement("span");
|
|
738
927
|
risk.className = "approval-risk";
|
|
739
|
-
risk.textContent = extreme ? "EXTREME RISK" : "HIGH RISK";
|
|
928
|
+
risk.textContent = extreme ? tx("EXTREME RISK") : tx("HIGH RISK");
|
|
740
929
|
head.append(title, risk);
|
|
741
930
|
|
|
742
931
|
const details = renderApprovalDetails(pending, snapshot.cwd);
|
|
@@ -745,11 +934,11 @@ function renderApproval(snapshot) {
|
|
|
745
934
|
actions.className = "approval-actions";
|
|
746
935
|
const approve = document.createElement("button");
|
|
747
936
|
approve.className = "approve-btn";
|
|
748
|
-
approve.textContent = "Approve";
|
|
937
|
+
approve.textContent = tx("Approve");
|
|
749
938
|
approve.addEventListener("click", () => decideApproval(true));
|
|
750
939
|
const deny = document.createElement("button");
|
|
751
940
|
deny.className = "deny-btn";
|
|
752
|
-
deny.textContent = "Deny";
|
|
941
|
+
deny.textContent = tx("Deny");
|
|
753
942
|
deny.addEventListener("click", () => decideApproval(false));
|
|
754
943
|
actions.append(approve, deny);
|
|
755
944
|
|
|
@@ -765,7 +954,9 @@ function decideApproval(approved) {
|
|
|
765
954
|
function renderLive() {
|
|
766
955
|
const snapshot = state.snapshot;
|
|
767
956
|
if (!snapshot) return;
|
|
768
|
-
|
|
957
|
+
state.fileData = null;
|
|
958
|
+
state.agentData = null;
|
|
959
|
+
el.title.textContent = snapshot.title || tr("New conversation");
|
|
769
960
|
el.sub.textContent = [snapshot.cwd, snapshot.model, snapshot.provider].filter(Boolean).join(" · ");
|
|
770
961
|
preferences.showConversation(snapshot, `live:${snapshot.key}`);
|
|
771
962
|
renderControls(snapshot);
|
|
@@ -777,6 +968,8 @@ function renderLive() {
|
|
|
777
968
|
// paused turn is unfinished by the user's own choice, so it stays open too.
|
|
778
969
|
active: Boolean(snapshot.busy || snapshot.paused),
|
|
779
970
|
});
|
|
971
|
+
maybeOpenPlan(snapshot.plan);
|
|
972
|
+
renderPlanCard(snapshot);
|
|
780
973
|
}
|
|
781
974
|
|
|
782
975
|
async function changeApprovalMode(key, mode) {
|
|
@@ -794,21 +987,25 @@ async function changeApprovalMode(key, mode) {
|
|
|
794
987
|
}
|
|
795
988
|
|
|
796
989
|
function renderApprovalModeControl(mode, change) {
|
|
990
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
991
|
+
? tr(text, values)
|
|
992
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
993
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
797
994
|
const automatic = mode === "auto";
|
|
798
995
|
const button = document.createElement("button");
|
|
799
996
|
button.className = "control";
|
|
800
997
|
button.type = "button";
|
|
801
998
|
button.setAttribute("role", "switch");
|
|
802
|
-
button.setAttribute("aria-label", "Auto Approval");
|
|
999
|
+
button.setAttribute("aria-label", tx("Auto Approval"));
|
|
803
1000
|
button.setAttribute("aria-checked", String(automatic));
|
|
804
|
-
button.textContent = automatic ? "Auto Approval" : "Request Approval";
|
|
805
|
-
button.title = "Switch between requesting approval and automatic review";
|
|
1001
|
+
button.textContent = automatic ? tx("Auto Approval") : tx("Request Approval");
|
|
1002
|
+
button.title = tx("Switch between requesting approval and automatic review");
|
|
806
1003
|
button.addEventListener("click", async () => {
|
|
807
1004
|
button.disabled = true;
|
|
808
1005
|
try {
|
|
809
1006
|
await change(automatic ? "request" : "auto");
|
|
810
1007
|
} catch (error) {
|
|
811
|
-
el.sub.textContent =
|
|
1008
|
+
el.sub.textContent = tx("Could not change approval mode: {error}", { error: error.message });
|
|
812
1009
|
} finally {
|
|
813
1010
|
button.disabled = false;
|
|
814
1011
|
}
|
|
@@ -818,6 +1015,7 @@ function renderApprovalModeControl(mode, change) {
|
|
|
818
1015
|
|
|
819
1016
|
function renderControls(snapshot) {
|
|
820
1017
|
el.controls.innerHTML = "";
|
|
1018
|
+
el.controls.appendChild(planToggleButton());
|
|
821
1019
|
renderApprovalModeControl(snapshot.approvalMode, mode =>
|
|
822
1020
|
changeApprovalMode(snapshot.key, mode));
|
|
823
1021
|
|
|
@@ -826,51 +1024,66 @@ function renderControls(snapshot) {
|
|
|
826
1024
|
badge.className = `badge ${mode}`;
|
|
827
1025
|
// A retried request leaves the session busy but silent: name the retry so the
|
|
828
1026
|
// badge does not just say "working" for minutes. The same goes for a
|
|
829
|
-
// sub-agent, whose own messages never reach this transcript.
|
|
1027
|
+
// sub-agent, whose own messages never reach this transcript. An automated
|
|
1028
|
+
// review is reported here too, so it never needs the approval banner.
|
|
830
1029
|
const retrying = snapshot.retrying;
|
|
1030
|
+
// A pending human decision is an approval, not a review, even if an earlier
|
|
1031
|
+
// review callback is still winding down.
|
|
1032
|
+
const reviewing = snapshot.approval ? null : snapshot.reviewing;
|
|
831
1033
|
const subagent = snapshot.subagent;
|
|
832
1034
|
badge.textContent = snapshot.paused
|
|
833
|
-
? "paused"
|
|
1035
|
+
? tr("paused")
|
|
834
1036
|
: snapshot.busy
|
|
835
1037
|
? snapshot.compressing
|
|
836
|
-
? "compressing"
|
|
1038
|
+
? tr("compressing")
|
|
837
1039
|
: retrying
|
|
838
|
-
?
|
|
839
|
-
:
|
|
840
|
-
?
|
|
841
|
-
:
|
|
842
|
-
|
|
1040
|
+
? tr("retrying {attempt}/{attempts}", { attempt: retrying.attempt, attempts: retrying.attempts })
|
|
1041
|
+
: reviewing
|
|
1042
|
+
? reviewStatus(reviewing)
|
|
1043
|
+
: subagent
|
|
1044
|
+
? tr("sub-agent: {label}", { label: subagent })
|
|
1045
|
+
: tr("working")
|
|
1046
|
+
: tr("idle");
|
|
843
1047
|
el.controls.appendChild(badge);
|
|
844
1048
|
|
|
845
1049
|
if (snapshot.saveError) {
|
|
846
1050
|
const warning = document.createElement("span");
|
|
847
1051
|
warning.className = "badge paused";
|
|
848
|
-
warning.textContent =
|
|
1052
|
+
warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError });
|
|
849
1053
|
el.controls.appendChild(warning);
|
|
850
1054
|
}
|
|
851
1055
|
|
|
852
1056
|
if (snapshot.busy || snapshot.paused) {
|
|
853
1057
|
const button = document.createElement("button");
|
|
854
1058
|
button.className = "control";
|
|
855
|
-
button.textContent = snapshot.paused ? "Resume" : "Pause";
|
|
1059
|
+
button.textContent = snapshot.paused ? tr("Resume") : tr("Pause");
|
|
856
1060
|
button.addEventListener("click", () => {
|
|
857
1061
|
postAction(snapshot.key, snapshot.paused ? "resume" : "pause");
|
|
858
1062
|
});
|
|
859
1063
|
el.controls.appendChild(button);
|
|
860
1064
|
}
|
|
861
1065
|
|
|
1066
|
+
if ((snapshot.messages?.length ?? 0) > 0 || snapshot.busy) {
|
|
1067
|
+
const clear = document.createElement("button");
|
|
1068
|
+
clear.className = "control danger";
|
|
1069
|
+
clear.textContent = tr("Clear");
|
|
1070
|
+
clear.title = tr("Clear conversation");
|
|
1071
|
+
clear.addEventListener("click", () => clearConversation(snapshot.key));
|
|
1072
|
+
el.controls.appendChild(clear);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
862
1075
|
if (snapshot.queued) {
|
|
863
1076
|
const queued = document.createElement("span");
|
|
864
1077
|
queued.className = "queued";
|
|
865
1078
|
queued.title = snapshot.queued;
|
|
866
|
-
queued.textContent = "queued";
|
|
1079
|
+
queued.textContent = tr("queued");
|
|
867
1080
|
el.controls.appendChild(queued);
|
|
868
1081
|
}
|
|
869
1082
|
|
|
870
1083
|
if (snapshot.tokens && (snapshot.tokens.prompt || snapshot.tokens.completion)) {
|
|
871
1084
|
const tokens = document.createElement("span");
|
|
872
1085
|
tokens.className = "tokens";
|
|
873
|
-
tokens.textContent =
|
|
1086
|
+
tokens.textContent = tr("{prompt}→{completion} tok", { prompt: snapshot.tokens.prompt, completion: snapshot.tokens.completion });
|
|
874
1087
|
el.controls.appendChild(tokens);
|
|
875
1088
|
}
|
|
876
1089
|
}
|
|
@@ -885,7 +1098,8 @@ function renderMessageList(messages, streaming, options) {
|
|
|
885
1098
|
if (messages.length === 0 && !streaming) {
|
|
886
1099
|
const empty = document.createElement("div");
|
|
887
1100
|
empty.className = "empty";
|
|
888
|
-
|
|
1101
|
+
const emptyText = options?.readOnly ? "This conversation is empty." : "Send a message to get started.";
|
|
1102
|
+
empty.textContent = typeof tr === "function" ? tr(emptyText) : emptyText;
|
|
889
1103
|
el.messages.appendChild(empty);
|
|
890
1104
|
return;
|
|
891
1105
|
}
|
|
@@ -899,7 +1113,9 @@ function renderMessageList(messages, streaming, options) {
|
|
|
899
1113
|
const details = document.createElement("details");
|
|
900
1114
|
details.className = "conversation-history";
|
|
901
1115
|
const summary = document.createElement("summary");
|
|
902
|
-
summary.textContent =
|
|
1116
|
+
summary.textContent = typeof tr === "function"
|
|
1117
|
+
? tr("Earlier conversation · {n} messages", { n: end })
|
|
1118
|
+
: `Earlier conversation · ${end} messages`;
|
|
903
1119
|
details.appendChild(summary);
|
|
904
1120
|
const body = document.createElement("div");
|
|
905
1121
|
body.className = "history-messages";
|
|
@@ -982,13 +1198,13 @@ function renderMessage(message, index) {
|
|
|
982
1198
|
if (message.role === "summary") {
|
|
983
1199
|
article.classList.add("summary");
|
|
984
1200
|
article.innerHTML =
|
|
985
|
-
`<div class="summary-label"
|
|
1201
|
+
`<div class="summary-label">${escapeHtml(tr("Context summary · {n} messages", { n: Number(message.count ?? 0) }))}</div>` +
|
|
986
1202
|
`<div class="body">${renderMarkdown(message.content)}</div>`;
|
|
987
1203
|
return article;
|
|
988
1204
|
}
|
|
989
1205
|
|
|
990
|
-
const who = message.role === "user" ? "You" : "Neutrino";
|
|
991
|
-
article.innerHTML = `<div class="who">${who}</div><div class="body">${renderMarkdown(message.content)}</div>`;
|
|
1206
|
+
const who = message.role === "user" ? tr("You") : "Neutrino";
|
|
1207
|
+
article.innerHTML = `<div class="who">${escapeHtml(who)}</div><div class="body">${renderMarkdown(message.content)}</div>`;
|
|
992
1208
|
return article;
|
|
993
1209
|
}
|
|
994
1210
|
|
|
@@ -1000,6 +1216,12 @@ function renderMessage(message, index) {
|
|
|
1000
1216
|
* transcript always holds every message.
|
|
1001
1217
|
*/
|
|
1002
1218
|
function renderTurnFold(start, messages) {
|
|
1219
|
+
// This renderer is also exercised standalone by the web tests, so it carries
|
|
1220
|
+
// an English fallback for the translation helper.
|
|
1221
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
1222
|
+
? tr(text, values)
|
|
1223
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
1224
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
1003
1225
|
// The reply is never folded away, so a turn renders as a group: the fold (the
|
|
1004
1226
|
// prompt line plus the work it hides) and, right below it, the answer itself.
|
|
1005
1227
|
const reply = turnAnswerIndex(messages);
|
|
@@ -1018,10 +1240,10 @@ function renderTurnFold(start, messages) {
|
|
|
1018
1240
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>';
|
|
1019
1241
|
const you = document.createElement("span");
|
|
1020
1242
|
you.className = "turn-you";
|
|
1021
|
-
you.textContent =
|
|
1243
|
+
you.textContent = tx("You: {text}", { text: truncate(turnPreview(messages, "user"), 110) });
|
|
1022
1244
|
const meta = document.createElement("span");
|
|
1023
1245
|
meta.className = "turn-meta";
|
|
1024
|
-
meta.textContent =
|
|
1246
|
+
meta.textContent = tx("{n} of {m} messages folded", { n: hidden, m: messages.length });
|
|
1025
1247
|
// The hint cannot live in the static text: it says the opposite once open.
|
|
1026
1248
|
const hint = document.createElement("span");
|
|
1027
1249
|
hint.className = "turn-hint";
|
|
@@ -1033,7 +1255,7 @@ function renderTurnFold(start, messages) {
|
|
|
1033
1255
|
group.appendChild(details);
|
|
1034
1256
|
|
|
1035
1257
|
const syncHint = () => {
|
|
1036
|
-
hint.textContent = details.open ? " — click to collapse" : " — click to expand";
|
|
1258
|
+
hint.textContent = details.open ? tx(" — click to collapse") : tx(" — click to expand");
|
|
1037
1259
|
};
|
|
1038
1260
|
syncHint();
|
|
1039
1261
|
|
|
@@ -1110,6 +1332,24 @@ async function sendMessage() {
|
|
|
1110
1332
|
return;
|
|
1111
1333
|
}
|
|
1112
1334
|
|
|
1335
|
+
if (selection.kind === "new") {
|
|
1336
|
+
// A workspace landing page: typing starts a fresh conversation there, just
|
|
1337
|
+
// like typing on the terminal's start screen.
|
|
1338
|
+
el.input.value = "";
|
|
1339
|
+
autosize();
|
|
1340
|
+
try {
|
|
1341
|
+
const created = await createConversation(selection.cwd, preferences.defaultSelection?.());
|
|
1342
|
+
await selectLive(created.key);
|
|
1343
|
+
setConversationView(true);
|
|
1344
|
+
await postAction(created.key, "message", { text });
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
el.input.value = text;
|
|
1347
|
+
autosize();
|
|
1348
|
+
el.sub.textContent = tr("Could not start the conversation: {error}", { error: error.message });
|
|
1349
|
+
}
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1113
1353
|
// Reading a saved transcript: resume it in the browser, then deliver the text.
|
|
1114
1354
|
el.input.value = "";
|
|
1115
1355
|
autosize();
|
|
@@ -1119,7 +1359,7 @@ async function sendMessage() {
|
|
|
1119
1359
|
} catch (error) {
|
|
1120
1360
|
el.input.value = text;
|
|
1121
1361
|
autosize();
|
|
1122
|
-
el.sub.textContent =
|
|
1362
|
+
el.sub.textContent = tr("Could not continue this conversation: {error}", { error: error.message });
|
|
1123
1363
|
}
|
|
1124
1364
|
}
|
|
1125
1365
|
|
|
@@ -1128,6 +1368,854 @@ function autosize() {
|
|
|
1128
1368
|
el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
|
|
1129
1369
|
}
|
|
1130
1370
|
|
|
1371
|
+
// ------------------------------------------------------- workspaces & dialogs
|
|
1372
|
+
|
|
1373
|
+
function renderWorkspaceChrome() {
|
|
1374
|
+
if (!el.workspaceLabel) return;
|
|
1375
|
+
const workspace = state.workspace;
|
|
1376
|
+
el.workspaceLabel.textContent = workspace ? workspaceName(workspace.path) : tr("All directories");
|
|
1377
|
+
if (el.workspaceButton) {
|
|
1378
|
+
el.workspaceButton.title = workspace ? workspace.path : tr("Working directory");
|
|
1379
|
+
el.workspaceButton.classList.toggle("active", Boolean(workspace));
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function joinPath(parent, name) {
|
|
1384
|
+
const separator = String(parent ?? "").includes("\\") ? "\\" : "/";
|
|
1385
|
+
return `${String(parent ?? "").replace(/[\\/]+$/, "")}${separator}${name}`;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function selectionCwd() {
|
|
1389
|
+
const selection = state.selection;
|
|
1390
|
+
if (!selection) return "";
|
|
1391
|
+
if (selection.kind === "live") return state.snapshot?.cwd || "";
|
|
1392
|
+
if (selection.kind === "file") return state.fileData?.cwd || "";
|
|
1393
|
+
if (selection.kind === "new") return selection.cwd || "";
|
|
1394
|
+
if (selection.kind === "agent") return state.agentData?.cwd || state.workspace?.path || "";
|
|
1395
|
+
return "";
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function confirmAction(message) {
|
|
1399
|
+
try {
|
|
1400
|
+
return typeof window === "undefined" || typeof window.confirm !== "function" ? true : window.confirm(message);
|
|
1401
|
+
} catch {
|
|
1402
|
+
return true;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function apiError(data, fallback) {
|
|
1407
|
+
return tr(data && typeof data.error === "string" ? data.error : fallback);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function renderHomeEmpty() {
|
|
1411
|
+
el.messages.innerHTML = "";
|
|
1412
|
+
const empty = document.createElement("div");
|
|
1413
|
+
empty.className = "empty";
|
|
1414
|
+
empty.innerHTML =
|
|
1415
|
+
'<span class="empty-mark" aria-hidden="true">' +
|
|
1416
|
+
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
|
|
1417
|
+
'<ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />' +
|
|
1418
|
+
'<circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />' +
|
|
1419
|
+
"</svg></span>" +
|
|
1420
|
+
`<strong>${escapeHtml(tr("No conversation open"))}</strong>` +
|
|
1421
|
+
`<span>${escapeHtml(tr("Choose a conversation from the sidebar to view it."))}</span>`;
|
|
1422
|
+
const button = document.createElement("button");
|
|
1423
|
+
button.className = "control primary";
|
|
1424
|
+
button.type = "button";
|
|
1425
|
+
button.textContent = tr("New conversation");
|
|
1426
|
+
button.addEventListener("click", () => openNewChatDialog());
|
|
1427
|
+
empty.appendChild(button);
|
|
1428
|
+
el.messages.appendChild(empty);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** Back to the neutral landing view, with no conversation selected. */
|
|
1432
|
+
function showHome({ menu = true } = {}) {
|
|
1433
|
+
closeStream();
|
|
1434
|
+
state.selection = null;
|
|
1435
|
+
state.snapshot = null;
|
|
1436
|
+
state.fileData = null;
|
|
1437
|
+
state.agentData = null;
|
|
1438
|
+
state.planOpen = false;
|
|
1439
|
+
state.planSeen = "";
|
|
1440
|
+
preferences.clear();
|
|
1441
|
+
renderSidebar();
|
|
1442
|
+
renderWorkspaceChrome();
|
|
1443
|
+
el.controls.innerHTML = "";
|
|
1444
|
+
renderApproval(null);
|
|
1445
|
+
renderPlanCard(null);
|
|
1446
|
+
hideComposer();
|
|
1447
|
+
el.title.textContent = tr("Select a conversation");
|
|
1448
|
+
el.sub.textContent = "";
|
|
1449
|
+
renderHomeEmpty();
|
|
1450
|
+
if (menu) setConversationView(false);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** Show a folder as an empty workspace ready for its first message. */
|
|
1454
|
+
function renderWorkspaceLanding(path) {
|
|
1455
|
+
state.selection = { kind: "new", cwd: path };
|
|
1456
|
+
state.snapshot = null;
|
|
1457
|
+
state.fileData = null;
|
|
1458
|
+
state.planOpen = false;
|
|
1459
|
+
state.planSeen = "";
|
|
1460
|
+
closeStream();
|
|
1461
|
+
preferences.clear();
|
|
1462
|
+
renderSidebar();
|
|
1463
|
+
renderWorkspaceChrome();
|
|
1464
|
+
el.controls.innerHTML = "";
|
|
1465
|
+
renderApproval(null);
|
|
1466
|
+
renderPlanCard(null);
|
|
1467
|
+
el.title.textContent = workspaceName(path) || tr("Working directory");
|
|
1468
|
+
el.sub.textContent = path;
|
|
1469
|
+
setComposerMode("new");
|
|
1470
|
+
el.messages.innerHTML = "";
|
|
1471
|
+
|
|
1472
|
+
const empty = document.createElement("div");
|
|
1473
|
+
empty.className = "empty workspace-empty";
|
|
1474
|
+
empty.innerHTML =
|
|
1475
|
+
'<span class="empty-mark" aria-hidden="true">' +
|
|
1476
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
1477
|
+
'<path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.5h7.5A2.5 2.5 0 0 1 21 10v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17z" />' +
|
|
1478
|
+
"</svg></span>" +
|
|
1479
|
+
`<strong>${escapeHtml(workspaceName(path) || path)}</strong>` +
|
|
1480
|
+
`<span class="workspace-path">${escapeHtml(path)}</span>` +
|
|
1481
|
+
`<p class="workspace-prompt">${escapeHtml(tr("Type a message below to start a conversation in this folder."))}</p>`;
|
|
1482
|
+
const button = document.createElement("button");
|
|
1483
|
+
button.className = "control primary";
|
|
1484
|
+
button.type = "button";
|
|
1485
|
+
button.textContent = tr("New conversation");
|
|
1486
|
+
button.addEventListener("click", () => openNewChatDialog({ cwd: path }));
|
|
1487
|
+
empty.appendChild(button);
|
|
1488
|
+
el.messages.appendChild(empty);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/** Open a working directory and make it the active sidebar scope. */
|
|
1492
|
+
async function openWorkspace(path, { record = true } = {}) {
|
|
1493
|
+
const target = String(path ?? "").trim();
|
|
1494
|
+
if (!target) return;
|
|
1495
|
+
setWorkspace({ path: target });
|
|
1496
|
+
renderWorkspaceLanding(target);
|
|
1497
|
+
setConversationView(true);
|
|
1498
|
+
if (record) {
|
|
1499
|
+
try {
|
|
1500
|
+
const response = await apiFetch("/api/workspaces", {
|
|
1501
|
+
method: "POST",
|
|
1502
|
+
headers: { "Content-Type": "application/json" },
|
|
1503
|
+
body: JSON.stringify({ path: target }),
|
|
1504
|
+
});
|
|
1505
|
+
const data = await response.json().catch(() => ({}));
|
|
1506
|
+
if (response.ok && Array.isArray(data.workspaces)) {
|
|
1507
|
+
state.workspaces = { ...(state.workspaces ?? {}), workspaces: data.workspaces };
|
|
1508
|
+
}
|
|
1509
|
+
} catch {
|
|
1510
|
+
// Opening the folder still works when remembering it does not.
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
await loadOverview({ silent: true });
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function renderWorkspaceList() {
|
|
1517
|
+
if (!el.workspaceList) return;
|
|
1518
|
+
el.workspaceList.innerHTML = "";
|
|
1519
|
+
const folders = state.workspaces?.workspaces ?? [];
|
|
1520
|
+
if (!folders.length) {
|
|
1521
|
+
const empty = document.createElement("div");
|
|
1522
|
+
empty.className = "empty-side";
|
|
1523
|
+
empty.textContent = tr("No recent folders.");
|
|
1524
|
+
el.workspaceList.appendChild(empty);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
for (const folder of folders) {
|
|
1528
|
+
const item = document.createElement("button");
|
|
1529
|
+
item.className = "workspace-item";
|
|
1530
|
+
item.type = "button";
|
|
1531
|
+
item.title = folder.path;
|
|
1532
|
+
if (state.workspace && pathKey(state.workspace.path) === pathKey(folder.path)) {
|
|
1533
|
+
item.classList.add("selected");
|
|
1534
|
+
}
|
|
1535
|
+
if (!folder.exists) item.classList.add("missing");
|
|
1536
|
+
const count = Number(folder.sessions ?? 0);
|
|
1537
|
+
item.innerHTML =
|
|
1538
|
+
'<span class="workspace-icon" aria-hidden="true">' +
|
|
1539
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.5h7.5A2.5 2.5 0 0 1 21 10v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17z" /></svg>' +
|
|
1540
|
+
"</span>" +
|
|
1541
|
+
`<span class="workspace-info"><strong>${escapeHtml(folder.name || workspaceName(folder.path))}</strong>` +
|
|
1542
|
+
`<small>${escapeHtml(shortenPath(folder.path, 3))}</small></span>` +
|
|
1543
|
+
`<span class="workspace-count">${escapeHtml(tr("{n} conversations", { n: count }))}</span>`;
|
|
1544
|
+
item.addEventListener("click", async () => {
|
|
1545
|
+
if (!folder.exists) {
|
|
1546
|
+
el.workspaceFeedback.textContent = tr("This folder no longer exists.");
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
el.workspaceDialog.close();
|
|
1550
|
+
await openWorkspace(folder.path, { record: false });
|
|
1551
|
+
});
|
|
1552
|
+
el.workspaceList.appendChild(item);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
async function openWorkspaceDialog() {
|
|
1557
|
+
el.workspaceFeedback.textContent = "";
|
|
1558
|
+
if (!el.workspaceDialog.open) el.workspaceDialog.showModal();
|
|
1559
|
+
renderWorkspaceList();
|
|
1560
|
+
try {
|
|
1561
|
+
const response = await apiFetch("/api/workspaces", { cache: "no-store" });
|
|
1562
|
+
const data = await response.json();
|
|
1563
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
1564
|
+
state.workspaces = data;
|
|
1565
|
+
renderWorkspaceList();
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
el.workspaceFeedback.textContent = tr("Could not open this folder: {error}", { error: error.message });
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function renderNewChatRecents() {
|
|
1572
|
+
if (!el.newChatRecents) return;
|
|
1573
|
+
el.newChatRecents.innerHTML = "";
|
|
1574
|
+
const folders = (state.workspaces?.workspaces ?? []).filter((folder) => folder.exists !== false).slice(0, 8);
|
|
1575
|
+
if (!folders.length) {
|
|
1576
|
+
const empty = document.createElement("span");
|
|
1577
|
+
empty.className = "recents-empty";
|
|
1578
|
+
empty.textContent = tr("No recent folders.");
|
|
1579
|
+
el.newChatRecents.appendChild(empty);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
for (const folder of folders) {
|
|
1583
|
+
const chip = document.createElement("button");
|
|
1584
|
+
chip.className = "path-chip";
|
|
1585
|
+
chip.type = "button";
|
|
1586
|
+
chip.title = folder.path;
|
|
1587
|
+
chip.textContent = workspaceName(folder.path) || folder.path;
|
|
1588
|
+
if (el.newChatCwd.value.trim() && pathKey(el.newChatCwd.value) === pathKey(folder.path)) {
|
|
1589
|
+
chip.classList.add("selected");
|
|
1590
|
+
}
|
|
1591
|
+
chip.addEventListener("click", () => {
|
|
1592
|
+
el.newChatCwd.value = folder.path;
|
|
1593
|
+
renderNewChatRecents();
|
|
1594
|
+
});
|
|
1595
|
+
el.newChatRecents.appendChild(chip);
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function fillSelect(select, entries, selected) {
|
|
1600
|
+
select.replaceChildren();
|
|
1601
|
+
for (const entry of entries) {
|
|
1602
|
+
const option = document.createElement("option");
|
|
1603
|
+
option.value = entry.value;
|
|
1604
|
+
option.textContent = entry.label;
|
|
1605
|
+
select.appendChild(option);
|
|
1606
|
+
}
|
|
1607
|
+
select.value = entries.some((entry) => entry.value === selected) ? selected : entries[0]?.value ?? "";
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
function fillNewChatModels(catalog, selectedModel) {
|
|
1611
|
+
const provider = catalog.providers.find((entry) => entry.name === el.newChatProvider.value) ?? catalog.providers[0];
|
|
1612
|
+
const models = provider?.models ?? [];
|
|
1613
|
+
fillSelect(el.newChatModel, models.map((model) => ({
|
|
1614
|
+
value: model.name,
|
|
1615
|
+
label: model.name + (model.configured ? "" : ` ${tr("(API key missing)")}`),
|
|
1616
|
+
})), selectedModel);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function updateNewChatNote(catalog) {
|
|
1620
|
+
const provider = catalog?.providers.find((entry) => entry.name === el.newChatProvider.value);
|
|
1621
|
+
const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
|
|
1622
|
+
if (model && !model.configured) {
|
|
1623
|
+
el.newChatNote.textContent = tr("This model has no API key configured. Add one in the terminal model screen, or choose another model.");
|
|
1624
|
+
} else {
|
|
1625
|
+
el.newChatNote.textContent = "";
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/** Relabel the open new-conversation sheet without losing its selections. */
|
|
1630
|
+
function refreshNewChatTexts() {
|
|
1631
|
+
if (!el.newChatDialog?.open) return;
|
|
1632
|
+
const catalog = preferences.catalog?.();
|
|
1633
|
+
if (!catalog) return;
|
|
1634
|
+
const provider = el.newChatProvider.value;
|
|
1635
|
+
const model = el.newChatModel.value;
|
|
1636
|
+
const intensity = el.newChatIntensity.value;
|
|
1637
|
+
const approval = el.newChatApproval.value;
|
|
1638
|
+
fillSelect(el.newChatProvider, catalog.providers.map((entry) => ({ value: entry.name, label: entry.name })), provider);
|
|
1639
|
+
fillNewChatModels(catalog, model);
|
|
1640
|
+
fillSelect(el.newChatIntensity, catalog.intensities.map((value) => ({ value, label: value })), intensity);
|
|
1641
|
+
el.newChatApproval.value = approval;
|
|
1642
|
+
updateNewChatNote(catalog);
|
|
1643
|
+
renderNewChatRecents();
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function openNewChatDialog(options = {}) {
|
|
1647
|
+
el.newChatFeedback.textContent = "";
|
|
1648
|
+
if (!el.newChatDialog.open) el.newChatDialog.showModal();
|
|
1649
|
+
let catalog;
|
|
1650
|
+
try {
|
|
1651
|
+
catalog = await preferences.load();
|
|
1652
|
+
const response = await apiFetch("/api/workspaces", { cache: "no-store" });
|
|
1653
|
+
if (response.ok) state.workspaces = await response.json();
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
el.newChatFeedback.textContent = tr(error.message);
|
|
1656
|
+
}
|
|
1657
|
+
if (!catalog) return;
|
|
1658
|
+
|
|
1659
|
+
const defaults = catalog.selection;
|
|
1660
|
+
fillSelect(el.newChatProvider, catalog.providers.map((provider) => ({ value: provider.name, label: provider.name })), defaults.provider);
|
|
1661
|
+
fillNewChatModels(catalog, defaults.model);
|
|
1662
|
+
fillSelect(el.newChatIntensity, catalog.intensities.map((value) => ({ value, label: value })), defaults.intensity);
|
|
1663
|
+
el.newChatApproval.value = catalog.settings.approval_mode === "auto" ? "auto" : "request";
|
|
1664
|
+
|
|
1665
|
+
const start = String(options.cwd || selectionCwd() || state.workspace?.path || state.workspaces?.cwd || "").trim();
|
|
1666
|
+
el.newChatCwd.value = start;
|
|
1667
|
+
renderNewChatRecents();
|
|
1668
|
+
updateNewChatNote(catalog);
|
|
1669
|
+
if (typeof el.newChatCwd.focus === "function") el.newChatCwd.focus();
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
async function submitNewChat() {
|
|
1673
|
+
if (el.newChatStart.disabled) return;
|
|
1674
|
+
const cwd = el.newChatCwd.value.trim();
|
|
1675
|
+
if (!cwd) {
|
|
1676
|
+
el.newChatFeedback.textContent = tr("working directory is required");
|
|
1677
|
+
el.newChatCwd.focus();
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
const selection = {
|
|
1681
|
+
provider: el.newChatProvider.value,
|
|
1682
|
+
model: el.newChatModel.value,
|
|
1683
|
+
intensity: el.newChatIntensity.value,
|
|
1684
|
+
};
|
|
1685
|
+
el.newChatStart.disabled = true;
|
|
1686
|
+
el.newChatStart.textContent = tr("Creating…");
|
|
1687
|
+
el.newChatFeedback.textContent = "";
|
|
1688
|
+
try {
|
|
1689
|
+
const created = await createConversation(cwd, selection, el.newChatApproval.value);
|
|
1690
|
+
el.newChatDialog.close();
|
|
1691
|
+
await selectLive(created.key);
|
|
1692
|
+
setConversationView(true);
|
|
1693
|
+
el.input.focus();
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
el.newChatFeedback.textContent = tr("Could not start the conversation: {error}", { error: error.message });
|
|
1696
|
+
} finally {
|
|
1697
|
+
el.newChatStart.disabled = false;
|
|
1698
|
+
el.newChatStart.textContent = tr("Start conversation");
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/** Create a browser-owned conversation in `cwd` and reveal its folder. */
|
|
1703
|
+
async function createConversation(cwd, selection, approvalMode) {
|
|
1704
|
+
const payload = { cwd };
|
|
1705
|
+
if (selection && selection.provider && selection.model) payload.selection = selection;
|
|
1706
|
+
if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
|
|
1707
|
+
const response = await apiFetch("/api/conversations", {
|
|
1708
|
+
method: "POST",
|
|
1709
|
+
headers: { "Content-Type": "application/json" },
|
|
1710
|
+
body: JSON.stringify(payload),
|
|
1711
|
+
});
|
|
1712
|
+
const data = await response.json().catch(() => ({}));
|
|
1713
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
1714
|
+
setWorkspace({ path: data.cwd || cwd });
|
|
1715
|
+
await loadOverview({ silent: true });
|
|
1716
|
+
return data;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// ------------------------------------------------------------- folder picker
|
|
1720
|
+
|
|
1721
|
+
function renderFolder() {
|
|
1722
|
+
if (!el.folderList) return;
|
|
1723
|
+
const folder = state.folder;
|
|
1724
|
+
el.folderPath.value = folder.path || "";
|
|
1725
|
+
el.folderUp.disabled = !folder.parent;
|
|
1726
|
+
el.folderUse.disabled = !folder.path || folder.loading;
|
|
1727
|
+
|
|
1728
|
+
el.folderRoots.innerHTML = "";
|
|
1729
|
+
for (const root of folder.roots ?? []) {
|
|
1730
|
+
const button = document.createElement("button");
|
|
1731
|
+
button.className = "root-chip";
|
|
1732
|
+
button.type = "button";
|
|
1733
|
+
button.textContent = root;
|
|
1734
|
+
button.addEventListener("click", () => loadFolder(root));
|
|
1735
|
+
el.folderRoots.appendChild(button);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
el.folderList.innerHTML = "";
|
|
1739
|
+
const entries = folder.entries ?? [];
|
|
1740
|
+
if (!entries.length) {
|
|
1741
|
+
const empty = document.createElement("div");
|
|
1742
|
+
empty.className = "empty-side";
|
|
1743
|
+
empty.textContent = folder.loading ? tr("Loading folders…") : tr("No subfolders.");
|
|
1744
|
+
el.folderList.appendChild(empty);
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
for (const entry of entries) {
|
|
1748
|
+
const item = document.createElement("button");
|
|
1749
|
+
item.className = "folder-item";
|
|
1750
|
+
item.type = "button";
|
|
1751
|
+
item.title = entry.path;
|
|
1752
|
+
item.innerHTML =
|
|
1753
|
+
'<span class="folder-item-icon" aria-hidden="true">' +
|
|
1754
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5A2.5 2.5 0 0 1 5.5 5H9l2 2.5h7.5A2.5 2.5 0 0 1 21 10v7a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17z" /></svg>' +
|
|
1755
|
+
`</span><span>${escapeHtml(entry.name)}</span>`;
|
|
1756
|
+
item.addEventListener("click", () => loadFolder(entry.path));
|
|
1757
|
+
if (typeof item.addEventListener === "function") {
|
|
1758
|
+
item.addEventListener("dblclick", () => useCurrentFolder());
|
|
1759
|
+
}
|
|
1760
|
+
el.folderList.appendChild(item);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
let folderLoadSeq = 0;
|
|
1765
|
+
|
|
1766
|
+
async function loadFolder(path) {
|
|
1767
|
+
const folder = state.folder;
|
|
1768
|
+
const request = ++folderLoadSeq;
|
|
1769
|
+
folder.loading = true;
|
|
1770
|
+
el.folderFeedback.textContent = tr("Loading folders…");
|
|
1771
|
+
el.folderUse.disabled = true;
|
|
1772
|
+
try {
|
|
1773
|
+
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
|
1774
|
+
const response = await apiFetch(`/api/fs${query}`, { cache: "no-store" });
|
|
1775
|
+
const data = await response.json().catch(() => ({}));
|
|
1776
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
1777
|
+
// A slower, older answer must not replace the folder the user is on now.
|
|
1778
|
+
if (request !== folderLoadSeq) return;
|
|
1779
|
+
state.folder = {
|
|
1780
|
+
...folder,
|
|
1781
|
+
loading: false,
|
|
1782
|
+
path: data.path,
|
|
1783
|
+
parent: data.parent,
|
|
1784
|
+
roots: data.roots ?? [],
|
|
1785
|
+
entries: data.entries ?? [],
|
|
1786
|
+
};
|
|
1787
|
+
el.folderFeedback.textContent = "";
|
|
1788
|
+
renderFolder();
|
|
1789
|
+
if (typeof el.folderPath.focus === "function") el.folderPath.focus();
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
if (request !== folderLoadSeq) return;
|
|
1792
|
+
folder.loading = false;
|
|
1793
|
+
el.folderFeedback.textContent = tr("Could not open this folder: {error}", { error: error.message });
|
|
1794
|
+
el.folderUse.disabled = !state.folder.path;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
function openFolderDialog({ start = "", onPick = null } = {}) {
|
|
1799
|
+
state.folder = {
|
|
1800
|
+
path: "",
|
|
1801
|
+
parent: null,
|
|
1802
|
+
roots: [],
|
|
1803
|
+
entries: [],
|
|
1804
|
+
loading: false,
|
|
1805
|
+
onPick,
|
|
1806
|
+
};
|
|
1807
|
+
el.folderNew.classList.add("hidden");
|
|
1808
|
+
el.folderNewName.value = "";
|
|
1809
|
+
el.folderFeedback.textContent = "";
|
|
1810
|
+
if (!el.folderDialog.open) el.folderDialog.showModal();
|
|
1811
|
+
renderFolder();
|
|
1812
|
+
const from = String(start || state.workspace?.path || state.workspaces?.cwd || "").trim();
|
|
1813
|
+
void loadFolder(from);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
function useCurrentFolder() {
|
|
1817
|
+
const path = state.folder.path;
|
|
1818
|
+
if (!path) return;
|
|
1819
|
+
const onPick = state.folder.onPick;
|
|
1820
|
+
el.folderDialog.close();
|
|
1821
|
+
if (typeof onPick === "function") onPick(path);
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
async function createFolderFromDialog() {
|
|
1825
|
+
const name = el.folderNewName.value.trim();
|
|
1826
|
+
if (!name) {
|
|
1827
|
+
el.folderFeedback.textContent = tr("Enter a folder name.");
|
|
1828
|
+
el.folderNewName.focus();
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (/[\\/]/.test(name)) {
|
|
1832
|
+
el.folderFeedback.textContent = tr("folder name cannot contain a path separator");
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
const target = joinPath(state.folder.path, name);
|
|
1836
|
+
el.folderNewCreate.disabled = true;
|
|
1837
|
+
try {
|
|
1838
|
+
const response = await apiFetch("/api/fs/mkdir", {
|
|
1839
|
+
method: "POST",
|
|
1840
|
+
headers: { "Content-Type": "application/json" },
|
|
1841
|
+
body: JSON.stringify({ path: target }),
|
|
1842
|
+
});
|
|
1843
|
+
const data = await response.json().catch(() => ({}));
|
|
1844
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
1845
|
+
el.folderNew.classList.add("hidden");
|
|
1846
|
+
el.folderNewName.value = "";
|
|
1847
|
+
await loadFolder(data.path || target);
|
|
1848
|
+
el.folderFeedback.textContent = tr("Folder created.");
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
el.folderFeedback.textContent = apiError({ error: error.message }, error.message);
|
|
1851
|
+
} finally {
|
|
1852
|
+
el.folderNewCreate.disabled = false;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// -------------------------------------------------------------- plan / usage
|
|
1857
|
+
|
|
1858
|
+
function planSignature(plan) {
|
|
1859
|
+
if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return "";
|
|
1860
|
+
return `${plan.goal ?? ""}|${plan.steps.map((step) => step.title ?? "").join("|")}`;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
function maybeOpenPlan(plan) {
|
|
1864
|
+
const signature = planSignature(plan);
|
|
1865
|
+
if (signature && signature !== state.planSeen) {
|
|
1866
|
+
state.planSeen = signature;
|
|
1867
|
+
state.planOpen = true;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function planToggleButton() {
|
|
1872
|
+
const button = document.createElement("button");
|
|
1873
|
+
button.className = "icon-btn plan-toggle";
|
|
1874
|
+
button.type = "button";
|
|
1875
|
+
button.setAttribute("aria-expanded", String(state.planOpen));
|
|
1876
|
+
button.title = state.planOpen ? tr("Hide plan") : tr("Show plan");
|
|
1877
|
+
button.setAttribute("aria-label", button.title);
|
|
1878
|
+
button.innerHTML =
|
|
1879
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M12 11v5M12 8h.01" /></svg>';
|
|
1880
|
+
button.addEventListener("click", () => {
|
|
1881
|
+
state.planOpen = !state.planOpen;
|
|
1882
|
+
button.classList.toggle("active", state.planOpen);
|
|
1883
|
+
button.setAttribute("aria-expanded", String(state.planOpen));
|
|
1884
|
+
button.title = state.planOpen ? tr("Hide plan") : tr("Show plan");
|
|
1885
|
+
renderPlanCard(state.snapshot ?? state.fileData ?? null);
|
|
1886
|
+
});
|
|
1887
|
+
button.classList.toggle("active", state.planOpen);
|
|
1888
|
+
return button;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
function planStatusLabel(status) {
|
|
1892
|
+
if (status === "done") return tr("Done");
|
|
1893
|
+
if (status === "active") return tr("Active");
|
|
1894
|
+
if (status === "failed") return tr("Failed");
|
|
1895
|
+
return tr("Pending");
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function renderPlanCard(source) {
|
|
1899
|
+
if (!el.planCard) return;
|
|
1900
|
+
const plan = source && source.plan && Array.isArray(source.plan.steps) && source.plan.steps.length ? source.plan : null;
|
|
1901
|
+
const tokens = source && source.tokens ? source.tokens : null;
|
|
1902
|
+
const hasUsage = tokens && (Number(tokens.prompt) > 0 || Number(tokens.completion) > 0);
|
|
1903
|
+
if (!state.planOpen || (!plan && !hasUsage)) {
|
|
1904
|
+
el.planCard.classList.add("hidden");
|
|
1905
|
+
el.planCard.replaceChildren();
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
const card = document.createElement("div");
|
|
1910
|
+
card.className = "plan-card-inner";
|
|
1911
|
+
const head = document.createElement("div");
|
|
1912
|
+
head.className = "plan-card-head";
|
|
1913
|
+
const title = document.createElement("strong");
|
|
1914
|
+
title.textContent = plan ? tr("Plan") : tr("Usage");
|
|
1915
|
+
const close = document.createElement("button");
|
|
1916
|
+
close.className = "icon-btn";
|
|
1917
|
+
close.type = "button";
|
|
1918
|
+
close.setAttribute("aria-label", tr("Close"));
|
|
1919
|
+
close.innerHTML =
|
|
1920
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>';
|
|
1921
|
+
close.addEventListener("click", () => {
|
|
1922
|
+
state.planOpen = false;
|
|
1923
|
+
renderPlanCard(source);
|
|
1924
|
+
});
|
|
1925
|
+
head.append(title, close);
|
|
1926
|
+
card.appendChild(head);
|
|
1927
|
+
|
|
1928
|
+
if (plan) {
|
|
1929
|
+
if (plan.goal) {
|
|
1930
|
+
const goal = document.createElement("div");
|
|
1931
|
+
goal.className = "plan-goal";
|
|
1932
|
+
goal.textContent = plan.goal;
|
|
1933
|
+
card.appendChild(goal);
|
|
1934
|
+
}
|
|
1935
|
+
const steps = document.createElement("div");
|
|
1936
|
+
steps.className = "plan-steps";
|
|
1937
|
+
let done = 0;
|
|
1938
|
+
for (const step of plan.steps) {
|
|
1939
|
+
const status = ["done", "active", "failed", "pending"].includes(step.status) ? step.status : "pending";
|
|
1940
|
+
if (status === "done") done += 1;
|
|
1941
|
+
const row = document.createElement("div");
|
|
1942
|
+
row.className = `plan-step ${status}`;
|
|
1943
|
+
const node = document.createElement("span");
|
|
1944
|
+
node.className = "plan-node";
|
|
1945
|
+
node.setAttribute("aria-hidden", "true");
|
|
1946
|
+
const body = document.createElement("div");
|
|
1947
|
+
body.className = "plan-step-body";
|
|
1948
|
+
const stepTitle = document.createElement("strong");
|
|
1949
|
+
stepTitle.textContent = step.title || "";
|
|
1950
|
+
const stateLabel = document.createElement("span");
|
|
1951
|
+
stateLabel.className = "plan-step-state";
|
|
1952
|
+
stateLabel.textContent = planStatusLabel(status);
|
|
1953
|
+
const titleRow = document.createElement("div");
|
|
1954
|
+
titleRow.className = "plan-step-title";
|
|
1955
|
+
titleRow.append(stepTitle, stateLabel);
|
|
1956
|
+
body.appendChild(titleRow);
|
|
1957
|
+
if (step.detail) {
|
|
1958
|
+
const detail = document.createElement("div");
|
|
1959
|
+
detail.className = "plan-step-detail";
|
|
1960
|
+
detail.textContent = step.detail;
|
|
1961
|
+
body.appendChild(detail);
|
|
1962
|
+
}
|
|
1963
|
+
row.append(node, body);
|
|
1964
|
+
steps.appendChild(row);
|
|
1965
|
+
}
|
|
1966
|
+
card.appendChild(steps);
|
|
1967
|
+
const meta = document.createElement("div");
|
|
1968
|
+
meta.className = "plan-meta";
|
|
1969
|
+
const stopped = plan.stopped ? ` · ${tr("Stopped")}` : "";
|
|
1970
|
+
meta.textContent = tr("{done} of {total} steps done", { done, total: plan.steps.length }) + stopped;
|
|
1971
|
+
card.appendChild(meta);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
if (hasUsage) {
|
|
1975
|
+
const usage = document.createElement("div");
|
|
1976
|
+
usage.className = "plan-usage";
|
|
1977
|
+
const rows = [
|
|
1978
|
+
[tr("Prompt"), Number(tokens.prompt)],
|
|
1979
|
+
[tr("Completion"), Number(tokens.completion)],
|
|
1980
|
+
[tr("Total"), Number(tokens.prompt) + Number(tokens.completion)],
|
|
1981
|
+
];
|
|
1982
|
+
for (const [label, value] of rows) {
|
|
1983
|
+
const row = document.createElement("div");
|
|
1984
|
+
row.className = "plan-usage-row";
|
|
1985
|
+
const name = document.createElement("span");
|
|
1986
|
+
name.textContent = label;
|
|
1987
|
+
const count = document.createElement("strong");
|
|
1988
|
+
count.textContent = value.toLocaleString(localeTag());
|
|
1989
|
+
row.append(name, count);
|
|
1990
|
+
usage.appendChild(row);
|
|
1991
|
+
}
|
|
1992
|
+
card.appendChild(usage);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
el.planCard.replaceChildren(card);
|
|
1996
|
+
el.planCard.classList.remove("hidden");
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// ------------------------------------------------------------------- actions
|
|
2000
|
+
|
|
2001
|
+
async function clearConversation(key) {
|
|
2002
|
+
try {
|
|
2003
|
+
const response = await apiFetch(`/api/live/${encodeURIComponent(key)}/clear`, { method: "POST" });
|
|
2004
|
+
const data = await response.json().catch(() => ({}));
|
|
2005
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2006
|
+
if (state.selection?.kind === "live" && state.selection.key === key) {
|
|
2007
|
+
state.snapshot = data.snapshot ?? state.snapshot;
|
|
2008
|
+
// The cleared conversation has no transcript file until its next message.
|
|
2009
|
+
state.lastSessionId = null;
|
|
2010
|
+
state.planOpen = false;
|
|
2011
|
+
state.planSeen = "";
|
|
2012
|
+
if (state.snapshot) renderLive();
|
|
2013
|
+
if (state.snapshot) renderPlanCard(null);
|
|
2014
|
+
el.sub.textContent = tr("Conversation cleared.");
|
|
2015
|
+
}
|
|
2016
|
+
} catch (error) {
|
|
2017
|
+
el.sub.textContent = tr("Could not clear this conversation: {error}", { error: error.message });
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
async function deleteConversation(id, button) {
|
|
2022
|
+
if (!confirmAction(tr("Delete this conversation? Its saved history will be removed."))) return;
|
|
2023
|
+
if (button) button.disabled = true;
|
|
2024
|
+
try {
|
|
2025
|
+
const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/delete`, { method: "POST" });
|
|
2026
|
+
const data = await response.json().catch(() => ({}));
|
|
2027
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2028
|
+
showHome();
|
|
2029
|
+
await loadOverview();
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
if (button) button.disabled = false;
|
|
2032
|
+
el.sub.textContent = tr("Could not delete this conversation: {error}", { error: error.message });
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// -------------------------------------------------------------- other agents
|
|
2037
|
+
|
|
2038
|
+
const OTHER_AGENTS = [
|
|
2039
|
+
{ id: "claude", label: "Claude Code" },
|
|
2040
|
+
{ id: "codex", label: "Codex" },
|
|
2041
|
+
{ id: "opencode", label: "OpenCode" },
|
|
2042
|
+
];
|
|
2043
|
+
|
|
2044
|
+
function agentLabel(agent) {
|
|
2045
|
+
return OTHER_AGENTS.find((entry) => entry.id === agent)?.label ?? agent;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/** Working directory the other-agent lists are scoped to. */
|
|
2049
|
+
async function agentsScopeCwd() {
|
|
2050
|
+
if (!state.workspaces) {
|
|
2051
|
+
try {
|
|
2052
|
+
const response = await apiFetch("/api/workspaces", { cache: "no-store" });
|
|
2053
|
+
if (response.ok) state.workspaces = await response.json();
|
|
2054
|
+
} catch {
|
|
2055
|
+
// The dialog can still show a readable error below.
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
return state.workspace?.path || selectionCwd() || state.agentData?.cwd || state.workspaces?.cwd || "";
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function renderAgentList(groups) {
|
|
2062
|
+
el.agentsList.innerHTML = "";
|
|
2063
|
+
const total = groups.reduce((count, group) => count + group.sessions.length, 0);
|
|
2064
|
+
if (total === 0) {
|
|
2065
|
+
const empty = document.createElement("div");
|
|
2066
|
+
empty.className = "empty-side";
|
|
2067
|
+
empty.textContent = tr("No saved sessions from other agents in this folder.");
|
|
2068
|
+
el.agentsList.appendChild(empty);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
for (const group of groups) {
|
|
2072
|
+
if (!group.sessions.length) continue;
|
|
2073
|
+
const section = document.createElement("section");
|
|
2074
|
+
section.className = "agent-section";
|
|
2075
|
+
const title = document.createElement("h3");
|
|
2076
|
+
title.textContent = group.label;
|
|
2077
|
+
section.appendChild(title);
|
|
2078
|
+
for (const session of group.sessions) {
|
|
2079
|
+
const item = document.createElement("button");
|
|
2080
|
+
item.className = "agent-session";
|
|
2081
|
+
item.type = "button";
|
|
2082
|
+
item.title = session.title || session.id;
|
|
2083
|
+
item.innerHTML =
|
|
2084
|
+
'<span class="agent-session-body">' +
|
|
2085
|
+
`<span class="agent-session-title">${escapeHtml(session.title || tr("Conversation"))}</span>` +
|
|
2086
|
+
`<span class="agent-session-meta">${escapeHtml([session.model, formatTime(session.updated_at)].filter(Boolean).join(" · "))}</span>` +
|
|
2087
|
+
"</span>";
|
|
2088
|
+
item.addEventListener("click", () => {
|
|
2089
|
+
el.agentsDialog.close();
|
|
2090
|
+
void selectAgentSession(group.id, session.id, session.title || "");
|
|
2091
|
+
});
|
|
2092
|
+
section.appendChild(item);
|
|
2093
|
+
}
|
|
2094
|
+
el.agentsList.appendChild(section);
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
async function openAgentsDialog() {
|
|
2099
|
+
el.agentsFeedback.textContent = tr("Loading…");
|
|
2100
|
+
el.agentsList.innerHTML = "";
|
|
2101
|
+
if (!el.agentsDialog.open) el.agentsDialog.showModal();
|
|
2102
|
+
const cwd = await agentsScopeCwd();
|
|
2103
|
+
el.agentsCwd.textContent = cwd;
|
|
2104
|
+
try {
|
|
2105
|
+
const groups = await Promise.all(OTHER_AGENTS.map(async (agent) => {
|
|
2106
|
+
const response = await apiFetch(`/api/agents/${agent.id}/sessions?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
|
|
2107
|
+
const data = await response.json().catch(() => ({}));
|
|
2108
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2109
|
+
return { ...agent, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
|
|
2110
|
+
}));
|
|
2111
|
+
el.agentsFeedback.textContent = "";
|
|
2112
|
+
renderAgentList(groups);
|
|
2113
|
+
} catch (error) {
|
|
2114
|
+
el.agentsFeedback.textContent = tr("Could not load other agents' history: {error}", { error: error.message });
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
function renderAgentView(data) {
|
|
2119
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
2120
|
+
const label = agentLabel(state.selection?.agent ?? data.agent ?? "");
|
|
2121
|
+
el.title.textContent = state.selection?.title || firstUserMessage(messages) || tr("Conversation");
|
|
2122
|
+
el.sub.textContent = [label, data.cwd || state.agentData?.cwd].filter(Boolean).join(" · ");
|
|
2123
|
+
renderMessageList(messages, null, { readOnly: true, active: false });
|
|
2124
|
+
el.controls.innerHTML = "";
|
|
2125
|
+
const badge = document.createElement("span");
|
|
2126
|
+
badge.className = "badge readonly";
|
|
2127
|
+
badge.textContent = tr("Read-only");
|
|
2128
|
+
el.controls.appendChild(badge);
|
|
2129
|
+
const source = document.createElement("span");
|
|
2130
|
+
source.className = "badge readonly";
|
|
2131
|
+
source.textContent = label;
|
|
2132
|
+
el.controls.appendChild(source);
|
|
2133
|
+
renderApproval(null);
|
|
2134
|
+
renderPlanCard(null);
|
|
2135
|
+
hideComposer();
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
async function selectAgentSession(agent, id, title) {
|
|
2139
|
+
closeStream();
|
|
2140
|
+
state.selection = { kind: "agent", agent, id, title };
|
|
2141
|
+
state.snapshot = null;
|
|
2142
|
+
state.fileData = null;
|
|
2143
|
+
state.agentData = null;
|
|
2144
|
+
state.planOpen = false;
|
|
2145
|
+
state.planSeen = "";
|
|
2146
|
+
preferences.clear();
|
|
2147
|
+
renderSidebar();
|
|
2148
|
+
el.controls.innerHTML = "";
|
|
2149
|
+
renderApproval(null);
|
|
2150
|
+
renderPlanCard(null);
|
|
2151
|
+
hideComposer();
|
|
2152
|
+
el.title.textContent = tr("Loading…");
|
|
2153
|
+
el.sub.textContent = title || "";
|
|
2154
|
+
resetMessagePane(tr("Loading…"));
|
|
2155
|
+
setConversationView(true);
|
|
2156
|
+
try {
|
|
2157
|
+
const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}`, { cache: "no-store" });
|
|
2158
|
+
const data = await response.json().catch(() => ({}));
|
|
2159
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2160
|
+
state.agentData = { ...data, agent, cwd: state.agentData?.cwd || state.workspace?.path || "" };
|
|
2161
|
+
renderAgentView(state.agentData);
|
|
2162
|
+
} catch (error) {
|
|
2163
|
+
el.title.textContent = title || tr("Conversation");
|
|
2164
|
+
el.sub.textContent = tr("Could not load this session: {error}", { error: error.message });
|
|
2165
|
+
resetMessagePane(tr("Could not load this session: {error}", { error: error.message }));
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// ------------------------------------------------------------------ language
|
|
2170
|
+
|
|
2171
|
+
function syncLanguageSelects(language) {
|
|
2172
|
+
if (el.languageSelect) el.languageSelect.value = language;
|
|
2173
|
+
const settingsLanguage = document.getElementById("settings-language");
|
|
2174
|
+
if (settingsLanguage) settingsLanguage.value = language;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
async function changeInterfaceLanguage(language, { persist = true } = {}) {
|
|
2178
|
+
const normalized = String(language ?? "").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
2179
|
+
setUiLanguage(normalized);
|
|
2180
|
+
writeStorage("neutrino.language", normalized);
|
|
2181
|
+
applyStaticTranslations();
|
|
2182
|
+
syncLanguageSelects(normalized);
|
|
2183
|
+
if (state.status) setStatus(state.status.key, state.status.kind);
|
|
2184
|
+
preferences.refreshTexts?.();
|
|
2185
|
+
refreshNewChatTexts();
|
|
2186
|
+
renderWorkspaceChrome();
|
|
2187
|
+
renderSidebar();
|
|
2188
|
+
const kind = state.selection?.kind;
|
|
2189
|
+
if (kind === "live" && state.snapshot) renderLive();
|
|
2190
|
+
else if (kind === "file" && state.fileData) renderFile(state.fileData);
|
|
2191
|
+
else if (kind === "agent" && state.agentData) renderAgentView(state.agentData);
|
|
2192
|
+
else if (kind === "new" && state.selection.cwd) renderWorkspaceLanding(state.selection.cwd);
|
|
2193
|
+
else if (!state.selection) renderHomeEmpty();
|
|
2194
|
+
if (kind === "live") setComposerMode("live");
|
|
2195
|
+
else if (kind === "file") setComposerMode("resume");
|
|
2196
|
+
else if (kind === "new") setComposerMode("new");
|
|
2197
|
+
else if (!state.selection) hideComposer();
|
|
2198
|
+
if (persist) {
|
|
2199
|
+
try {
|
|
2200
|
+
await apiFetch("/api/settings", {
|
|
2201
|
+
method: "POST",
|
|
2202
|
+
headers: { "Content-Type": "application/json" },
|
|
2203
|
+
body: JSON.stringify({ settings: { language: normalized } }),
|
|
2204
|
+
});
|
|
2205
|
+
} catch (error) {
|
|
2206
|
+
console.error(error);
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function initializeLanguage() {
|
|
2212
|
+
const stored = readStorage("neutrino.language");
|
|
2213
|
+
if (stored) setUiLanguage(stored);
|
|
2214
|
+
applyStaticTranslations();
|
|
2215
|
+
syncLanguageSelects(uiLanguage());
|
|
2216
|
+
if (state.status) setStatus(state.status.key, state.status.kind);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
1131
2219
|
// --------------------------------------------------------------------- init
|
|
1132
2220
|
|
|
1133
2221
|
el.refresh.addEventListener("click", () => loadOverview());
|
|
@@ -1141,8 +2229,105 @@ el.input.addEventListener("keydown", (event) => {
|
|
|
1141
2229
|
}
|
|
1142
2230
|
});
|
|
1143
2231
|
|
|
2232
|
+
el.newChat.addEventListener("click", () => openNewChatDialog());
|
|
2233
|
+
if (el.emptyNewChat) el.emptyNewChat.addEventListener("click", () => openNewChatDialog());
|
|
2234
|
+
el.workspaceButton.addEventListener("click", openWorkspaceDialog);
|
|
2235
|
+
el.workspaceClose.addEventListener("click", () => el.workspaceDialog.close());
|
|
2236
|
+
el.workspaceAll.addEventListener("click", () => {
|
|
2237
|
+
el.workspaceDialog.close();
|
|
2238
|
+
setWorkspace(null);
|
|
2239
|
+
showHome();
|
|
2240
|
+
});
|
|
2241
|
+
el.workspaceAdd.addEventListener("click", () => {
|
|
2242
|
+
el.workspaceDialog.close();
|
|
2243
|
+
openFolderDialog({ start: state.workspace?.path || state.workspaces?.cwd || "", onPick: (path) => openWorkspace(path) });
|
|
2244
|
+
});
|
|
2245
|
+
el.agentsOpen.addEventListener("click", () => openAgentsDialog());
|
|
2246
|
+
el.agentsClose.addEventListener("click", () => el.agentsDialog.close());
|
|
2247
|
+
el.agentsDialog.addEventListener("cancel", (event) => {
|
|
2248
|
+
event.preventDefault();
|
|
2249
|
+
el.agentsDialog.close();
|
|
2250
|
+
});
|
|
2251
|
+
el.languageSelect?.addEventListener("change", () => changeInterfaceLanguage(el.languageSelect.value));
|
|
2252
|
+
|
|
2253
|
+
el.newChatClose.addEventListener("click", () => el.newChatDialog.close());
|
|
2254
|
+
el.newChatCancel.addEventListener("click", () => el.newChatDialog.close());
|
|
2255
|
+
el.newChatDialog.addEventListener("cancel", (event) => {
|
|
2256
|
+
event.preventDefault();
|
|
2257
|
+
el.newChatDialog.close();
|
|
2258
|
+
});
|
|
2259
|
+
el.newChatForm.addEventListener("submit", (event) => {
|
|
2260
|
+
event.preventDefault();
|
|
2261
|
+
submitNewChat();
|
|
2262
|
+
});
|
|
2263
|
+
el.newChatBrowse.addEventListener("click", () => {
|
|
2264
|
+
openFolderDialog({
|
|
2265
|
+
start: el.newChatCwd.value.trim() || state.workspace?.path || "",
|
|
2266
|
+
onPick: (path) => {
|
|
2267
|
+
el.newChatCwd.value = path;
|
|
2268
|
+
renderNewChatRecents();
|
|
2269
|
+
},
|
|
2270
|
+
});
|
|
2271
|
+
});
|
|
2272
|
+
el.newChatCwd.addEventListener("input", renderNewChatRecents);
|
|
2273
|
+
el.newChatProvider.addEventListener("change", () => {
|
|
2274
|
+
const catalog = preferences.catalog?.();
|
|
2275
|
+
if (catalog) {
|
|
2276
|
+
fillNewChatModels(catalog, "");
|
|
2277
|
+
updateNewChatNote(catalog);
|
|
2278
|
+
}
|
|
2279
|
+
});
|
|
2280
|
+
el.newChatModel.addEventListener("change", () => {
|
|
2281
|
+
const catalog = preferences.catalog?.();
|
|
2282
|
+
if (catalog) updateNewChatNote(catalog);
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
el.folderClose.addEventListener("click", () => el.folderDialog.close());
|
|
2286
|
+
el.folderCancel.addEventListener("click", () => el.folderDialog.close());
|
|
2287
|
+
el.folderUse.addEventListener("click", useCurrentFolder);
|
|
2288
|
+
el.folderUp.addEventListener("click", () => {
|
|
2289
|
+
if (state.folder.parent) void loadFolder(state.folder.parent);
|
|
2290
|
+
});
|
|
2291
|
+
el.folderGo.addEventListener("click", () => void loadFolder(el.folderPath.value.trim()));
|
|
2292
|
+
el.folderPath.addEventListener("keydown", (event) => {
|
|
2293
|
+
if (event.key === "Enter") {
|
|
2294
|
+
event.preventDefault();
|
|
2295
|
+
void loadFolder(el.folderPath.value.trim());
|
|
2296
|
+
}
|
|
2297
|
+
});
|
|
2298
|
+
el.folderMkdir.addEventListener("click", () => {
|
|
2299
|
+
el.folderNew.classList.remove("hidden");
|
|
2300
|
+
el.folderFeedback.textContent = "";
|
|
2301
|
+
el.folderNewName.value = "";
|
|
2302
|
+
el.folderNewName.focus();
|
|
2303
|
+
});
|
|
2304
|
+
el.folderNewCreate.addEventListener("click", () => void createFolderFromDialog());
|
|
2305
|
+
el.folderNewCancel.addEventListener("click", () => el.folderNew.classList.add("hidden"));
|
|
2306
|
+
el.folderNewName.addEventListener("keydown", (event) => {
|
|
2307
|
+
if (event.key === "Enter") {
|
|
2308
|
+
event.preventDefault();
|
|
2309
|
+
void createFolderFromDialog();
|
|
2310
|
+
}
|
|
2311
|
+
if (event.key === "Escape") el.folderNew.classList.add("hidden");
|
|
2312
|
+
});
|
|
2313
|
+
el.folderDialog.addEventListener("cancel", (event) => {
|
|
2314
|
+
event.preventDefault();
|
|
2315
|
+
el.folderDialog.close();
|
|
2316
|
+
});
|
|
2317
|
+
|
|
1144
2318
|
window.addEventListener("beforeunload", closeStream);
|
|
1145
2319
|
|
|
1146
|
-
|
|
1147
|
-
|
|
2320
|
+
initializeLanguage();
|
|
2321
|
+
preferences.load()
|
|
2322
|
+
.then((config) => {
|
|
2323
|
+
const language = config?.settings?.language;
|
|
2324
|
+
if (language && language !== uiLanguage()) return changeInterfaceLanguage(language, { persist: false });
|
|
2325
|
+
return undefined;
|
|
2326
|
+
})
|
|
2327
|
+
.catch(error => console.error(error));
|
|
2328
|
+
loadOverview().then(() => {
|
|
2329
|
+
// Reopen the folder the user had open last time, without stealing a click
|
|
2330
|
+
// that landed on a saved conversation while the overview was loading.
|
|
2331
|
+
if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
|
|
2332
|
+
});
|
|
1148
2333
|
setInterval(() => loadOverview({ silent: true }), REFRESH_MS);
|