@xingjisoft/neutrino 0.2.1 → 0.4.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 +312 -436
- package/assets/pro/backend_output_model.txt +5 -0
- package/assets/pro/claude-module-prompt.txt +65 -0
- package/assets/pro/common.txt +9 -0
- package/assets/pro/confirm_model.txt +5 -0
- package/assets/pro/front_output_model.txt +9 -0
- package/assets/pro/genimage_model.txt +1 -0
- package/assets/pro/main_model.txt +5 -0
- package/assets/pro/model-template.json +376 -0
- package/assets/pro/modeling_model.txt +5 -0
- package/assets/pro/plan_model.txt +8 -0
- package/assets/pro/thinking_model.txt +3 -0
- package/dist/app.js +43 -7
- package/dist/bin.js +5 -2
- package/dist/browser.js +57 -0
- package/dist/config.js +179 -26
- package/dist/index.js +1 -1
- package/dist/llm/approval.js +6 -2
- package/dist/llm/client.js +197 -28
- package/dist/llm/subagent.js +37 -1
- package/dist/pro/agents.js +104 -0
- package/dist/pro/config.js +88 -0
- package/dist/pro/preflight.js +172 -0
- package/dist/screens/chat.js +54 -38
- package/dist/screens/modelSelect.js +15 -4
- package/dist/screens/settings.js +9 -7
- package/dist/screens/start.js +48 -15
- package/dist/ui.js +58 -0
- package/dist/web/hub.js +31 -4
- package/dist/web/server.js +611 -50
- package/dist/web/session.js +320 -20
- package/dist/web/settings.js +117 -24
- package/package.json +37 -37
- package/web/README.md +158 -16
- package/web/app.js +1603 -128
- package/web/i18n.js +382 -0
- package/web/index.html +289 -37
- package/web/markdown.js +710 -0
- package/web/neutrino-pro.gif +0 -0
- package/web/neutrino.png +0 -0
- package/web/pro.js +184 -0
- package/web/settings.js +322 -193
- package/web/style.css +1378 -42
package/web/app.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createSettingsUI } from "./settings.js";
|
|
2
|
+
import { createProUI } from "./pro.js";
|
|
3
|
+
import { renderMarkdown as renderMarkdownHtml } from "./markdown.js";
|
|
2
4
|
import { turnAnswerIndex, turnFolds, turnPreview, turnRanges } from "./turns.js";
|
|
5
|
+
import { applyTranslations, getLanguage as currentLanguage, locale as currentLocale, setLanguage as setI18nLanguage, t as translate } from "./i18n.js";
|
|
3
6
|
|
|
4
7
|
// Neutrino web UI — a dependency-free single page app.
|
|
5
8
|
//
|
|
@@ -21,6 +24,74 @@ const REFRESH_MS = 4000;
|
|
|
21
24
|
// Login is remembered in an HttpOnly cookie shared by fetch and EventSource.
|
|
22
25
|
// Remove credentials left behind by older versions of the UI.
|
|
23
26
|
try { localStorage.removeItem("neutrino.token"); } catch { /* Storage may be blocked. */ }
|
|
27
|
+
|
|
28
|
+
// ------------------------------------------------------------------ pro mode
|
|
29
|
+
//
|
|
30
|
+
// The "Pro" workspace is served at /pro and owns an isolated API under
|
|
31
|
+
// /pro/api, while this page and the login cookie are shared. Detect the mode
|
|
32
|
+
// before the request helpers below so no call can leak between the workspaces.
|
|
33
|
+
const PRO_MODE = (() => {
|
|
34
|
+
try {
|
|
35
|
+
const pathname = String(location?.pathname ?? "");
|
|
36
|
+
return pathname === "/pro" || pathname.startsWith("/pro/");
|
|
37
|
+
} catch {
|
|
38
|
+
return false; // Test sandboxes and unusual embedders have no location.
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
|
|
42
|
+
const API_BASE = PRO_MODE ? "/pro/api" : "/api";
|
|
43
|
+
// Lets Pro-specific chrome (the wider sidebar needed by the longer brand name)
|
|
44
|
+
// apply before the first paint of this module.
|
|
45
|
+
if (PRO_MODE) document.body?.classList.add("pro-mode");
|
|
46
|
+
|
|
47
|
+
/** Point an /api request at the active workspace; other paths are unchanged. */
|
|
48
|
+
function apiPath(path) {
|
|
49
|
+
if (!PRO_MODE) return path;
|
|
50
|
+
const text = String(path);
|
|
51
|
+
return text.startsWith("/api/") ? `${API_BASE}${text.slice(4)}` : path;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Namespace a remembered preference so both workspaces keep their own copy. */
|
|
55
|
+
function modeStorageKey(key) {
|
|
56
|
+
return PRO_MODE ? `neutrino.pro.${key}` : key;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Translation lookup. `translate` is an ES-module import; the browser tests
|
|
60
|
+
// that evaluate this file in a sandbox strip imports, so the helper degrades to
|
|
61
|
+
// the English source text instead of throwing. Interpolation is kept here too.
|
|
62
|
+
const tr = (text, values) => {
|
|
63
|
+
if (typeof translate === "function") return translate(text, values);
|
|
64
|
+
return String(text ?? "").replace(/\{(\w+)\}/g, (match, name) =>
|
|
65
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function uiLanguage() {
|
|
69
|
+
return typeof currentLanguage === "function" ? currentLanguage() : "en";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setUiLanguage(value) {
|
|
73
|
+
return typeof setI18nLanguage === "function" ? setI18nLanguage(value) : "en";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function localeTag() {
|
|
77
|
+
return typeof currentLocale === "function" ? currentLocale() : "en-US";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyStaticTranslations() {
|
|
81
|
+
if (typeof applyTranslations === "function") applyTranslations(document);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readStorage(key) {
|
|
85
|
+
try {
|
|
86
|
+
return localStorage.getItem(key) || "";
|
|
87
|
+
} catch {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeStorage(key, value) {
|
|
93
|
+
try { localStorage.setItem(key, value); } catch { /* Private mode may block storage. */ }
|
|
94
|
+
}
|
|
24
95
|
async function loginWithPassword(password) {
|
|
25
96
|
const response = await fetch("/api/auth/login", {
|
|
26
97
|
method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
|
|
@@ -53,7 +124,7 @@ const initialLogin = (() => {
|
|
|
53
124
|
|
|
54
125
|
async function apiFetch(path, init = {}) {
|
|
55
126
|
if (!await initialLogin) throw new Error("Password login required.");
|
|
56
|
-
const response = await fetch(path, { ...init, credentials: "same-origin" });
|
|
127
|
+
const response = await fetch(apiPath(path), { ...init, credentials: "same-origin" });
|
|
57
128
|
if (response.status === 401) {
|
|
58
129
|
showGate();
|
|
59
130
|
throw new Error("Password login required.");
|
|
@@ -84,12 +155,12 @@ function showGate(message = "") {
|
|
|
84
155
|
gate.innerHTML =
|
|
85
156
|
'<form class="gate-card">' +
|
|
86
157
|
'<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>
|
|
158
|
+
'<h2 id="login-title">' + tr("Sign in") + "</h2>" +
|
|
159
|
+
"<p>" + tr("Enter your Web password to open this console. The default password is 114514.") + "</p>" +
|
|
160
|
+
'<label for="login-password">' + tr("Password") + "</label>" +
|
|
90
161
|
'<input id="login-password" name="password" type="password" autocomplete="current-password" maxlength="256" required autofocus>' +
|
|
91
162
|
'<p class="form-feedback error" role="alert"></p>' +
|
|
92
|
-
'<button class="primary" type="submit">Sign in</button>
|
|
163
|
+
'<button class="primary" type="submit">' + tr("Sign in") + "</button>" +
|
|
93
164
|
"</form>";
|
|
94
165
|
gate.querySelector(".form-feedback").textContent = message;
|
|
95
166
|
gate.addEventListener("cancel", event => event.preventDefault());
|
|
@@ -119,6 +190,11 @@ const el = {
|
|
|
119
190
|
sessions: document.getElementById("sessions"),
|
|
120
191
|
status: document.getElementById("status"),
|
|
121
192
|
refresh: document.getElementById("refresh"),
|
|
193
|
+
brandLogo: document.getElementById("brand-logo"),
|
|
194
|
+
brandName: document.getElementById("brand-name"),
|
|
195
|
+
proTip: document.getElementById("pro-tip"),
|
|
196
|
+
proTipClose: document.getElementById("pro-tip-close"),
|
|
197
|
+
proExit: document.getElementById("pro-exit"),
|
|
122
198
|
title: document.getElementById("session-title"),
|
|
123
199
|
sub: document.getElementById("session-sub"),
|
|
124
200
|
controls: document.getElementById("controls"),
|
|
@@ -127,11 +203,61 @@ const el = {
|
|
|
127
203
|
input: document.getElementById("input"),
|
|
128
204
|
send: document.getElementById("send"),
|
|
129
205
|
approval: document.getElementById("approval"),
|
|
206
|
+
emptyNewChat: document.getElementById("empty-new-chat"),
|
|
207
|
+
newChat: document.getElementById("new-chat"),
|
|
208
|
+
workspaceButton: document.getElementById("workspace-button"),
|
|
209
|
+
workspaceLabel: document.getElementById("workspace-label"),
|
|
210
|
+
languageSelect: document.getElementById("language-select"),
|
|
211
|
+
planCard: document.getElementById("plan-card"),
|
|
212
|
+
workspaceDialog: document.getElementById("workspace-dialog"),
|
|
213
|
+
workspaceList: document.getElementById("workspace-list"),
|
|
214
|
+
workspaceAll: document.getElementById("workspace-all"),
|
|
215
|
+
workspaceAdd: document.getElementById("workspace-add"),
|
|
216
|
+
workspaceClose: document.getElementById("workspace-close"),
|
|
217
|
+
workspaceFeedback: document.getElementById("workspace-feedback"),
|
|
218
|
+
newChatDialog: document.getElementById("new-chat-dialog"),
|
|
219
|
+
newChatForm: document.getElementById("new-chat-form"),
|
|
220
|
+
newChatTitle: document.getElementById("new-chat-title"),
|
|
221
|
+
newChatClose: document.getElementById("new-chat-close"),
|
|
222
|
+
newChatCancel: document.getElementById("new-chat-cancel"),
|
|
223
|
+
newChatStart: document.getElementById("new-chat-start"),
|
|
224
|
+
newChatCwd: document.getElementById("new-chat-cwd"),
|
|
225
|
+
newChatBrowse: document.getElementById("new-chat-browse"),
|
|
226
|
+
newChatRecents: document.getElementById("new-chat-recents"),
|
|
227
|
+
newChatProvider: document.getElementById("new-chat-provider"),
|
|
228
|
+
newChatModel: document.getElementById("new-chat-model"),
|
|
229
|
+
newChatIntensity: document.getElementById("new-chat-intensity"),
|
|
230
|
+
newChatApproval: document.getElementById("new-chat-approval"),
|
|
231
|
+
newChatNote: document.getElementById("new-chat-note"),
|
|
232
|
+
newChatFeedback: document.getElementById("new-chat-feedback"),
|
|
233
|
+
folderDialog: document.getElementById("folder-dialog"),
|
|
234
|
+
folderTitle: document.getElementById("folder-title"),
|
|
235
|
+
folderClose: document.getElementById("folder-close"),
|
|
236
|
+
folderCancel: document.getElementById("folder-cancel"),
|
|
237
|
+
folderUse: document.getElementById("folder-use"),
|
|
238
|
+
folderUp: document.getElementById("folder-up"),
|
|
239
|
+
folderPath: document.getElementById("folder-path"),
|
|
240
|
+
folderGo: document.getElementById("folder-go"),
|
|
241
|
+
folderRoots: document.getElementById("folder-roots"),
|
|
242
|
+
folderList: document.getElementById("folder-list"),
|
|
243
|
+
folderNew: document.getElementById("folder-new"),
|
|
244
|
+
folderNewName: document.getElementById("folder-new-name"),
|
|
245
|
+
folderNewCreate: document.getElementById("folder-new-create"),
|
|
246
|
+
folderNewCancel: document.getElementById("folder-new-cancel"),
|
|
247
|
+
folderMkdir: document.getElementById("folder-mkdir"),
|
|
248
|
+
folderFeedback: document.getElementById("folder-feedback"),
|
|
249
|
+
agentsOpen: document.getElementById("agents-open"),
|
|
250
|
+
agentsDialog: document.getElementById("agents-dialog"),
|
|
251
|
+
agentsClose: document.getElementById("agents-close"),
|
|
252
|
+
agentsList: document.getElementById("agents-list"),
|
|
253
|
+
agentsFeedback: document.getElementById("agents-feedback"),
|
|
254
|
+
agentsCwd: document.getElementById("agents-cwd"),
|
|
130
255
|
};
|
|
131
256
|
|
|
132
257
|
const state = {
|
|
133
258
|
overview: null,
|
|
134
|
-
// { kind: "live", key } | { kind: "file", id } |
|
|
259
|
+
// { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
|
|
260
|
+
// { kind: "agent", agent, id, title } | null
|
|
135
261
|
selection: null,
|
|
136
262
|
snapshot: null,
|
|
137
263
|
eventSource: null,
|
|
@@ -142,10 +268,35 @@ const state = {
|
|
|
142
268
|
// Turns the reader expanded; a finished turn is folded unless listed here.
|
|
143
269
|
openTurns: new Set(),
|
|
144
270
|
openContext: null,
|
|
271
|
+
// Last loaded saved transcript, kept so a language switch can repaint it.
|
|
272
|
+
fileData: null,
|
|
273
|
+
// Last loaded read-only transcript from another agent.
|
|
274
|
+
agentData: null,
|
|
275
|
+
// { key, path } | null — the working directory the sidebar is scoped to.
|
|
276
|
+
workspace: storedWorkspace(),
|
|
277
|
+
// Plan / usage card visibility and the plan it was opened for.
|
|
278
|
+
planOpen: false,
|
|
279
|
+
planSeen: "",
|
|
280
|
+
composerMode: "live",
|
|
281
|
+
// Folder-picker state: where it is, what to do with the picked path.
|
|
282
|
+
folder: { path: "", parent: null, roots: [], onPick: null, mode: "workspace", loading: false },
|
|
283
|
+
workspaces: null,
|
|
284
|
+
// Translation key and colour class of the sidebar status line.
|
|
285
|
+
status: { key: "connecting…", kind: "" },
|
|
145
286
|
};
|
|
146
287
|
|
|
288
|
+
function storedWorkspace() {
|
|
289
|
+
const path = readStorage(modeStorageKey("neutrino.workspace"));
|
|
290
|
+
if (!path) return null;
|
|
291
|
+
return { key: pathKey(path), path };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let proUI = null;
|
|
147
295
|
const preferences = createSettingsUI({
|
|
148
296
|
apiFetch,
|
|
297
|
+
proMode: PRO_MODE,
|
|
298
|
+
onCatalogChange: (catalog) => proUI?.load(catalog),
|
|
299
|
+
onLanguageChange: (language) => changeInterfaceLanguage(language),
|
|
149
300
|
async applyModel(selection) {
|
|
150
301
|
const current = state.selection;
|
|
151
302
|
if (!current) throw new Error("Select a conversation first.");
|
|
@@ -165,6 +316,37 @@ const preferences = createSettingsUI({
|
|
|
165
316
|
},
|
|
166
317
|
});
|
|
167
318
|
|
|
319
|
+
if (PRO_MODE && typeof createProUI === "function") {
|
|
320
|
+
proUI = createProUI({
|
|
321
|
+
async changePreset(preset) {
|
|
322
|
+
const current = state.selection;
|
|
323
|
+
if (current?.kind === "live") {
|
|
324
|
+
const response = await apiFetch(`/api/live/${encodeURIComponent(current.key)}/pro-preset`, {
|
|
325
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ preset }),
|
|
326
|
+
});
|
|
327
|
+
const body = await response.json();
|
|
328
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
329
|
+
if (state.selection?.kind === "live" && state.selection.key === current.key) {
|
|
330
|
+
state.snapshot = body.snapshot;
|
|
331
|
+
renderLive();
|
|
332
|
+
}
|
|
333
|
+
} else if (current?.kind !== "file") {
|
|
334
|
+
const response = await apiFetch("/api/settings", {
|
|
335
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify({ settings: { pro_preset: preset } }),
|
|
337
|
+
});
|
|
338
|
+
const body = await response.json();
|
|
339
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
340
|
+
await preferences.load();
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function currentProPreset() {
|
|
347
|
+
return proUI?.preset() || preferences.catalog?.()?.pro?.preset;
|
|
348
|
+
}
|
|
349
|
+
|
|
168
350
|
// ------------------------------------------------------------------- sounds
|
|
169
351
|
//
|
|
170
352
|
// A turn that ends while the tab is in the background is invisible, so finished
|
|
@@ -258,6 +440,37 @@ function shortenPath(value, depth = 2) {
|
|
|
258
440
|
return `…${separator}${parts.slice(-depth).join(separator)}`;
|
|
259
441
|
}
|
|
260
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Canonical form of a working directory, matching the server's `normalizeCwd`.
|
|
445
|
+
* It is only used to decide which sidebar group a path belongs to.
|
|
446
|
+
*/
|
|
447
|
+
function pathKey(value) {
|
|
448
|
+
let text = String(value ?? "").trim();
|
|
449
|
+
if (!text) return "";
|
|
450
|
+
text = text.replace(/^\\\\\?\\/, "").replace(/\\/g, "/").replace(/\/{2,}/g, "/");
|
|
451
|
+
const wsl = /^\/(?:mnt|cygdrive)\/([a-zA-Z])(\/.*)?$/.exec(text);
|
|
452
|
+
const win = /^([a-zA-Z]):(\/.*)?$/.exec(text);
|
|
453
|
+
const drive = wsl ?? win;
|
|
454
|
+
if (drive) {
|
|
455
|
+
const rest = (drive[2] || "/").replace(/\/+$/, "") || "/";
|
|
456
|
+
return `${drive[1].toLowerCase()}:${rest.toLowerCase()}`;
|
|
457
|
+
}
|
|
458
|
+
return text.length > 1 ? text.replace(/\/+$/, "") : text;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Last path segment, used for compact workspace labels. */
|
|
462
|
+
function workspaceName(value) {
|
|
463
|
+
const parts = String(value ?? "").split(/[\\/]+/).filter(Boolean);
|
|
464
|
+
return parts.length ? parts[parts.length - 1] : String(value ?? "");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function setWorkspace(workspace, { persist = true } = {}) {
|
|
468
|
+
state.workspace = workspace ? { key: pathKey(workspace.path), path: workspace.path } : null;
|
|
469
|
+
if (persist) writeStorage(modeStorageKey("neutrino.workspace"), state.workspace?.path ?? "");
|
|
470
|
+
renderWorkspaceChrome();
|
|
471
|
+
renderSidebar();
|
|
472
|
+
}
|
|
473
|
+
|
|
261
474
|
function safeJson(value) {
|
|
262
475
|
try {
|
|
263
476
|
return JSON.stringify(value ?? {});
|
|
@@ -272,44 +485,33 @@ function formatTime(value) {
|
|
|
272
485
|
if (Number.isNaN(date.getTime())) return "";
|
|
273
486
|
const now = Date.now();
|
|
274
487
|
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(
|
|
488
|
+
if (diff < 60_000) return tr("just now");
|
|
489
|
+
if (diff < 3_600_000) return tr("{n}m ago", { n: Math.floor(diff / 60_000) });
|
|
490
|
+
if (diff < 86_400_000) return tr("{n}h ago", { n: Math.floor(diff / 3_600_000) });
|
|
491
|
+
return date.toLocaleDateString(localeTag(), { month: "short", day: "numeric" });
|
|
279
492
|
}
|
|
280
493
|
|
|
281
494
|
function formatDate(value) {
|
|
282
495
|
if (!value) return "";
|
|
283
496
|
const date = new Date(value);
|
|
284
|
-
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString();
|
|
497
|
+
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString(localeTag());
|
|
285
498
|
}
|
|
286
499
|
|
|
287
|
-
function setStatus(
|
|
288
|
-
|
|
500
|
+
function setStatus(key, kind) {
|
|
501
|
+
// Store the translation key, not the rendered text: switching languages must
|
|
502
|
+
// not leave a stale word in the sidebar.
|
|
503
|
+
state.status = { key, kind };
|
|
504
|
+
el.status.textContent = tr(key);
|
|
289
505
|
el.status.className = `status${kind ? ` ${kind}` : ""}`;
|
|
290
506
|
}
|
|
291
507
|
|
|
292
|
-
//
|
|
293
|
-
//
|
|
508
|
+
// Full markdown lives in web/markdown.js: headings, lists, tables, code,
|
|
509
|
+
// quotes, links, images and the inline emphasis family. The fallback keeps the
|
|
510
|
+
// sandboxed browser tests working when module imports are stripped; it still
|
|
511
|
+
// escapes all model output before adding line breaks.
|
|
294
512
|
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;
|
|
513
|
+
if (typeof renderMarkdownHtml === "function") return renderMarkdownHtml(value);
|
|
514
|
+
return escapeHtml(String(value ?? "")).replace(/\n/g, "<br>");
|
|
313
515
|
}
|
|
314
516
|
|
|
315
517
|
// ------------------------------------------------------------------- status
|
|
@@ -324,6 +526,7 @@ async function loadOverview(options = {}) {
|
|
|
324
526
|
if (gateShown) return;
|
|
325
527
|
state.overview = overview;
|
|
326
528
|
setStatus(state.selection?.kind === "live" ? "live" : "online", state.selection?.kind === "live" ? "live" : "online");
|
|
529
|
+
renderWorkspaceChrome();
|
|
327
530
|
renderSidebar();
|
|
328
531
|
syncSelection();
|
|
329
532
|
} catch (error) {
|
|
@@ -350,35 +553,49 @@ function syncSelection() {
|
|
|
350
553
|
renderSidebar();
|
|
351
554
|
hideComposer();
|
|
352
555
|
el.controls.innerHTML = "";
|
|
353
|
-
el.title.textContent = "Session ended";
|
|
354
|
-
el.sub.textContent = "The terminal session was closed.";
|
|
355
|
-
el.messages.innerHTML =
|
|
556
|
+
el.title.textContent = tr("Session ended");
|
|
557
|
+
el.sub.textContent = tr("The terminal session was closed.");
|
|
558
|
+
el.messages.innerHTML = `<div class="empty">${escapeHtml(tr("This live session has ended."))}</div>`;
|
|
559
|
+
renderPlanCard(null);
|
|
356
560
|
}
|
|
357
561
|
|
|
358
562
|
// ------------------------------------------------------------------ sidebar
|
|
359
563
|
|
|
564
|
+
/** Sidebar groups for the current view: every folder, or only the open workspace. */
|
|
565
|
+
function viewGroups() {
|
|
566
|
+
if (!state.overview) return null;
|
|
567
|
+
if (!state.workspace) return state.overview.groups;
|
|
568
|
+
const found = state.overview.groups.find((group) => group.key === state.workspace.key);
|
|
569
|
+
return found ? [found] : [{ key: state.workspace.key, cwd: state.workspace.path, updated_at: "", sessions: [] }];
|
|
570
|
+
}
|
|
571
|
+
|
|
360
572
|
function renderSidebar() {
|
|
361
|
-
const overview = state.overview;
|
|
362
573
|
el.sessions.innerHTML = "";
|
|
574
|
+
const groups = viewGroups();
|
|
363
575
|
|
|
364
|
-
if (!
|
|
576
|
+
if (!groups || groups.length === 0) {
|
|
365
577
|
const empty = document.createElement("div");
|
|
366
578
|
empty.className = "empty-side";
|
|
367
|
-
empty.textContent = "No conversations yet.";
|
|
579
|
+
empty.textContent = state.workspace ? tr("No conversations in this folder yet.") : tr("No conversations yet.");
|
|
368
580
|
el.sessions.appendChild(empty);
|
|
369
581
|
return;
|
|
370
582
|
}
|
|
371
583
|
|
|
372
|
-
for (const group of
|
|
373
|
-
|
|
584
|
+
for (const group of groups) el.sessions.appendChild(renderGroup(group, { forceOpen: Boolean(state.workspace) }));
|
|
585
|
+
|
|
586
|
+
if (state.workspace && groups[0] && groups[0].sessions.length === 0) {
|
|
587
|
+
const note = document.createElement("div");
|
|
588
|
+
note.className = "empty-side workspace-hint";
|
|
589
|
+
note.textContent = tr("Type a message below to start a conversation in this folder.");
|
|
590
|
+
el.sessions.appendChild(note);
|
|
374
591
|
}
|
|
375
592
|
}
|
|
376
593
|
|
|
377
|
-
function renderGroup(group) {
|
|
594
|
+
function renderGroup(group, options = {}) {
|
|
378
595
|
const wrap = document.createElement("section");
|
|
379
596
|
wrap.className = "group";
|
|
380
597
|
|
|
381
|
-
const collapsed = state.collapsed.has(group.key);
|
|
598
|
+
const collapsed = !options.forceOpen && state.collapsed.has(group.key);
|
|
382
599
|
const header = document.createElement("button");
|
|
383
600
|
header.className = "group-header";
|
|
384
601
|
header.innerHTML =
|
|
@@ -402,11 +619,18 @@ function renderGroup(group) {
|
|
|
402
619
|
const visible = expanded ? group.sessions : group.sessions.slice(0, MAX_VISIBLE);
|
|
403
620
|
for (const session of visible) list.appendChild(renderSession(session));
|
|
404
621
|
|
|
622
|
+
if (group.sessions.length === 0) {
|
|
623
|
+
const empty = document.createElement("div");
|
|
624
|
+
empty.className = "empty-side";
|
|
625
|
+
empty.textContent = tr("No conversations in this folder yet.");
|
|
626
|
+
list.appendChild(empty);
|
|
627
|
+
}
|
|
628
|
+
|
|
405
629
|
const hiddenCount = group.sessions.length - MAX_VISIBLE;
|
|
406
630
|
if (hiddenCount > 0) {
|
|
407
631
|
const more = document.createElement("button");
|
|
408
632
|
more.className = "more";
|
|
409
|
-
more.textContent = expanded ? "Show less" :
|
|
633
|
+
more.textContent = expanded ? tr("Show less") : tr("Show {n} more", { n: hiddenCount });
|
|
410
634
|
more.addEventListener("click", () => {
|
|
411
635
|
if (state.expanded.has(group.key)) state.expanded.delete(group.key);
|
|
412
636
|
else state.expanded.add(group.key);
|
|
@@ -432,15 +656,15 @@ function renderSession(session) {
|
|
|
432
656
|
if (isSelected(session)) item.classList.add("selected");
|
|
433
657
|
if (session.live) item.classList.add("live");
|
|
434
658
|
|
|
435
|
-
const title = session.title || "New conversation";
|
|
659
|
+
const title = session.title || tr("New conversation");
|
|
436
660
|
const count = Number(session.message_count ?? 0);
|
|
437
661
|
item.title = title;
|
|
438
662
|
item.innerHTML =
|
|
439
663
|
`<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
|
|
440
|
-
`${session.live ?
|
|
664
|
+
`${session.live ? `<span class="dot" title="${escapeHtml(tr("Live"))}"></span>` : ""}</div>` +
|
|
441
665
|
`<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
|
|
442
666
|
`<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
|
|
443
|
-
`<span class="count">${count
|
|
667
|
+
`<span class="count">${escapeHtml(count === 1 ? tr("{n} message", { n: count }) : tr("{n} messages", { n: count }))}</span></div>`;
|
|
444
668
|
|
|
445
669
|
item.addEventListener("click", () => {
|
|
446
670
|
if (session.liveKey) selectLive(session.liveKey);
|
|
@@ -475,23 +699,28 @@ async function selectLive(key) {
|
|
|
475
699
|
closeStream();
|
|
476
700
|
state.selection = { kind: "live", key };
|
|
477
701
|
state.snapshot = null;
|
|
702
|
+
state.fileData = null;
|
|
703
|
+
state.agentData = null;
|
|
478
704
|
state.lastSessionId = null;
|
|
479
705
|
state.openTools.clear();
|
|
480
706
|
state.openTurns.clear();
|
|
481
707
|
state.openContext = null;
|
|
708
|
+
state.planOpen = false;
|
|
709
|
+
state.planSeen = "";
|
|
482
710
|
renderSidebar();
|
|
483
711
|
el.controls.innerHTML = "";
|
|
484
712
|
renderApproval(null);
|
|
485
|
-
|
|
713
|
+
renderPlanCard(null);
|
|
714
|
+
el.title.textContent = tr("Connecting…");
|
|
486
715
|
el.sub.textContent = "";
|
|
487
716
|
setComposerMode("live");
|
|
488
|
-
resetMessagePane("Connecting…");
|
|
717
|
+
resetMessagePane(tr("Connecting…"));
|
|
489
718
|
openStream(key);
|
|
490
719
|
}
|
|
491
720
|
|
|
492
721
|
function openStream(key) {
|
|
493
722
|
closeStream();
|
|
494
|
-
const source = new EventSource(`/api/live/${encodeURIComponent(key)}/stream`);
|
|
723
|
+
const source = new EventSource(apiPath(`/api/live/${encodeURIComponent(key)}/stream`));
|
|
495
724
|
state.eventSource = source;
|
|
496
725
|
|
|
497
726
|
source.addEventListener("snapshot", (event) => {
|
|
@@ -534,16 +763,21 @@ async function selectFile(id) {
|
|
|
534
763
|
closeStream();
|
|
535
764
|
state.selection = { kind: "file", id };
|
|
536
765
|
state.snapshot = null;
|
|
766
|
+
state.fileData = null;
|
|
767
|
+
state.agentData = null;
|
|
537
768
|
state.openTools.clear();
|
|
538
769
|
state.openTurns.clear();
|
|
539
770
|
state.openContext = null;
|
|
771
|
+
state.planOpen = false;
|
|
772
|
+
state.planSeen = "";
|
|
540
773
|
renderSidebar();
|
|
541
774
|
el.controls.innerHTML = "";
|
|
542
775
|
renderApproval(null);
|
|
776
|
+
renderPlanCard(null);
|
|
543
777
|
setComposerMode("resume");
|
|
544
|
-
el.title.textContent = "Loading…";
|
|
778
|
+
el.title.textContent = tr("Loading…");
|
|
545
779
|
el.sub.textContent = "";
|
|
546
|
-
resetMessagePane("Loading…");
|
|
780
|
+
resetMessagePane(tr("Loading…"));
|
|
547
781
|
|
|
548
782
|
try {
|
|
549
783
|
const response = await apiFetch(`/api/session/${encodeURIComponent(id)}`, { cache: "no-store" });
|
|
@@ -551,8 +785,8 @@ async function selectFile(id) {
|
|
|
551
785
|
const data = await response.json();
|
|
552
786
|
renderFile(data);
|
|
553
787
|
} catch (error) {
|
|
554
|
-
el.title.textContent = "Conversation";
|
|
555
|
-
resetMessagePane(
|
|
788
|
+
el.title.textContent = tr("Conversation");
|
|
789
|
+
resetMessagePane(tr("Could not load this conversation: {error}", { error: error.message }));
|
|
556
790
|
}
|
|
557
791
|
}
|
|
558
792
|
|
|
@@ -560,11 +794,16 @@ async function selectFile(id) {
|
|
|
560
794
|
|
|
561
795
|
function renderFile(data) {
|
|
562
796
|
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
563
|
-
|
|
797
|
+
state.fileData = data;
|
|
798
|
+
state.agentData = null;
|
|
799
|
+
el.title.textContent = firstUserMessage(messages) || tr("Conversation");
|
|
564
800
|
el.sub.textContent = [data.cwd, data.model, data.provider].filter(Boolean).join(" · ");
|
|
565
801
|
preferences.showConversation(data, `file:${data.id}`);
|
|
802
|
+
proUI?.show(data, `file:${data.id}`);
|
|
566
803
|
renderMessageList(messages, null, { readOnly: true, context: data.context, active: false });
|
|
567
804
|
renderFileControls(data);
|
|
805
|
+
maybeOpenPlan(data.plan);
|
|
806
|
+
renderPlanCard(data);
|
|
568
807
|
}
|
|
569
808
|
|
|
570
809
|
/**
|
|
@@ -573,26 +812,27 @@ function renderFile(data) {
|
|
|
573
812
|
*/
|
|
574
813
|
function renderFileControls(data) {
|
|
575
814
|
el.controls.innerHTML = "";
|
|
815
|
+
el.controls.appendChild(planToggleButton());
|
|
576
816
|
|
|
577
817
|
const badge = document.createElement("span");
|
|
578
818
|
badge.className = "badge readonly";
|
|
579
|
-
badge.textContent = "Read-only";
|
|
819
|
+
badge.textContent = tr("Read-only");
|
|
580
820
|
el.controls.appendChild(badge);
|
|
581
821
|
|
|
582
822
|
const button = document.createElement("button");
|
|
583
823
|
button.className = "control primary";
|
|
584
|
-
button.textContent = "Continue here";
|
|
585
|
-
button.title = "Resume this conversation in the browser and keep chatting";
|
|
824
|
+
button.textContent = tr("Continue here");
|
|
825
|
+
button.title = tr("Resume this conversation in the browser and keep chatting");
|
|
586
826
|
button.addEventListener("click", async () => {
|
|
587
827
|
button.disabled = true;
|
|
588
|
-
button.textContent = "Starting…";
|
|
828
|
+
button.textContent = tr("Starting…");
|
|
589
829
|
try {
|
|
590
830
|
await continueSession(data.id);
|
|
591
831
|
el.input.focus();
|
|
592
832
|
} catch (error) {
|
|
593
833
|
button.disabled = false;
|
|
594
|
-
button.textContent = "Continue here";
|
|
595
|
-
el.sub.textContent =
|
|
834
|
+
button.textContent = tr("Continue here");
|
|
835
|
+
el.sub.textContent = tr("Could not continue this conversation: {error}", { error: error.message });
|
|
596
836
|
}
|
|
597
837
|
});
|
|
598
838
|
el.controls.appendChild(button);
|
|
@@ -600,14 +840,22 @@ function renderFileControls(data) {
|
|
|
600
840
|
const key = await continueSession(data.id);
|
|
601
841
|
await changeApprovalMode(key, mode);
|
|
602
842
|
});
|
|
843
|
+
|
|
844
|
+
const remove = document.createElement("button");
|
|
845
|
+
remove.className = "control danger";
|
|
846
|
+
remove.textContent = tr("Delete");
|
|
847
|
+
remove.title = tr("Delete conversation");
|
|
848
|
+
remove.addEventListener("click", () => deleteConversation(data.id, remove));
|
|
849
|
+
el.controls.appendChild(remove);
|
|
603
850
|
}
|
|
604
851
|
|
|
605
852
|
/** Attach to a resumed conversation, creating it server-side when needed. */
|
|
606
853
|
async function continueSession(id, selection = preferences.selectionForFile(id)) {
|
|
854
|
+
const payload = PRO_MODE ? { pro_preset: currentProPreset() } : selection ? { selection } : undefined;
|
|
607
855
|
const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/continue`, {
|
|
608
856
|
method: "POST",
|
|
609
|
-
headers:
|
|
610
|
-
body:
|
|
857
|
+
headers: payload ? { "Content-Type": "application/json" } : undefined,
|
|
858
|
+
body: payload ? JSON.stringify(payload) : undefined,
|
|
611
859
|
});
|
|
612
860
|
const body = await response.json().catch(() => ({}));
|
|
613
861
|
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
@@ -616,11 +864,13 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
|
|
|
616
864
|
}
|
|
617
865
|
|
|
618
866
|
function setComposerMode(mode) {
|
|
867
|
+
state.composerMode = mode;
|
|
619
868
|
el.composer.classList.remove("hidden");
|
|
620
|
-
el.input.placeholder =
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
869
|
+
el.input.placeholder = mode === "resume"
|
|
870
|
+
? tr("Continue this conversation…")
|
|
871
|
+
: mode === "new"
|
|
872
|
+
? tr("Message Neutrino in this folder…")
|
|
873
|
+
: tr("Message Neutrino…");
|
|
624
874
|
}
|
|
625
875
|
|
|
626
876
|
function hideComposer() {
|
|
@@ -630,18 +880,24 @@ function hideComposer() {
|
|
|
630
880
|
}
|
|
631
881
|
|
|
632
882
|
function renderApprovalDetails(request, fallbackCwd = "") {
|
|
883
|
+
// The approval renderer is exercised on its own in the browser tests; fall
|
|
884
|
+
// back to the English source if the translation helper is out of scope.
|
|
885
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
886
|
+
? tr(text, values)
|
|
887
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
888
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
633
889
|
const details = document.createElement("div");
|
|
634
890
|
details.className = "approval-details";
|
|
635
891
|
const tool = document.createElement("div");
|
|
636
892
|
tool.className = "approval-tool";
|
|
637
|
-
tool.textContent = request.tool || "Tool call";
|
|
893
|
+
tool.textContent = request.tool || tx("Tool call");
|
|
638
894
|
details.appendChild(tool);
|
|
639
895
|
|
|
640
896
|
const cwd = request.cwd || fallbackCwd;
|
|
641
897
|
if (cwd) {
|
|
642
898
|
const location = document.createElement("div");
|
|
643
899
|
location.className = "approval-location";
|
|
644
|
-
location.textContent =
|
|
900
|
+
location.textContent = tx("Working directory: {path}", { path: cwd });
|
|
645
901
|
details.appendChild(location);
|
|
646
902
|
}
|
|
647
903
|
|
|
@@ -658,14 +914,14 @@ function renderApprovalDetails(request, fallbackCwd = "") {
|
|
|
658
914
|
// Literal text preserves the complete command and prevents tool arguments
|
|
659
915
|
// from becoming markup. Risk summaries may be shortened; arguments are not.
|
|
660
916
|
code.textContent = command !== null ? command
|
|
661
|
-
: callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || "Command details unavailable.";
|
|
917
|
+
: callArgs ? JSON.stringify(callArgs, null, 2) : request.summary || tx("Command details unavailable.");
|
|
662
918
|
details.appendChild(code);
|
|
663
919
|
|
|
664
920
|
if (stated) {
|
|
665
921
|
const why = document.createElement("div");
|
|
666
922
|
why.className = "approval-why";
|
|
667
923
|
const label = document.createElement("strong");
|
|
668
|
-
label.textContent = "Why: ";
|
|
924
|
+
label.textContent = tx("Why: ");
|
|
669
925
|
const claim = document.createElement("span");
|
|
670
926
|
claim.textContent = stated;
|
|
671
927
|
why.append(label, claim);
|
|
@@ -676,7 +932,7 @@ function renderApprovalDetails(request, fallbackCwd = "") {
|
|
|
676
932
|
const argumentsBox = document.createElement("details");
|
|
677
933
|
argumentsBox.className = "approval-arguments";
|
|
678
934
|
const label = document.createElement("summary");
|
|
679
|
-
label.textContent = "All arguments";
|
|
935
|
+
label.textContent = tx("All arguments");
|
|
680
936
|
const parameters = document.createElement("pre");
|
|
681
937
|
parameters.className = "approval-code";
|
|
682
938
|
parameters.textContent = JSON.stringify(callArgs, null, 2);
|
|
@@ -697,33 +953,39 @@ function renderApprovalDetails(request, fallbackCwd = "") {
|
|
|
697
953
|
return details;
|
|
698
954
|
}
|
|
699
955
|
|
|
956
|
+
/**
|
|
957
|
+
* Progress text for an automated review. It belongs in the status badge, not
|
|
958
|
+
* in the approval banner: automatic review needs no answer from the user, and
|
|
959
|
+
* reusing the approval card made the browser look like it was still asking for
|
|
960
|
+
* one while the reviewers were already deciding.
|
|
961
|
+
*/
|
|
962
|
+
function reviewStatus(review) {
|
|
963
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
964
|
+
? tr(text, values)
|
|
965
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
966
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
967
|
+
const rounds = Number(review && review.rounds) || 1;
|
|
968
|
+
const total = Number(review && review.total) || 0;
|
|
969
|
+
if (rounds > 1) {
|
|
970
|
+
return tx("reviewing round {round}/{rounds}", { round: (review && review.round) || 1, rounds });
|
|
971
|
+
}
|
|
972
|
+
if (total === 1) return tx("reviewing · {n} reviewer", { n: total });
|
|
973
|
+
if (total > 1) return tx("reviewing · {n} reviewers", { n: total });
|
|
974
|
+
return tx("reviewing");
|
|
975
|
+
}
|
|
976
|
+
|
|
700
977
|
function renderApproval(snapshot) {
|
|
978
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
979
|
+
? tr(text, values)
|
|
980
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
981
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
701
982
|
const pending = snapshot && snapshot.approval;
|
|
702
983
|
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");
|
|
984
|
+
// The banner is reserved for a call that is really waiting for a human.
|
|
985
|
+
// Automatic review progress lives in the controls badge (see renderControls),
|
|
986
|
+
// because otherwise it reads as a second approval request that then
|
|
987
|
+
// disappears by itself.
|
|
988
|
+
el.approval.className = "approval hidden";
|
|
727
989
|
el.approval.replaceChildren();
|
|
728
990
|
return;
|
|
729
991
|
}
|
|
@@ -733,10 +995,10 @@ function renderApproval(snapshot) {
|
|
|
733
995
|
const head = document.createElement("div");
|
|
734
996
|
head.className = "approval-head";
|
|
735
997
|
const title = document.createElement("strong");
|
|
736
|
-
title.textContent = "Approval required";
|
|
998
|
+
title.textContent = tx("Approval required");
|
|
737
999
|
const risk = document.createElement("span");
|
|
738
1000
|
risk.className = "approval-risk";
|
|
739
|
-
risk.textContent = extreme ? "EXTREME RISK" : "HIGH RISK";
|
|
1001
|
+
risk.textContent = extreme ? tx("EXTREME RISK") : tx("HIGH RISK");
|
|
740
1002
|
head.append(title, risk);
|
|
741
1003
|
|
|
742
1004
|
const details = renderApprovalDetails(pending, snapshot.cwd);
|
|
@@ -745,11 +1007,11 @@ function renderApproval(snapshot) {
|
|
|
745
1007
|
actions.className = "approval-actions";
|
|
746
1008
|
const approve = document.createElement("button");
|
|
747
1009
|
approve.className = "approve-btn";
|
|
748
|
-
approve.textContent = "Approve";
|
|
1010
|
+
approve.textContent = tx("Approve");
|
|
749
1011
|
approve.addEventListener("click", () => decideApproval(true));
|
|
750
1012
|
const deny = document.createElement("button");
|
|
751
1013
|
deny.className = "deny-btn";
|
|
752
|
-
deny.textContent = "Deny";
|
|
1014
|
+
deny.textContent = tx("Deny");
|
|
753
1015
|
deny.addEventListener("click", () => decideApproval(false));
|
|
754
1016
|
actions.append(approve, deny);
|
|
755
1017
|
|
|
@@ -765,9 +1027,12 @@ function decideApproval(approved) {
|
|
|
765
1027
|
function renderLive() {
|
|
766
1028
|
const snapshot = state.snapshot;
|
|
767
1029
|
if (!snapshot) return;
|
|
768
|
-
|
|
1030
|
+
state.fileData = null;
|
|
1031
|
+
state.agentData = null;
|
|
1032
|
+
el.title.textContent = snapshot.title || tr("New conversation");
|
|
769
1033
|
el.sub.textContent = [snapshot.cwd, snapshot.model, snapshot.provider].filter(Boolean).join(" · ");
|
|
770
1034
|
preferences.showConversation(snapshot, `live:${snapshot.key}`);
|
|
1035
|
+
proUI?.show(snapshot, `live:${snapshot.key}`);
|
|
771
1036
|
renderControls(snapshot);
|
|
772
1037
|
renderApproval(snapshot);
|
|
773
1038
|
renderMessageList(snapshot.messages ?? [], snapshot.streamingContent, {
|
|
@@ -777,6 +1042,8 @@ function renderLive() {
|
|
|
777
1042
|
// paused turn is unfinished by the user's own choice, so it stays open too.
|
|
778
1043
|
active: Boolean(snapshot.busy || snapshot.paused),
|
|
779
1044
|
});
|
|
1045
|
+
maybeOpenPlan(snapshot.plan);
|
|
1046
|
+
renderPlanCard(snapshot);
|
|
780
1047
|
}
|
|
781
1048
|
|
|
782
1049
|
async function changeApprovalMode(key, mode) {
|
|
@@ -794,21 +1061,25 @@ async function changeApprovalMode(key, mode) {
|
|
|
794
1061
|
}
|
|
795
1062
|
|
|
796
1063
|
function renderApprovalModeControl(mode, change) {
|
|
1064
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
1065
|
+
? tr(text, values)
|
|
1066
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
1067
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
797
1068
|
const automatic = mode === "auto";
|
|
798
1069
|
const button = document.createElement("button");
|
|
799
1070
|
button.className = "control";
|
|
800
1071
|
button.type = "button";
|
|
801
1072
|
button.setAttribute("role", "switch");
|
|
802
|
-
button.setAttribute("aria-label", "Auto Approval");
|
|
1073
|
+
button.setAttribute("aria-label", tx("Auto Approval"));
|
|
803
1074
|
button.setAttribute("aria-checked", String(automatic));
|
|
804
|
-
button.textContent = automatic ? "Auto Approval" : "Request Approval";
|
|
805
|
-
button.title = "Switch between requesting approval and automatic review";
|
|
1075
|
+
button.textContent = automatic ? tx("Auto Approval") : tx("Request Approval");
|
|
1076
|
+
button.title = tx("Switch between requesting approval and automatic review");
|
|
806
1077
|
button.addEventListener("click", async () => {
|
|
807
1078
|
button.disabled = true;
|
|
808
1079
|
try {
|
|
809
1080
|
await change(automatic ? "request" : "auto");
|
|
810
1081
|
} catch (error) {
|
|
811
|
-
el.sub.textContent =
|
|
1082
|
+
el.sub.textContent = tx("Could not change approval mode: {error}", { error: error.message });
|
|
812
1083
|
} finally {
|
|
813
1084
|
button.disabled = false;
|
|
814
1085
|
}
|
|
@@ -818,6 +1089,7 @@ function renderApprovalModeControl(mode, change) {
|
|
|
818
1089
|
|
|
819
1090
|
function renderControls(snapshot) {
|
|
820
1091
|
el.controls.innerHTML = "";
|
|
1092
|
+
el.controls.appendChild(planToggleButton());
|
|
821
1093
|
renderApprovalModeControl(snapshot.approvalMode, mode =>
|
|
822
1094
|
changeApprovalMode(snapshot.key, mode));
|
|
823
1095
|
|
|
@@ -826,51 +1098,66 @@ function renderControls(snapshot) {
|
|
|
826
1098
|
badge.className = `badge ${mode}`;
|
|
827
1099
|
// A retried request leaves the session busy but silent: name the retry so the
|
|
828
1100
|
// badge does not just say "working" for minutes. The same goes for a
|
|
829
|
-
// sub-agent, whose own messages never reach this transcript.
|
|
1101
|
+
// sub-agent, whose own messages never reach this transcript. An automated
|
|
1102
|
+
// review is reported here too, so it never needs the approval banner.
|
|
830
1103
|
const retrying = snapshot.retrying;
|
|
1104
|
+
// A pending human decision is an approval, not a review, even if an earlier
|
|
1105
|
+
// review callback is still winding down.
|
|
1106
|
+
const reviewing = snapshot.approval ? null : snapshot.reviewing;
|
|
831
1107
|
const subagent = snapshot.subagent;
|
|
832
1108
|
badge.textContent = snapshot.paused
|
|
833
|
-
? "paused"
|
|
1109
|
+
? tr("paused")
|
|
834
1110
|
: snapshot.busy
|
|
835
1111
|
? snapshot.compressing
|
|
836
|
-
? "compressing"
|
|
1112
|
+
? tr("compressing")
|
|
837
1113
|
: retrying
|
|
838
|
-
?
|
|
839
|
-
:
|
|
840
|
-
?
|
|
841
|
-
:
|
|
842
|
-
|
|
1114
|
+
? tr("retrying {attempt}/{attempts}", { attempt: retrying.attempt, attempts: retrying.attempts })
|
|
1115
|
+
: reviewing
|
|
1116
|
+
? reviewStatus(reviewing)
|
|
1117
|
+
: subagent
|
|
1118
|
+
? tr("sub-agent: {label}", { label: subagent })
|
|
1119
|
+
: tr("working")
|
|
1120
|
+
: tr("idle");
|
|
843
1121
|
el.controls.appendChild(badge);
|
|
844
1122
|
|
|
845
1123
|
if (snapshot.saveError) {
|
|
846
1124
|
const warning = document.createElement("span");
|
|
847
1125
|
warning.className = "badge paused";
|
|
848
|
-
warning.textContent =
|
|
1126
|
+
warning.textContent = tr("History save failed: {error}", { error: snapshot.saveError });
|
|
849
1127
|
el.controls.appendChild(warning);
|
|
850
1128
|
}
|
|
851
1129
|
|
|
852
1130
|
if (snapshot.busy || snapshot.paused) {
|
|
853
1131
|
const button = document.createElement("button");
|
|
854
1132
|
button.className = "control";
|
|
855
|
-
button.textContent = snapshot.paused ? "Resume" : "Pause";
|
|
1133
|
+
button.textContent = snapshot.paused ? tr("Resume") : tr("Pause");
|
|
856
1134
|
button.addEventListener("click", () => {
|
|
857
1135
|
postAction(snapshot.key, snapshot.paused ? "resume" : "pause");
|
|
858
1136
|
});
|
|
859
1137
|
el.controls.appendChild(button);
|
|
860
1138
|
}
|
|
861
1139
|
|
|
1140
|
+
if ((snapshot.messages?.length ?? 0) > 0 || snapshot.busy) {
|
|
1141
|
+
const clear = document.createElement("button");
|
|
1142
|
+
clear.className = "control danger";
|
|
1143
|
+
clear.textContent = tr("Clear");
|
|
1144
|
+
clear.title = tr("Clear conversation");
|
|
1145
|
+
clear.addEventListener("click", () => clearConversation(snapshot.key));
|
|
1146
|
+
el.controls.appendChild(clear);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
862
1149
|
if (snapshot.queued) {
|
|
863
1150
|
const queued = document.createElement("span");
|
|
864
1151
|
queued.className = "queued";
|
|
865
1152
|
queued.title = snapshot.queued;
|
|
866
|
-
queued.textContent = "queued";
|
|
1153
|
+
queued.textContent = tr("queued");
|
|
867
1154
|
el.controls.appendChild(queued);
|
|
868
1155
|
}
|
|
869
1156
|
|
|
870
1157
|
if (snapshot.tokens && (snapshot.tokens.prompt || snapshot.tokens.completion)) {
|
|
871
1158
|
const tokens = document.createElement("span");
|
|
872
1159
|
tokens.className = "tokens";
|
|
873
|
-
tokens.textContent =
|
|
1160
|
+
tokens.textContent = tr("{prompt}→{completion} tok", { prompt: snapshot.tokens.prompt, completion: snapshot.tokens.completion });
|
|
874
1161
|
el.controls.appendChild(tokens);
|
|
875
1162
|
}
|
|
876
1163
|
}
|
|
@@ -885,7 +1172,8 @@ function renderMessageList(messages, streaming, options) {
|
|
|
885
1172
|
if (messages.length === 0 && !streaming) {
|
|
886
1173
|
const empty = document.createElement("div");
|
|
887
1174
|
empty.className = "empty";
|
|
888
|
-
|
|
1175
|
+
const emptyText = options?.readOnly ? "This conversation is empty." : "Send a message to get started.";
|
|
1176
|
+
empty.textContent = typeof tr === "function" ? tr(emptyText) : emptyText;
|
|
889
1177
|
el.messages.appendChild(empty);
|
|
890
1178
|
return;
|
|
891
1179
|
}
|
|
@@ -899,7 +1187,9 @@ function renderMessageList(messages, streaming, options) {
|
|
|
899
1187
|
const details = document.createElement("details");
|
|
900
1188
|
details.className = "conversation-history";
|
|
901
1189
|
const summary = document.createElement("summary");
|
|
902
|
-
summary.textContent =
|
|
1190
|
+
summary.textContent = typeof tr === "function"
|
|
1191
|
+
? tr("Earlier conversation · {n} messages", { n: end })
|
|
1192
|
+
: `Earlier conversation · ${end} messages`;
|
|
903
1193
|
details.appendChild(summary);
|
|
904
1194
|
const body = document.createElement("div");
|
|
905
1195
|
body.className = "history-messages";
|
|
@@ -982,13 +1272,13 @@ function renderMessage(message, index) {
|
|
|
982
1272
|
if (message.role === "summary") {
|
|
983
1273
|
article.classList.add("summary");
|
|
984
1274
|
article.innerHTML =
|
|
985
|
-
`<div class="summary-label"
|
|
1275
|
+
`<div class="summary-label">${escapeHtml(tr("Context summary · {n} messages", { n: Number(message.count ?? 0) }))}</div>` +
|
|
986
1276
|
`<div class="body">${renderMarkdown(message.content)}</div>`;
|
|
987
1277
|
return article;
|
|
988
1278
|
}
|
|
989
1279
|
|
|
990
|
-
const who = message.role === "user" ? "You" : "Neutrino";
|
|
991
|
-
article.innerHTML = `<div class="who">${who}</div><div class="body">${renderMarkdown(message.content)}</div>`;
|
|
1280
|
+
const who = message.role === "user" ? tr("You") : "Neutrino";
|
|
1281
|
+
article.innerHTML = `<div class="who">${escapeHtml(who)}</div><div class="body">${renderMarkdown(message.content)}</div>`;
|
|
992
1282
|
return article;
|
|
993
1283
|
}
|
|
994
1284
|
|
|
@@ -1000,6 +1290,12 @@ function renderMessage(message, index) {
|
|
|
1000
1290
|
* transcript always holds every message.
|
|
1001
1291
|
*/
|
|
1002
1292
|
function renderTurnFold(start, messages) {
|
|
1293
|
+
// This renderer is also exercised standalone by the web tests, so it carries
|
|
1294
|
+
// an English fallback for the translation helper.
|
|
1295
|
+
const tx = (text, values) => (typeof tr === "function"
|
|
1296
|
+
? tr(text, values)
|
|
1297
|
+
: String(text).replace(/\{(\w+)\}/g, (match, name) =>
|
|
1298
|
+
values && Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match));
|
|
1003
1299
|
// The reply is never folded away, so a turn renders as a group: the fold (the
|
|
1004
1300
|
// prompt line plus the work it hides) and, right below it, the answer itself.
|
|
1005
1301
|
const reply = turnAnswerIndex(messages);
|
|
@@ -1018,10 +1314,10 @@ function renderTurnFold(start, messages) {
|
|
|
1018
1314
|
'<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
1315
|
const you = document.createElement("span");
|
|
1020
1316
|
you.className = "turn-you";
|
|
1021
|
-
you.textContent =
|
|
1317
|
+
you.textContent = tx("You: {text}", { text: truncate(turnPreview(messages, "user"), 110) });
|
|
1022
1318
|
const meta = document.createElement("span");
|
|
1023
1319
|
meta.className = "turn-meta";
|
|
1024
|
-
meta.textContent =
|
|
1320
|
+
meta.textContent = tx("{n} of {m} messages folded", { n: hidden, m: messages.length });
|
|
1025
1321
|
// The hint cannot live in the static text: it says the opposite once open.
|
|
1026
1322
|
const hint = document.createElement("span");
|
|
1027
1323
|
hint.className = "turn-hint";
|
|
@@ -1033,7 +1329,7 @@ function renderTurnFold(start, messages) {
|
|
|
1033
1329
|
group.appendChild(details);
|
|
1034
1330
|
|
|
1035
1331
|
const syncHint = () => {
|
|
1036
|
-
hint.textContent = details.open ? " — click to collapse" : " — click to expand";
|
|
1332
|
+
hint.textContent = details.open ? tx(" — click to collapse") : tx(" — click to expand");
|
|
1037
1333
|
};
|
|
1038
1334
|
syncHint();
|
|
1039
1335
|
|
|
@@ -1102,6 +1398,10 @@ async function sendMessage() {
|
|
|
1102
1398
|
if (!selection) return;
|
|
1103
1399
|
const text = el.input.value;
|
|
1104
1400
|
if (!text.trim()) return;
|
|
1401
|
+
if (PRO_MODE) {
|
|
1402
|
+
try { await proUI?.flush(); }
|
|
1403
|
+
catch (error) { el.sub.textContent = error.message; return; }
|
|
1404
|
+
}
|
|
1105
1405
|
|
|
1106
1406
|
if (selection.kind === "live") {
|
|
1107
1407
|
el.input.value = "";
|
|
@@ -1110,6 +1410,24 @@ async function sendMessage() {
|
|
|
1110
1410
|
return;
|
|
1111
1411
|
}
|
|
1112
1412
|
|
|
1413
|
+
if (selection.kind === "new") {
|
|
1414
|
+
// A workspace landing page: typing starts a fresh conversation there, just
|
|
1415
|
+
// like typing on the terminal's start screen.
|
|
1416
|
+
el.input.value = "";
|
|
1417
|
+
autosize();
|
|
1418
|
+
try {
|
|
1419
|
+
const created = await createConversation(selection.cwd, preferences.defaultSelection?.());
|
|
1420
|
+
await selectLive(created.key);
|
|
1421
|
+
setConversationView(true);
|
|
1422
|
+
await postAction(created.key, "message", { text });
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
el.input.value = text;
|
|
1425
|
+
autosize();
|
|
1426
|
+
el.sub.textContent = tr("Could not start the conversation: {error}", { error: error.message });
|
|
1427
|
+
}
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1113
1431
|
// Reading a saved transcript: resume it in the browser, then deliver the text.
|
|
1114
1432
|
el.input.value = "";
|
|
1115
1433
|
autosize();
|
|
@@ -1119,7 +1437,7 @@ async function sendMessage() {
|
|
|
1119
1437
|
} catch (error) {
|
|
1120
1438
|
el.input.value = text;
|
|
1121
1439
|
autosize();
|
|
1122
|
-
el.sub.textContent =
|
|
1440
|
+
el.sub.textContent = tr("Could not continue this conversation: {error}", { error: error.message });
|
|
1123
1441
|
}
|
|
1124
1442
|
}
|
|
1125
1443
|
|
|
@@ -1128,6 +1446,1061 @@ function autosize() {
|
|
|
1128
1446
|
el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
|
|
1129
1447
|
}
|
|
1130
1448
|
|
|
1449
|
+
// ------------------------------------------------------- workspaces & dialogs
|
|
1450
|
+
|
|
1451
|
+
function renderWorkspaceChrome() {
|
|
1452
|
+
if (!el.workspaceLabel) return;
|
|
1453
|
+
const workspace = state.workspace;
|
|
1454
|
+
el.workspaceLabel.textContent = workspace ? workspaceName(workspace.path) : tr("All directories");
|
|
1455
|
+
if (el.workspaceButton) {
|
|
1456
|
+
el.workspaceButton.title = workspace ? workspace.path : tr("Working directory");
|
|
1457
|
+
el.workspaceButton.classList.toggle("active", Boolean(workspace));
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function joinPath(parent, name) {
|
|
1462
|
+
const separator = String(parent ?? "").includes("\\") ? "\\" : "/";
|
|
1463
|
+
return `${String(parent ?? "").replace(/[\\/]+$/, "")}${separator}${name}`;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function selectionCwd() {
|
|
1467
|
+
const selection = state.selection;
|
|
1468
|
+
if (!selection) return "";
|
|
1469
|
+
if (selection.kind === "live") return state.snapshot?.cwd || "";
|
|
1470
|
+
if (selection.kind === "file") return state.fileData?.cwd || "";
|
|
1471
|
+
if (selection.kind === "new") return selection.cwd || "";
|
|
1472
|
+
if (selection.kind === "agent") return state.agentData?.cwd || state.workspace?.path || "";
|
|
1473
|
+
return "";
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function confirmAction(message) {
|
|
1477
|
+
try {
|
|
1478
|
+
return typeof window === "undefined" || typeof window.confirm !== "function" ? true : window.confirm(message);
|
|
1479
|
+
} catch {
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function apiError(data, fallback) {
|
|
1485
|
+
return tr(data && typeof data.error === "string" ? data.error : fallback);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function renderHomeEmpty() {
|
|
1489
|
+
el.messages.innerHTML = "";
|
|
1490
|
+
const empty = document.createElement("div");
|
|
1491
|
+
empty.className = "empty";
|
|
1492
|
+
empty.innerHTML =
|
|
1493
|
+
'<span class="empty-mark" aria-hidden="true">' +
|
|
1494
|
+
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
|
|
1495
|
+
'<ellipse cx="12" cy="12" rx="9.4" ry="3.8" transform="rotate(-30 12 12)" stroke="currentColor" stroke-width="1.7" />' +
|
|
1496
|
+
'<circle cx="12" cy="12" r="3.1" stroke="currentColor" stroke-width="1.7" />' +
|
|
1497
|
+
"</svg></span>" +
|
|
1498
|
+
`<strong>${escapeHtml(tr("No conversation open"))}</strong>` +
|
|
1499
|
+
`<span>${escapeHtml(tr("Choose a conversation from the sidebar to view it."))}</span>`;
|
|
1500
|
+
const button = document.createElement("button");
|
|
1501
|
+
button.className = "control primary";
|
|
1502
|
+
button.type = "button";
|
|
1503
|
+
button.textContent = tr("New conversation");
|
|
1504
|
+
button.addEventListener("click", () => openNewChatDialog());
|
|
1505
|
+
empty.appendChild(button);
|
|
1506
|
+
el.messages.appendChild(empty);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/** Back to the neutral landing view, with no conversation selected. */
|
|
1510
|
+
function showHome({ menu = true } = {}) {
|
|
1511
|
+
closeStream();
|
|
1512
|
+
state.selection = null;
|
|
1513
|
+
state.snapshot = null;
|
|
1514
|
+
state.fileData = null;
|
|
1515
|
+
state.agentData = null;
|
|
1516
|
+
state.planOpen = false;
|
|
1517
|
+
state.planSeen = "";
|
|
1518
|
+
preferences.clear();
|
|
1519
|
+
renderSidebar();
|
|
1520
|
+
renderWorkspaceChrome();
|
|
1521
|
+
el.controls.innerHTML = "";
|
|
1522
|
+
renderApproval(null);
|
|
1523
|
+
renderPlanCard(null);
|
|
1524
|
+
hideComposer();
|
|
1525
|
+
el.title.textContent = tr("Select a conversation");
|
|
1526
|
+
el.sub.textContent = "";
|
|
1527
|
+
renderHomeEmpty();
|
|
1528
|
+
if (menu) setConversationView(false);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** Show a folder as an empty workspace ready for its first message. */
|
|
1532
|
+
function renderWorkspaceLanding(path) {
|
|
1533
|
+
state.selection = { kind: "new", cwd: path };
|
|
1534
|
+
state.snapshot = null;
|
|
1535
|
+
state.fileData = null;
|
|
1536
|
+
state.planOpen = false;
|
|
1537
|
+
state.planSeen = "";
|
|
1538
|
+
closeStream();
|
|
1539
|
+
preferences.clear();
|
|
1540
|
+
renderSidebar();
|
|
1541
|
+
renderWorkspaceChrome();
|
|
1542
|
+
el.controls.innerHTML = "";
|
|
1543
|
+
renderApproval(null);
|
|
1544
|
+
renderPlanCard(null);
|
|
1545
|
+
el.title.textContent = workspaceName(path) || tr("Working directory");
|
|
1546
|
+
el.sub.textContent = path;
|
|
1547
|
+
setComposerMode("new");
|
|
1548
|
+
proUI?.show(null, `new:${path}`);
|
|
1549
|
+
el.messages.innerHTML = "";
|
|
1550
|
+
|
|
1551
|
+
const empty = document.createElement("div");
|
|
1552
|
+
empty.className = "empty workspace-empty";
|
|
1553
|
+
empty.innerHTML =
|
|
1554
|
+
'<span class="empty-mark" aria-hidden="true">' +
|
|
1555
|
+
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
1556
|
+
'<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" />' +
|
|
1557
|
+
"</svg></span>" +
|
|
1558
|
+
`<strong>${escapeHtml(workspaceName(path) || path)}</strong>` +
|
|
1559
|
+
`<span class="workspace-path">${escapeHtml(path)}</span>` +
|
|
1560
|
+
`<p class="workspace-prompt">${escapeHtml(tr("Type a message below to start a conversation in this folder."))}</p>`;
|
|
1561
|
+
const button = document.createElement("button");
|
|
1562
|
+
button.className = "control primary";
|
|
1563
|
+
button.type = "button";
|
|
1564
|
+
button.textContent = tr("New conversation");
|
|
1565
|
+
button.addEventListener("click", () => openNewChatDialog({ cwd: path }));
|
|
1566
|
+
empty.appendChild(button);
|
|
1567
|
+
el.messages.appendChild(empty);
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/** Open a working directory and make it the active sidebar scope. */
|
|
1571
|
+
async function openWorkspace(path, { record = true } = {}) {
|
|
1572
|
+
const target = String(path ?? "").trim();
|
|
1573
|
+
if (!target) return;
|
|
1574
|
+
setWorkspace({ path: target });
|
|
1575
|
+
renderWorkspaceLanding(target);
|
|
1576
|
+
setConversationView(true);
|
|
1577
|
+
if (record) {
|
|
1578
|
+
try {
|
|
1579
|
+
const response = await apiFetch("/api/workspaces", {
|
|
1580
|
+
method: "POST",
|
|
1581
|
+
headers: { "Content-Type": "application/json" },
|
|
1582
|
+
body: JSON.stringify({ path: target }),
|
|
1583
|
+
});
|
|
1584
|
+
const data = await response.json().catch(() => ({}));
|
|
1585
|
+
if (response.ok && Array.isArray(data.workspaces)) {
|
|
1586
|
+
state.workspaces = { ...(state.workspaces ?? {}), workspaces: data.workspaces };
|
|
1587
|
+
}
|
|
1588
|
+
} catch {
|
|
1589
|
+
// Opening the folder still works when remembering it does not.
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
await loadOverview({ silent: true });
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function renderWorkspaceList() {
|
|
1596
|
+
if (!el.workspaceList) return;
|
|
1597
|
+
el.workspaceList.innerHTML = "";
|
|
1598
|
+
const folders = state.workspaces?.workspaces ?? [];
|
|
1599
|
+
if (!folders.length) {
|
|
1600
|
+
const empty = document.createElement("div");
|
|
1601
|
+
empty.className = "empty-side";
|
|
1602
|
+
empty.textContent = tr("No recent folders.");
|
|
1603
|
+
el.workspaceList.appendChild(empty);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
for (const folder of folders) {
|
|
1607
|
+
const item = document.createElement("button");
|
|
1608
|
+
item.className = "workspace-item";
|
|
1609
|
+
item.type = "button";
|
|
1610
|
+
item.title = folder.path;
|
|
1611
|
+
if (state.workspace && pathKey(state.workspace.path) === pathKey(folder.path)) {
|
|
1612
|
+
item.classList.add("selected");
|
|
1613
|
+
}
|
|
1614
|
+
if (!folder.exists) item.classList.add("missing");
|
|
1615
|
+
const count = Number(folder.sessions ?? 0);
|
|
1616
|
+
item.innerHTML =
|
|
1617
|
+
'<span class="workspace-icon" aria-hidden="true">' +
|
|
1618
|
+
'<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>' +
|
|
1619
|
+
"</span>" +
|
|
1620
|
+
`<span class="workspace-info"><strong>${escapeHtml(folder.name || workspaceName(folder.path))}</strong>` +
|
|
1621
|
+
`<small>${escapeHtml(shortenPath(folder.path, 3))}</small></span>` +
|
|
1622
|
+
`<span class="workspace-count">${escapeHtml(tr("{n} conversations", { n: count }))}</span>`;
|
|
1623
|
+
item.addEventListener("click", async () => {
|
|
1624
|
+
if (!folder.exists) {
|
|
1625
|
+
el.workspaceFeedback.textContent = tr("This folder no longer exists.");
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
el.workspaceDialog.close();
|
|
1629
|
+
await openWorkspace(folder.path, { record: false });
|
|
1630
|
+
});
|
|
1631
|
+
el.workspaceList.appendChild(item);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
async function openWorkspaceDialog() {
|
|
1636
|
+
el.workspaceFeedback.textContent = "";
|
|
1637
|
+
if (!el.workspaceDialog.open) el.workspaceDialog.showModal();
|
|
1638
|
+
renderWorkspaceList();
|
|
1639
|
+
try {
|
|
1640
|
+
const response = await apiFetch("/api/workspaces", { cache: "no-store" });
|
|
1641
|
+
const data = await response.json();
|
|
1642
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
1643
|
+
state.workspaces = data;
|
|
1644
|
+
renderWorkspaceList();
|
|
1645
|
+
} catch (error) {
|
|
1646
|
+
el.workspaceFeedback.textContent = tr("Could not open this folder: {error}", { error: error.message });
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
function renderNewChatRecents() {
|
|
1651
|
+
if (!el.newChatRecents) return;
|
|
1652
|
+
el.newChatRecents.innerHTML = "";
|
|
1653
|
+
const folders = (state.workspaces?.workspaces ?? []).filter((folder) => folder.exists !== false).slice(0, 8);
|
|
1654
|
+
if (!folders.length) {
|
|
1655
|
+
const empty = document.createElement("span");
|
|
1656
|
+
empty.className = "recents-empty";
|
|
1657
|
+
empty.textContent = tr("No recent folders.");
|
|
1658
|
+
el.newChatRecents.appendChild(empty);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
for (const folder of folders) {
|
|
1662
|
+
const chip = document.createElement("button");
|
|
1663
|
+
chip.className = "path-chip";
|
|
1664
|
+
chip.type = "button";
|
|
1665
|
+
chip.title = folder.path;
|
|
1666
|
+
chip.textContent = workspaceName(folder.path) || folder.path;
|
|
1667
|
+
if (el.newChatCwd.value.trim() && pathKey(el.newChatCwd.value) === pathKey(folder.path)) {
|
|
1668
|
+
chip.classList.add("selected");
|
|
1669
|
+
}
|
|
1670
|
+
chip.addEventListener("click", () => {
|
|
1671
|
+
el.newChatCwd.value = folder.path;
|
|
1672
|
+
renderNewChatRecents();
|
|
1673
|
+
});
|
|
1674
|
+
el.newChatRecents.appendChild(chip);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
function fillSelect(select, entries, selected) {
|
|
1679
|
+
select.replaceChildren();
|
|
1680
|
+
for (const entry of entries) {
|
|
1681
|
+
const option = document.createElement("option");
|
|
1682
|
+
option.value = entry.value;
|
|
1683
|
+
option.textContent = entry.label;
|
|
1684
|
+
select.appendChild(option);
|
|
1685
|
+
}
|
|
1686
|
+
select.value = entries.some((entry) => entry.value === selected) ? selected : entries[0]?.value ?? "";
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function fillNewChatModels(catalog, selectedModel) {
|
|
1690
|
+
const provider = catalog.providers.find((entry) => entry.name === el.newChatProvider.value) ?? catalog.providers[0];
|
|
1691
|
+
const models = provider?.models ?? [];
|
|
1692
|
+
fillSelect(el.newChatModel, models.map((model) => ({
|
|
1693
|
+
value: model.name,
|
|
1694
|
+
label: model.name + (model.configured ? "" : ` ${tr("(API key missing)")}`),
|
|
1695
|
+
})), selectedModel);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** Offer only the levels the chosen model declares, keeping the draft if valid. */
|
|
1699
|
+
function fillNewChatIntensities(catalog, selected) {
|
|
1700
|
+
const provider = catalog.providers.find((entry) => entry.name === el.newChatProvider.value) ?? catalog.providers[0];
|
|
1701
|
+
const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
|
|
1702
|
+
const levels = Array.isArray(model?.intensities) && model.intensities.length ? model.intensities : catalog.intensities;
|
|
1703
|
+
let wanted = selected;
|
|
1704
|
+
if (!levels.includes(wanted)) {
|
|
1705
|
+
const fallback = catalog.settings?.default_intensity;
|
|
1706
|
+
wanted = levels.includes(fallback) ? fallback : levels.includes("High") ? "High" : levels[0] ?? "";
|
|
1707
|
+
}
|
|
1708
|
+
fillSelect(el.newChatIntensity, levels.map((value) => ({ value, label: value })), wanted);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function updateNewChatNote(catalog) {
|
|
1712
|
+
const provider = catalog?.providers.find((entry) => entry.name === el.newChatProvider.value);
|
|
1713
|
+
const model = provider?.models.find((entry) => entry.name === el.newChatModel.value);
|
|
1714
|
+
if (model && !model.configured) {
|
|
1715
|
+
el.newChatNote.textContent = tr("This model has no API key configured. Add one in the terminal model screen, or choose another model.");
|
|
1716
|
+
} else {
|
|
1717
|
+
el.newChatNote.textContent = "";
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function renderProNewChatPresets(catalog, selected = catalog.pro?.preset) {
|
|
1722
|
+
if (!PRO_MODE) return;
|
|
1723
|
+
const select = document.getElementById("new-chat-pro-preset");
|
|
1724
|
+
if (!select) return;
|
|
1725
|
+
fillSelect(select, (catalog.pro?.presets || []).map(preset => ({
|
|
1726
|
+
value: preset.name, label: uiLanguage() === "zh" ? preset.chinese_name || preset.name : preset.name,
|
|
1727
|
+
})), selected);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** Relabel the open new-conversation sheet without losing its selections. */
|
|
1731
|
+
function refreshNewChatTexts() {
|
|
1732
|
+
if (!el.newChatDialog?.open) return;
|
|
1733
|
+
const catalog = preferences.catalog?.();
|
|
1734
|
+
if (!catalog) return;
|
|
1735
|
+
const provider = el.newChatProvider.value;
|
|
1736
|
+
const model = el.newChatModel.value;
|
|
1737
|
+
const intensity = el.newChatIntensity.value;
|
|
1738
|
+
const approval = el.newChatApproval.value;
|
|
1739
|
+
fillSelect(el.newChatProvider, catalog.providers.map((entry) => ({ value: entry.name, label: entry.name })), provider);
|
|
1740
|
+
fillNewChatModels(catalog, model);
|
|
1741
|
+
fillNewChatIntensities(catalog, intensity);
|
|
1742
|
+
el.newChatApproval.value = approval;
|
|
1743
|
+
updateNewChatNote(catalog);
|
|
1744
|
+
renderProNewChatPresets(catalog, document.getElementById("new-chat-pro-preset")?.value);
|
|
1745
|
+
renderNewChatRecents();
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
async function openNewChatDialog(options = {}) {
|
|
1749
|
+
el.newChatFeedback.textContent = "";
|
|
1750
|
+
if (!el.newChatDialog.open) el.newChatDialog.showModal();
|
|
1751
|
+
let catalog;
|
|
1752
|
+
try {
|
|
1753
|
+
catalog = await preferences.load();
|
|
1754
|
+
const response = await apiFetch("/api/workspaces", { cache: "no-store" });
|
|
1755
|
+
if (response.ok) state.workspaces = await response.json();
|
|
1756
|
+
} catch (error) {
|
|
1757
|
+
el.newChatFeedback.textContent = tr(error.message);
|
|
1758
|
+
}
|
|
1759
|
+
if (!catalog) return;
|
|
1760
|
+
|
|
1761
|
+
const defaults = catalog.selection;
|
|
1762
|
+
fillSelect(el.newChatProvider, catalog.providers.map((provider) => ({ value: provider.name, label: provider.name })), defaults.provider);
|
|
1763
|
+
fillNewChatModels(catalog, defaults.model);
|
|
1764
|
+
fillNewChatIntensities(catalog, defaults.intensity);
|
|
1765
|
+
el.newChatApproval.value = catalog.settings.approval_mode === "auto" ? "auto" : "request";
|
|
1766
|
+
|
|
1767
|
+
const start = String(options.cwd || selectionCwd() || state.workspace?.path || state.workspaces?.cwd || "").trim();
|
|
1768
|
+
el.newChatCwd.value = start;
|
|
1769
|
+
renderNewChatRecents();
|
|
1770
|
+
updateNewChatNote(catalog);
|
|
1771
|
+
renderProNewChatPresets(catalog);
|
|
1772
|
+
if (typeof el.newChatCwd.focus === "function") el.newChatCwd.focus();
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
async function submitNewChat() {
|
|
1776
|
+
if (el.newChatStart.disabled) return;
|
|
1777
|
+
const cwd = el.newChatCwd.value.trim();
|
|
1778
|
+
if (!cwd) {
|
|
1779
|
+
el.newChatFeedback.textContent = tr("working directory is required");
|
|
1780
|
+
el.newChatCwd.focus();
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
const selection = {
|
|
1784
|
+
provider: el.newChatProvider.value,
|
|
1785
|
+
model: el.newChatModel.value,
|
|
1786
|
+
intensity: el.newChatIntensity.value,
|
|
1787
|
+
};
|
|
1788
|
+
el.newChatStart.disabled = true;
|
|
1789
|
+
el.newChatStart.textContent = tr("Creating…");
|
|
1790
|
+
el.newChatFeedback.textContent = "";
|
|
1791
|
+
try {
|
|
1792
|
+
const created = await createConversation(cwd, selection, el.newChatApproval.value,
|
|
1793
|
+
PRO_MODE ? document.getElementById("new-chat-pro-preset")?.value : undefined);
|
|
1794
|
+
el.newChatDialog.close();
|
|
1795
|
+
await selectLive(created.key);
|
|
1796
|
+
setConversationView(true);
|
|
1797
|
+
el.input.focus();
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
el.newChatFeedback.textContent = tr("Could not start the conversation: {error}", { error: error.message });
|
|
1800
|
+
} finally {
|
|
1801
|
+
el.newChatStart.disabled = false;
|
|
1802
|
+
el.newChatStart.textContent = tr("Start conversation");
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/** Create a browser-owned conversation in `cwd` and reveal its folder. */
|
|
1807
|
+
async function createConversation(cwd, selection, approvalMode, proPreset = currentProPreset()) {
|
|
1808
|
+
const payload = { cwd };
|
|
1809
|
+
if (PRO_MODE) payload.pro_preset = proPreset;
|
|
1810
|
+
else if (selection && selection.provider && selection.model) payload.selection = selection;
|
|
1811
|
+
if (approvalMode === "request" || approvalMode === "auto") payload.approval_mode = approvalMode;
|
|
1812
|
+
const response = await apiFetch("/api/conversations", {
|
|
1813
|
+
method: "POST",
|
|
1814
|
+
headers: { "Content-Type": "application/json" },
|
|
1815
|
+
body: JSON.stringify(payload),
|
|
1816
|
+
});
|
|
1817
|
+
const data = await response.json().catch(() => ({}));
|
|
1818
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
1819
|
+
setWorkspace({ path: data.cwd || cwd });
|
|
1820
|
+
await loadOverview({ silent: true });
|
|
1821
|
+
return data;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// ------------------------------------------------------------- folder picker
|
|
1825
|
+
|
|
1826
|
+
function renderFolder() {
|
|
1827
|
+
if (!el.folderList) return;
|
|
1828
|
+
const folder = state.folder;
|
|
1829
|
+
el.folderPath.value = folder.path || "";
|
|
1830
|
+
el.folderUp.disabled = !folder.parent;
|
|
1831
|
+
el.folderUse.disabled = !folder.path || folder.loading;
|
|
1832
|
+
|
|
1833
|
+
el.folderRoots.innerHTML = "";
|
|
1834
|
+
for (const root of folder.roots ?? []) {
|
|
1835
|
+
const button = document.createElement("button");
|
|
1836
|
+
button.className = "root-chip";
|
|
1837
|
+
button.type = "button";
|
|
1838
|
+
button.textContent = root;
|
|
1839
|
+
button.addEventListener("click", () => loadFolder(root));
|
|
1840
|
+
el.folderRoots.appendChild(button);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
el.folderList.innerHTML = "";
|
|
1844
|
+
const entries = folder.entries ?? [];
|
|
1845
|
+
if (!entries.length) {
|
|
1846
|
+
const empty = document.createElement("div");
|
|
1847
|
+
empty.className = "empty-side";
|
|
1848
|
+
empty.textContent = folder.loading ? tr("Loading folders…") : tr("No subfolders.");
|
|
1849
|
+
el.folderList.appendChild(empty);
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
for (const entry of entries) {
|
|
1853
|
+
const item = document.createElement("button");
|
|
1854
|
+
item.className = "folder-item";
|
|
1855
|
+
item.type = "button";
|
|
1856
|
+
item.title = entry.path;
|
|
1857
|
+
item.innerHTML =
|
|
1858
|
+
'<span class="folder-item-icon" aria-hidden="true">' +
|
|
1859
|
+
'<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>' +
|
|
1860
|
+
`</span><span>${escapeHtml(entry.name)}</span>`;
|
|
1861
|
+
item.addEventListener("click", () => loadFolder(entry.path));
|
|
1862
|
+
if (typeof item.addEventListener === "function") {
|
|
1863
|
+
item.addEventListener("dblclick", () => useCurrentFolder());
|
|
1864
|
+
}
|
|
1865
|
+
el.folderList.appendChild(item);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
let folderLoadSeq = 0;
|
|
1870
|
+
|
|
1871
|
+
async function loadFolder(path) {
|
|
1872
|
+
const folder = state.folder;
|
|
1873
|
+
const request = ++folderLoadSeq;
|
|
1874
|
+
folder.loading = true;
|
|
1875
|
+
el.folderFeedback.textContent = tr("Loading folders…");
|
|
1876
|
+
el.folderUse.disabled = true;
|
|
1877
|
+
try {
|
|
1878
|
+
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
|
1879
|
+
const response = await apiFetch(`/api/fs${query}`, { cache: "no-store" });
|
|
1880
|
+
const data = await response.json().catch(() => ({}));
|
|
1881
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
1882
|
+
// A slower, older answer must not replace the folder the user is on now.
|
|
1883
|
+
if (request !== folderLoadSeq) return;
|
|
1884
|
+
state.folder = {
|
|
1885
|
+
...folder,
|
|
1886
|
+
loading: false,
|
|
1887
|
+
path: data.path,
|
|
1888
|
+
parent: data.parent,
|
|
1889
|
+
roots: data.roots ?? [],
|
|
1890
|
+
entries: data.entries ?? [],
|
|
1891
|
+
};
|
|
1892
|
+
el.folderFeedback.textContent = "";
|
|
1893
|
+
renderFolder();
|
|
1894
|
+
if (typeof el.folderPath.focus === "function") el.folderPath.focus();
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
if (request !== folderLoadSeq) return;
|
|
1897
|
+
folder.loading = false;
|
|
1898
|
+
el.folderFeedback.textContent = tr("Could not open this folder: {error}", { error: error.message });
|
|
1899
|
+
el.folderUse.disabled = !state.folder.path;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
function openFolderDialog({ start = "", onPick = null } = {}) {
|
|
1904
|
+
state.folder = {
|
|
1905
|
+
path: "",
|
|
1906
|
+
parent: null,
|
|
1907
|
+
roots: [],
|
|
1908
|
+
entries: [],
|
|
1909
|
+
loading: false,
|
|
1910
|
+
onPick,
|
|
1911
|
+
};
|
|
1912
|
+
el.folderNew.classList.add("hidden");
|
|
1913
|
+
el.folderNewName.value = "";
|
|
1914
|
+
el.folderFeedback.textContent = "";
|
|
1915
|
+
if (!el.folderDialog.open) el.folderDialog.showModal();
|
|
1916
|
+
renderFolder();
|
|
1917
|
+
const from = String(start || state.workspace?.path || state.workspaces?.cwd || "").trim();
|
|
1918
|
+
void loadFolder(from);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function useCurrentFolder() {
|
|
1922
|
+
const path = state.folder.path;
|
|
1923
|
+
if (!path) return;
|
|
1924
|
+
const onPick = state.folder.onPick;
|
|
1925
|
+
el.folderDialog.close();
|
|
1926
|
+
if (typeof onPick === "function") onPick(path);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
async function createFolderFromDialog() {
|
|
1930
|
+
const name = el.folderNewName.value.trim();
|
|
1931
|
+
if (!name) {
|
|
1932
|
+
el.folderFeedback.textContent = tr("Enter a folder name.");
|
|
1933
|
+
el.folderNewName.focus();
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
if (/[\\/]/.test(name)) {
|
|
1937
|
+
el.folderFeedback.textContent = tr("folder name cannot contain a path separator");
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
const target = joinPath(state.folder.path, name);
|
|
1941
|
+
el.folderNewCreate.disabled = true;
|
|
1942
|
+
try {
|
|
1943
|
+
const response = await apiFetch("/api/fs/mkdir", {
|
|
1944
|
+
method: "POST",
|
|
1945
|
+
headers: { "Content-Type": "application/json" },
|
|
1946
|
+
body: JSON.stringify({ path: target }),
|
|
1947
|
+
});
|
|
1948
|
+
const data = await response.json().catch(() => ({}));
|
|
1949
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
1950
|
+
el.folderNew.classList.add("hidden");
|
|
1951
|
+
el.folderNewName.value = "";
|
|
1952
|
+
await loadFolder(data.path || target);
|
|
1953
|
+
el.folderFeedback.textContent = tr("Folder created.");
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
el.folderFeedback.textContent = apiError({ error: error.message }, error.message);
|
|
1956
|
+
} finally {
|
|
1957
|
+
el.folderNewCreate.disabled = false;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
// -------------------------------------------------------------- plan / usage
|
|
1962
|
+
|
|
1963
|
+
function planSignature(plan) {
|
|
1964
|
+
if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0) return "";
|
|
1965
|
+
return `${plan.goal ?? ""}|${plan.steps.map((step) => step.title ?? "").join("|")}`;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function maybeOpenPlan(plan) {
|
|
1969
|
+
const signature = planSignature(plan);
|
|
1970
|
+
if (signature && signature !== state.planSeen) {
|
|
1971
|
+
state.planSeen = signature;
|
|
1972
|
+
state.planOpen = true;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function planToggleButton() {
|
|
1977
|
+
const button = document.createElement("button");
|
|
1978
|
+
button.className = "icon-btn plan-toggle";
|
|
1979
|
+
button.type = "button";
|
|
1980
|
+
button.setAttribute("aria-expanded", String(state.planOpen));
|
|
1981
|
+
button.title = state.planOpen ? tr("Hide plan") : tr("Show plan");
|
|
1982
|
+
button.setAttribute("aria-label", button.title);
|
|
1983
|
+
button.innerHTML =
|
|
1984
|
+
'<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>';
|
|
1985
|
+
button.addEventListener("click", () => {
|
|
1986
|
+
state.planOpen = !state.planOpen;
|
|
1987
|
+
button.classList.toggle("active", state.planOpen);
|
|
1988
|
+
button.setAttribute("aria-expanded", String(state.planOpen));
|
|
1989
|
+
button.title = state.planOpen ? tr("Hide plan") : tr("Show plan");
|
|
1990
|
+
renderPlanCard(state.snapshot ?? state.fileData ?? null);
|
|
1991
|
+
});
|
|
1992
|
+
button.classList.toggle("active", state.planOpen);
|
|
1993
|
+
return button;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function planStatusLabel(status) {
|
|
1997
|
+
if (status === "done") return tr("Done");
|
|
1998
|
+
if (status === "active") return tr("Active");
|
|
1999
|
+
if (status === "failed") return tr("Failed");
|
|
2000
|
+
return tr("Pending");
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
function renderPlanCard(source) {
|
|
2004
|
+
if (!el.planCard) return;
|
|
2005
|
+
const plan = source && source.plan && Array.isArray(source.plan.steps) && source.plan.steps.length ? source.plan : null;
|
|
2006
|
+
const tokens = source && source.tokens ? source.tokens : null;
|
|
2007
|
+
const hasUsage = tokens && (Number(tokens.prompt) > 0 || Number(tokens.completion) > 0);
|
|
2008
|
+
if (!state.planOpen || (!plan && !hasUsage)) {
|
|
2009
|
+
el.planCard.classList.add("hidden");
|
|
2010
|
+
el.planCard.replaceChildren();
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const card = document.createElement("div");
|
|
2015
|
+
card.className = "plan-card-inner";
|
|
2016
|
+
const head = document.createElement("div");
|
|
2017
|
+
head.className = "plan-card-head";
|
|
2018
|
+
const title = document.createElement("strong");
|
|
2019
|
+
title.textContent = plan ? tr("Plan") : tr("Usage");
|
|
2020
|
+
const close = document.createElement("button");
|
|
2021
|
+
close.className = "icon-btn";
|
|
2022
|
+
close.type = "button";
|
|
2023
|
+
close.setAttribute("aria-label", tr("Close"));
|
|
2024
|
+
close.innerHTML =
|
|
2025
|
+
'<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>';
|
|
2026
|
+
close.addEventListener("click", () => {
|
|
2027
|
+
state.planOpen = false;
|
|
2028
|
+
renderPlanCard(source);
|
|
2029
|
+
});
|
|
2030
|
+
head.append(title, close);
|
|
2031
|
+
card.appendChild(head);
|
|
2032
|
+
|
|
2033
|
+
if (plan) {
|
|
2034
|
+
if (plan.goal) {
|
|
2035
|
+
const goal = document.createElement("div");
|
|
2036
|
+
goal.className = "plan-goal";
|
|
2037
|
+
goal.textContent = plan.goal;
|
|
2038
|
+
card.appendChild(goal);
|
|
2039
|
+
}
|
|
2040
|
+
const steps = document.createElement("div");
|
|
2041
|
+
steps.className = "plan-steps";
|
|
2042
|
+
let done = 0;
|
|
2043
|
+
for (const step of plan.steps) {
|
|
2044
|
+
const status = ["done", "active", "failed", "pending"].includes(step.status) ? step.status : "pending";
|
|
2045
|
+
if (status === "done") done += 1;
|
|
2046
|
+
const row = document.createElement("div");
|
|
2047
|
+
row.className = `plan-step ${status}`;
|
|
2048
|
+
const node = document.createElement("span");
|
|
2049
|
+
node.className = "plan-node";
|
|
2050
|
+
node.setAttribute("aria-hidden", "true");
|
|
2051
|
+
const body = document.createElement("div");
|
|
2052
|
+
body.className = "plan-step-body";
|
|
2053
|
+
const stepTitle = document.createElement("strong");
|
|
2054
|
+
stepTitle.textContent = step.title || "";
|
|
2055
|
+
const stateLabel = document.createElement("span");
|
|
2056
|
+
stateLabel.className = "plan-step-state";
|
|
2057
|
+
stateLabel.textContent = planStatusLabel(status);
|
|
2058
|
+
const titleRow = document.createElement("div");
|
|
2059
|
+
titleRow.className = "plan-step-title";
|
|
2060
|
+
titleRow.append(stepTitle, stateLabel);
|
|
2061
|
+
body.appendChild(titleRow);
|
|
2062
|
+
if (step.detail) {
|
|
2063
|
+
const detail = document.createElement("div");
|
|
2064
|
+
detail.className = "plan-step-detail";
|
|
2065
|
+
detail.textContent = step.detail;
|
|
2066
|
+
body.appendChild(detail);
|
|
2067
|
+
}
|
|
2068
|
+
row.append(node, body);
|
|
2069
|
+
steps.appendChild(row);
|
|
2070
|
+
}
|
|
2071
|
+
card.appendChild(steps);
|
|
2072
|
+
const meta = document.createElement("div");
|
|
2073
|
+
meta.className = "plan-meta";
|
|
2074
|
+
const stopped = plan.stopped ? ` · ${tr("Stopped")}` : "";
|
|
2075
|
+
meta.textContent = tr("{done} of {total} steps done", { done, total: plan.steps.length }) + stopped;
|
|
2076
|
+
card.appendChild(meta);
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
if (hasUsage) {
|
|
2080
|
+
const usage = document.createElement("div");
|
|
2081
|
+
usage.className = "plan-usage";
|
|
2082
|
+
const rows = [
|
|
2083
|
+
[tr("Prompt"), Number(tokens.prompt)],
|
|
2084
|
+
[tr("Completion"), Number(tokens.completion)],
|
|
2085
|
+
[tr("Total"), Number(tokens.prompt) + Number(tokens.completion)],
|
|
2086
|
+
];
|
|
2087
|
+
for (const [label, value] of rows) {
|
|
2088
|
+
const row = document.createElement("div");
|
|
2089
|
+
row.className = "plan-usage-row";
|
|
2090
|
+
const name = document.createElement("span");
|
|
2091
|
+
name.textContent = label;
|
|
2092
|
+
const count = document.createElement("strong");
|
|
2093
|
+
count.textContent = value.toLocaleString(localeTag());
|
|
2094
|
+
row.append(name, count);
|
|
2095
|
+
usage.appendChild(row);
|
|
2096
|
+
}
|
|
2097
|
+
card.appendChild(usage);
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
el.planCard.replaceChildren(card);
|
|
2101
|
+
el.planCard.classList.remove("hidden");
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// ------------------------------------------------------------------- actions
|
|
2105
|
+
|
|
2106
|
+
async function clearConversation(key) {
|
|
2107
|
+
try {
|
|
2108
|
+
const response = await apiFetch(`/api/live/${encodeURIComponent(key)}/clear`, { method: "POST" });
|
|
2109
|
+
const data = await response.json().catch(() => ({}));
|
|
2110
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2111
|
+
if (state.selection?.kind === "live" && state.selection.key === key) {
|
|
2112
|
+
state.snapshot = data.snapshot ?? state.snapshot;
|
|
2113
|
+
// The cleared conversation has no transcript file until its next message.
|
|
2114
|
+
state.lastSessionId = null;
|
|
2115
|
+
state.planOpen = false;
|
|
2116
|
+
state.planSeen = "";
|
|
2117
|
+
if (state.snapshot) renderLive();
|
|
2118
|
+
if (state.snapshot) renderPlanCard(null);
|
|
2119
|
+
el.sub.textContent = tr("Conversation cleared.");
|
|
2120
|
+
}
|
|
2121
|
+
} catch (error) {
|
|
2122
|
+
el.sub.textContent = tr("Could not clear this conversation: {error}", { error: error.message });
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
async function deleteConversation(id, button) {
|
|
2127
|
+
if (!confirmAction(tr("Delete this conversation? Its saved history will be removed."))) return;
|
|
2128
|
+
if (button) button.disabled = true;
|
|
2129
|
+
try {
|
|
2130
|
+
const response = await apiFetch(`/api/session/${encodeURIComponent(id)}/delete`, { method: "POST" });
|
|
2131
|
+
const data = await response.json().catch(() => ({}));
|
|
2132
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2133
|
+
showHome();
|
|
2134
|
+
await loadOverview();
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
if (button) button.disabled = false;
|
|
2137
|
+
el.sub.textContent = tr("Could not delete this conversation: {error}", { error: error.message });
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// -------------------------------------------------------------- other agents
|
|
2142
|
+
|
|
2143
|
+
const OTHER_AGENTS = [
|
|
2144
|
+
{ id: "claude", label: "Claude Code" },
|
|
2145
|
+
{ id: "codex", label: "Codex" },
|
|
2146
|
+
{ id: "opencode", label: "OpenCode" },
|
|
2147
|
+
];
|
|
2148
|
+
|
|
2149
|
+
function agentLabel(agent) {
|
|
2150
|
+
return OTHER_AGENTS.find((entry) => entry.id === agent)?.label ?? agent;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/** Working directory the other-agent lists are scoped to. */
|
|
2154
|
+
async function agentsScopeCwd() {
|
|
2155
|
+
if (!state.workspaces) {
|
|
2156
|
+
try {
|
|
2157
|
+
const response = await apiFetch("/api/workspaces", { cache: "no-store" });
|
|
2158
|
+
if (response.ok) state.workspaces = await response.json();
|
|
2159
|
+
} catch {
|
|
2160
|
+
// The dialog can still show a readable error below.
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
return state.workspace?.path || selectionCwd() || state.agentData?.cwd || state.workspaces?.cwd || "";
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
function renderAgentList(groups) {
|
|
2167
|
+
el.agentsList.innerHTML = "";
|
|
2168
|
+
const total = groups.reduce((count, group) => count + group.sessions.length, 0);
|
|
2169
|
+
if (total === 0) {
|
|
2170
|
+
const empty = document.createElement("div");
|
|
2171
|
+
empty.className = "empty-side";
|
|
2172
|
+
empty.textContent = tr("No saved sessions from other agents in this folder.");
|
|
2173
|
+
el.agentsList.appendChild(empty);
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
for (const group of groups) {
|
|
2177
|
+
if (!group.sessions.length) continue;
|
|
2178
|
+
const section = document.createElement("section");
|
|
2179
|
+
section.className = "agent-section";
|
|
2180
|
+
const title = document.createElement("h3");
|
|
2181
|
+
title.textContent = group.label;
|
|
2182
|
+
section.appendChild(title);
|
|
2183
|
+
for (const session of group.sessions) {
|
|
2184
|
+
const item = document.createElement("button");
|
|
2185
|
+
item.className = "agent-session";
|
|
2186
|
+
item.type = "button";
|
|
2187
|
+
item.title = session.title || session.id;
|
|
2188
|
+
item.innerHTML =
|
|
2189
|
+
'<span class="agent-session-body">' +
|
|
2190
|
+
`<span class="agent-session-title">${escapeHtml(session.title || tr("Conversation"))}</span>` +
|
|
2191
|
+
`<span class="agent-session-meta">${escapeHtml([session.model, formatTime(session.updated_at)].filter(Boolean).join(" · "))}</span>` +
|
|
2192
|
+
"</span>";
|
|
2193
|
+
item.addEventListener("click", () => {
|
|
2194
|
+
el.agentsDialog.close();
|
|
2195
|
+
void selectAgentSession(group.id, session.id, session.title || "");
|
|
2196
|
+
});
|
|
2197
|
+
section.appendChild(item);
|
|
2198
|
+
}
|
|
2199
|
+
el.agentsList.appendChild(section);
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
async function openAgentsDialog() {
|
|
2204
|
+
el.agentsFeedback.textContent = tr("Loading…");
|
|
2205
|
+
el.agentsList.innerHTML = "";
|
|
2206
|
+
if (!el.agentsDialog.open) el.agentsDialog.showModal();
|
|
2207
|
+
const cwd = await agentsScopeCwd();
|
|
2208
|
+
el.agentsCwd.textContent = cwd;
|
|
2209
|
+
try {
|
|
2210
|
+
const groups = await Promise.all(OTHER_AGENTS.map(async (agent) => {
|
|
2211
|
+
const response = await apiFetch(`/api/agents/${agent.id}/sessions?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
|
|
2212
|
+
const data = await response.json().catch(() => ({}));
|
|
2213
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2214
|
+
return { ...agent, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
|
|
2215
|
+
}));
|
|
2216
|
+
el.agentsFeedback.textContent = "";
|
|
2217
|
+
renderAgentList(groups);
|
|
2218
|
+
} catch (error) {
|
|
2219
|
+
el.agentsFeedback.textContent = tr("Could not load other agents' history: {error}", { error: error.message });
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function renderAgentView(data) {
|
|
2224
|
+
const messages = Array.isArray(data.messages) ? data.messages : [];
|
|
2225
|
+
const label = agentLabel(state.selection?.agent ?? data.agent ?? "");
|
|
2226
|
+
el.title.textContent = state.selection?.title || firstUserMessage(messages) || tr("Conversation");
|
|
2227
|
+
el.sub.textContent = [label, data.cwd || state.agentData?.cwd].filter(Boolean).join(" · ");
|
|
2228
|
+
renderMessageList(messages, null, { readOnly: true, active: false });
|
|
2229
|
+
el.controls.innerHTML = "";
|
|
2230
|
+
const badge = document.createElement("span");
|
|
2231
|
+
badge.className = "badge readonly";
|
|
2232
|
+
badge.textContent = tr("Read-only");
|
|
2233
|
+
el.controls.appendChild(badge);
|
|
2234
|
+
const source = document.createElement("span");
|
|
2235
|
+
source.className = "badge readonly";
|
|
2236
|
+
source.textContent = label;
|
|
2237
|
+
el.controls.appendChild(source);
|
|
2238
|
+
renderApproval(null);
|
|
2239
|
+
renderPlanCard(null);
|
|
2240
|
+
hideComposer();
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
async function selectAgentSession(agent, id, title) {
|
|
2244
|
+
closeStream();
|
|
2245
|
+
state.selection = { kind: "agent", agent, id, title };
|
|
2246
|
+
state.snapshot = null;
|
|
2247
|
+
state.fileData = null;
|
|
2248
|
+
state.agentData = null;
|
|
2249
|
+
state.planOpen = false;
|
|
2250
|
+
state.planSeen = "";
|
|
2251
|
+
preferences.clear();
|
|
2252
|
+
renderSidebar();
|
|
2253
|
+
el.controls.innerHTML = "";
|
|
2254
|
+
renderApproval(null);
|
|
2255
|
+
renderPlanCard(null);
|
|
2256
|
+
hideComposer();
|
|
2257
|
+
el.title.textContent = tr("Loading…");
|
|
2258
|
+
el.sub.textContent = title || "";
|
|
2259
|
+
resetMessagePane(tr("Loading…"));
|
|
2260
|
+
setConversationView(true);
|
|
2261
|
+
try {
|
|
2262
|
+
const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}`, { cache: "no-store" });
|
|
2263
|
+
const data = await response.json().catch(() => ({}));
|
|
2264
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2265
|
+
state.agentData = { ...data, agent, cwd: state.agentData?.cwd || state.workspace?.path || "" };
|
|
2266
|
+
renderAgentView(state.agentData);
|
|
2267
|
+
} catch (error) {
|
|
2268
|
+
el.title.textContent = title || tr("Conversation");
|
|
2269
|
+
el.sub.textContent = tr("Could not load this session: {error}", { error: error.message });
|
|
2270
|
+
resetMessagePane(tr("Could not load this session: {error}", { error: error.message }));
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// ------------------------------------------------------------------ language
|
|
2275
|
+
|
|
2276
|
+
function syncLanguageSelects(language) {
|
|
2277
|
+
if (el.languageSelect) el.languageSelect.value = language;
|
|
2278
|
+
const settingsLanguage = document.getElementById("settings-language");
|
|
2279
|
+
if (settingsLanguage) settingsLanguage.value = language;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
async function changeInterfaceLanguage(language, { persist = true } = {}) {
|
|
2283
|
+
const normalized = String(language ?? "").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
2284
|
+
setUiLanguage(normalized);
|
|
2285
|
+
writeStorage(modeStorageKey("neutrino.language"), normalized);
|
|
2286
|
+
applyStaticTranslations();
|
|
2287
|
+
syncLanguageSelects(normalized);
|
|
2288
|
+
if (state.status) setStatus(state.status.key, state.status.kind);
|
|
2289
|
+
preferences.refreshTexts?.();
|
|
2290
|
+
proUI?.refresh();
|
|
2291
|
+
refreshNewChatTexts();
|
|
2292
|
+
renderWorkspaceChrome();
|
|
2293
|
+
renderSidebar();
|
|
2294
|
+
const kind = state.selection?.kind;
|
|
2295
|
+
if (kind === "live" && state.snapshot) renderLive();
|
|
2296
|
+
else if (kind === "file" && state.fileData) renderFile(state.fileData);
|
|
2297
|
+
else if (kind === "agent" && state.agentData) renderAgentView(state.agentData);
|
|
2298
|
+
else if (kind === "new" && state.selection.cwd) renderWorkspaceLanding(state.selection.cwd);
|
|
2299
|
+
else if (!state.selection) renderHomeEmpty();
|
|
2300
|
+
if (kind === "live") setComposerMode("live");
|
|
2301
|
+
else if (kind === "file") setComposerMode("resume");
|
|
2302
|
+
else if (kind === "new") setComposerMode("new");
|
|
2303
|
+
else if (!state.selection) hideComposer();
|
|
2304
|
+
if (persist) {
|
|
2305
|
+
try {
|
|
2306
|
+
await apiFetch("/api/settings", {
|
|
2307
|
+
method: "POST",
|
|
2308
|
+
headers: { "Content-Type": "application/json" },
|
|
2309
|
+
body: JSON.stringify({ settings: { language: normalized } }),
|
|
2310
|
+
});
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
console.error(error);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function initializeLanguage() {
|
|
2318
|
+
const stored = readStorage(modeStorageKey("neutrino.language"));
|
|
2319
|
+
if (stored) setUiLanguage(stored);
|
|
2320
|
+
applyStaticTranslations();
|
|
2321
|
+
syncLanguageSelects(uiLanguage());
|
|
2322
|
+
if (state.status) setStatus(state.status.key, state.status.kind);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// ------------------------------------------------------------------ pro mode
|
|
2326
|
+
//
|
|
2327
|
+
// Both workspaces reuse this console. Clicking the orbit badge on the normal
|
|
2328
|
+
// console starts a short wordmark curtain; the recorded start time lets /pro
|
|
2329
|
+
// finish the same animation instead of flashing the freshly loaded page.
|
|
2330
|
+
|
|
2331
|
+
const PRO_TRANSITION_KEY = "neutrino.pro.transition";
|
|
2332
|
+
const PRO_TIP_KEY = "neutrino.pro.tip.dismissed";
|
|
2333
|
+
const PRO_TRANSITION_FADE_MS = 350; // curtain fade in / fade out
|
|
2334
|
+
const PRO_TRANSITION_COVER_MS = 450; // navigate once the curtain is fully opaque
|
|
2335
|
+
const PRO_TRANSITION_HOLD_MS = 3000; // wordmark stays up until 3s after the switch began
|
|
2336
|
+
const PRO_TRANSITION_RESUME_MS = 8000; // older records are stale and never replayed
|
|
2337
|
+
const PRO_LOGO_MARK = '<img class="pro-logo-mark" src="/neutrino.png" alt="Neutrino Pro">';
|
|
2338
|
+
|
|
2339
|
+
let proTransitionStarted = false;
|
|
2340
|
+
|
|
2341
|
+
function proCurtain() {
|
|
2342
|
+
const curtain = document.createElement("div");
|
|
2343
|
+
curtain.className = "pro-curtain";
|
|
2344
|
+
curtain.setAttribute("aria-hidden", "true");
|
|
2345
|
+
const image = document.createElement("img");
|
|
2346
|
+
image.src = "/neutrino-pro.gif";
|
|
2347
|
+
image.alt = "";
|
|
2348
|
+
curtain.appendChild(image);
|
|
2349
|
+
return curtain;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/** Timers exist in the browser; the test sandboxes load this file without them. */
|
|
2353
|
+
function proDelay(callback, ms) {
|
|
2354
|
+
if (typeof setTimeout === "function") setTimeout(callback, ms);
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function startProTransition() {
|
|
2358
|
+
if (PRO_MODE || proTransitionStarted) return;
|
|
2359
|
+
proTransitionStarted = true;
|
|
2360
|
+
const started = Date.now();
|
|
2361
|
+
try {
|
|
2362
|
+
sessionStorage.setItem(PRO_TRANSITION_KEY, String(started));
|
|
2363
|
+
} catch { /* Storage may be blocked; navigation still works. */ }
|
|
2364
|
+
|
|
2365
|
+
const curtain = proCurtain();
|
|
2366
|
+
curtain.style.transition = `opacity ${PRO_TRANSITION_FADE_MS}ms ease`;
|
|
2367
|
+
document.body.appendChild(curtain);
|
|
2368
|
+
// Commit the transparent first frame, then let the style transition fade in.
|
|
2369
|
+
void curtain.offsetWidth;
|
|
2370
|
+
curtain.classList.add("pro-curtain-visible");
|
|
2371
|
+
|
|
2372
|
+
proDelay(() => {
|
|
2373
|
+
try { location.assign("/pro"); } catch { location.href = "/pro"; }
|
|
2374
|
+
}, PRO_TRANSITION_COVER_MS);
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
/** Remove every switch curtain left in this document. */
|
|
2378
|
+
function removeProCurtains() {
|
|
2379
|
+
if (typeof document.querySelectorAll !== "function") return;
|
|
2380
|
+
for (const curtain of document.querySelectorAll(".pro-curtain")) curtain.remove?.();
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
/**
|
|
2384
|
+
* Drop the one-shot switch chrome.
|
|
2385
|
+
*
|
|
2386
|
+
* The curtain lives in the document that navigated away, so the browser's
|
|
2387
|
+
* back/forward cache can restore it fully opaque — a Back or Forward would
|
|
2388
|
+
* otherwise land on that frozen frame. `clearRecord` stays false while this
|
|
2389
|
+
* document is being hidden for /pro: the destination still needs the recorded
|
|
2390
|
+
* start time to finish the same animation.
|
|
2391
|
+
*/
|
|
2392
|
+
function resetProTransition({ clearRecord = true } = {}) {
|
|
2393
|
+
removeProCurtains();
|
|
2394
|
+
document.documentElement?.classList?.remove?.("pro-transitioning");
|
|
2395
|
+
proTransitionStarted = false;
|
|
2396
|
+
if (!clearRecord) return;
|
|
2397
|
+
try { sessionStorage.removeItem(PRO_TRANSITION_KEY); } catch { /* Storage may be blocked. */ }
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/** Undo the switch chrome when the browser restores either page from bfcache. */
|
|
2401
|
+
function watchProNavigation() {
|
|
2402
|
+
if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
|
|
2403
|
+
window.addEventListener("pagehide", (event) => {
|
|
2404
|
+
if (!event?.persisted) return;
|
|
2405
|
+
// The page is entering the back/forward cache: clean the snapshot, but
|
|
2406
|
+
// keep the record for the /pro page that is currently loading.
|
|
2407
|
+
resetProTransition({ clearRecord: false });
|
|
2408
|
+
});
|
|
2409
|
+
window.addEventListener("pageshow", (event) => {
|
|
2410
|
+
if (!event?.persisted) return;
|
|
2411
|
+
// Restored by Back/Forward: any curtain captured in the snapshot is stale.
|
|
2412
|
+
resetProTransition();
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/** Consume a just-recorded switch and finish the animation on /pro. */
|
|
2417
|
+
function resumeProTransition() {
|
|
2418
|
+
if (!PRO_MODE) return;
|
|
2419
|
+
let started = 0;
|
|
2420
|
+
try { started = Number(sessionStorage.getItem(PRO_TRANSITION_KEY)) || 0; } catch { /* Storage may be blocked. */ }
|
|
2421
|
+
// Consume the record even when stale: direct visits never show a curtain.
|
|
2422
|
+
if (started) {
|
|
2423
|
+
try { sessionStorage.removeItem(PRO_TRANSITION_KEY); } catch { /* Ignore. */ }
|
|
2424
|
+
}
|
|
2425
|
+
const now = Date.now();
|
|
2426
|
+
const age = now - started;
|
|
2427
|
+
if (!started || age < 0 || age > PRO_TRANSITION_RESUME_MS) {
|
|
2428
|
+
document.documentElement.classList.remove("pro-transitioning");
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
const curtain = proCurtain();
|
|
2433
|
+
// Start fully opaque on the first frame so the new page never flashes through.
|
|
2434
|
+
curtain.style.transition = "none";
|
|
2435
|
+
curtain.style.opacity = "1";
|
|
2436
|
+
document.body.appendChild(curtain);
|
|
2437
|
+
// The real curtain is up: retire the pre-paint cover from index.html so it
|
|
2438
|
+
// cannot remain over the page after the fade completes.
|
|
2439
|
+
document.documentElement.classList.remove("pro-transitioning");
|
|
2440
|
+
|
|
2441
|
+
proDelay(() => {
|
|
2442
|
+
curtain.style.transition = `opacity ${PRO_TRANSITION_FADE_MS}ms ease`;
|
|
2443
|
+
curtain.style.opacity = "0";
|
|
2444
|
+
proDelay(() => curtain.remove(), PRO_TRANSITION_FADE_MS);
|
|
2445
|
+
}, Math.max(0, started + PRO_TRANSITION_HOLD_MS - now));
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/** In Pro mode the badge is a static "you are here" mark, not a control. */
|
|
2449
|
+
function applyProBrand() {
|
|
2450
|
+
document.title = "Neutrino Pro";
|
|
2451
|
+
if (el.brandName) el.brandName.textContent = "Neutrino Pro";
|
|
2452
|
+
const logo = el.brandLogo;
|
|
2453
|
+
if (!logo) return;
|
|
2454
|
+
logo.innerHTML = PRO_LOGO_MARK;
|
|
2455
|
+
logo.classList.add("pro-logo");
|
|
2456
|
+
logo.tabIndex = -1;
|
|
2457
|
+
// Drop the i18n annotations too, or a language switch restores the tooltip.
|
|
2458
|
+
const annotations = ["title", "aria-label", "data-i18n-title", "data-i18n-aria-label"];
|
|
2459
|
+
for (const attribute of annotations) logo.removeAttribute?.(attribute);
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
/** The blue tip explains the badge on the normal console; Pro never shows it. */
|
|
2463
|
+
function initializeProTip() {
|
|
2464
|
+
if (PRO_MODE || !el.proTip) return;
|
|
2465
|
+
if (readStorage(PRO_TIP_KEY) === "1") return;
|
|
2466
|
+
el.proTip.classList.remove("hidden");
|
|
2467
|
+
el.proTipClose?.addEventListener("click", () => {
|
|
2468
|
+
writeStorage(PRO_TIP_KEY, "1");
|
|
2469
|
+
el.proTip.classList.add("hidden");
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
/** The Pro workspace's top-right escape hatch back to the regular console. */
|
|
2474
|
+
function initializeProExit() {
|
|
2475
|
+
if (!el.proExit) return;
|
|
2476
|
+
el.proExit.classList.remove("hidden");
|
|
2477
|
+
el.proExit.addEventListener("click", () => {
|
|
2478
|
+
el.proExit.disabled = true;
|
|
2479
|
+
try {
|
|
2480
|
+
location.assign("/");
|
|
2481
|
+
} catch {
|
|
2482
|
+
location.href = "/";
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/** Wire the shared brand and either start the switch or resume its animation. */
|
|
2488
|
+
function initializeProWorkspace() {
|
|
2489
|
+
watchProNavigation();
|
|
2490
|
+
if (PRO_MODE) {
|
|
2491
|
+
applyProBrand();
|
|
2492
|
+
initializeProExit();
|
|
2493
|
+
resumeProTransition();
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
// A switch that was interrupted (Stop, Back, a failed navigation) can leave
|
|
2497
|
+
// the record and the pre-paint cover behind. Neither may ever affect the
|
|
2498
|
+
// regular console, so clear them before wiring the badge.
|
|
2499
|
+
resetProTransition();
|
|
2500
|
+
el.brandLogo?.addEventListener("click", startProTransition);
|
|
2501
|
+
initializeProTip();
|
|
2502
|
+
}
|
|
2503
|
+
|
|
1131
2504
|
// --------------------------------------------------------------------- init
|
|
1132
2505
|
|
|
1133
2506
|
el.refresh.addEventListener("click", () => loadOverview());
|
|
@@ -1141,8 +2514,110 @@ el.input.addEventListener("keydown", (event) => {
|
|
|
1141
2514
|
}
|
|
1142
2515
|
});
|
|
1143
2516
|
|
|
2517
|
+
el.newChat.addEventListener("click", () => openNewChatDialog());
|
|
2518
|
+
if (el.emptyNewChat) el.emptyNewChat.addEventListener("click", () => openNewChatDialog());
|
|
2519
|
+
el.workspaceButton.addEventListener("click", openWorkspaceDialog);
|
|
2520
|
+
el.workspaceClose.addEventListener("click", () => el.workspaceDialog.close());
|
|
2521
|
+
el.workspaceAll.addEventListener("click", () => {
|
|
2522
|
+
el.workspaceDialog.close();
|
|
2523
|
+
setWorkspace(null);
|
|
2524
|
+
showHome();
|
|
2525
|
+
});
|
|
2526
|
+
el.workspaceAdd.addEventListener("click", () => {
|
|
2527
|
+
el.workspaceDialog.close();
|
|
2528
|
+
openFolderDialog({ start: state.workspace?.path || state.workspaces?.cwd || "", onPick: (path) => openWorkspace(path) });
|
|
2529
|
+
});
|
|
2530
|
+
el.agentsOpen.addEventListener("click", () => openAgentsDialog());
|
|
2531
|
+
el.agentsClose.addEventListener("click", () => el.agentsDialog.close());
|
|
2532
|
+
el.agentsDialog.addEventListener("cancel", (event) => {
|
|
2533
|
+
event.preventDefault();
|
|
2534
|
+
el.agentsDialog.close();
|
|
2535
|
+
});
|
|
2536
|
+
el.languageSelect?.addEventListener("change", () => changeInterfaceLanguage(el.languageSelect.value));
|
|
2537
|
+
|
|
2538
|
+
el.newChatClose.addEventListener("click", () => el.newChatDialog.close());
|
|
2539
|
+
el.newChatCancel.addEventListener("click", () => el.newChatDialog.close());
|
|
2540
|
+
el.newChatDialog.addEventListener("cancel", (event) => {
|
|
2541
|
+
event.preventDefault();
|
|
2542
|
+
el.newChatDialog.close();
|
|
2543
|
+
});
|
|
2544
|
+
el.newChatForm.addEventListener("submit", (event) => {
|
|
2545
|
+
event.preventDefault();
|
|
2546
|
+
submitNewChat();
|
|
2547
|
+
});
|
|
2548
|
+
el.newChatBrowse.addEventListener("click", () => {
|
|
2549
|
+
openFolderDialog({
|
|
2550
|
+
start: el.newChatCwd.value.trim() || state.workspace?.path || "",
|
|
2551
|
+
onPick: (path) => {
|
|
2552
|
+
el.newChatCwd.value = path;
|
|
2553
|
+
renderNewChatRecents();
|
|
2554
|
+
},
|
|
2555
|
+
});
|
|
2556
|
+
});
|
|
2557
|
+
el.newChatCwd.addEventListener("input", renderNewChatRecents);
|
|
2558
|
+
el.newChatProvider.addEventListener("change", () => {
|
|
2559
|
+
const catalog = preferences.catalog?.();
|
|
2560
|
+
if (catalog) {
|
|
2561
|
+
fillNewChatModels(catalog, "");
|
|
2562
|
+
fillNewChatIntensities(catalog, el.newChatIntensity.value);
|
|
2563
|
+
updateNewChatNote(catalog);
|
|
2564
|
+
}
|
|
2565
|
+
});
|
|
2566
|
+
el.newChatModel.addEventListener("change", () => {
|
|
2567
|
+
const catalog = preferences.catalog?.();
|
|
2568
|
+
if (catalog) {
|
|
2569
|
+
fillNewChatIntensities(catalog, el.newChatIntensity.value);
|
|
2570
|
+
updateNewChatNote(catalog);
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
el.folderClose.addEventListener("click", () => el.folderDialog.close());
|
|
2575
|
+
el.folderCancel.addEventListener("click", () => el.folderDialog.close());
|
|
2576
|
+
el.folderUse.addEventListener("click", useCurrentFolder);
|
|
2577
|
+
el.folderUp.addEventListener("click", () => {
|
|
2578
|
+
if (state.folder.parent) void loadFolder(state.folder.parent);
|
|
2579
|
+
});
|
|
2580
|
+
el.folderGo.addEventListener("click", () => void loadFolder(el.folderPath.value.trim()));
|
|
2581
|
+
el.folderPath.addEventListener("keydown", (event) => {
|
|
2582
|
+
if (event.key === "Enter") {
|
|
2583
|
+
event.preventDefault();
|
|
2584
|
+
void loadFolder(el.folderPath.value.trim());
|
|
2585
|
+
}
|
|
2586
|
+
});
|
|
2587
|
+
el.folderMkdir.addEventListener("click", () => {
|
|
2588
|
+
el.folderNew.classList.remove("hidden");
|
|
2589
|
+
el.folderFeedback.textContent = "";
|
|
2590
|
+
el.folderNewName.value = "";
|
|
2591
|
+
el.folderNewName.focus();
|
|
2592
|
+
});
|
|
2593
|
+
el.folderNewCreate.addEventListener("click", () => void createFolderFromDialog());
|
|
2594
|
+
el.folderNewCancel.addEventListener("click", () => el.folderNew.classList.add("hidden"));
|
|
2595
|
+
el.folderNewName.addEventListener("keydown", (event) => {
|
|
2596
|
+
if (event.key === "Enter") {
|
|
2597
|
+
event.preventDefault();
|
|
2598
|
+
void createFolderFromDialog();
|
|
2599
|
+
}
|
|
2600
|
+
if (event.key === "Escape") el.folderNew.classList.add("hidden");
|
|
2601
|
+
});
|
|
2602
|
+
el.folderDialog.addEventListener("cancel", (event) => {
|
|
2603
|
+
event.preventDefault();
|
|
2604
|
+
el.folderDialog.close();
|
|
2605
|
+
});
|
|
2606
|
+
|
|
1144
2607
|
window.addEventListener("beforeunload", closeStream);
|
|
1145
2608
|
|
|
1146
|
-
|
|
1147
|
-
|
|
2609
|
+
initializeLanguage();
|
|
2610
|
+
initializeProWorkspace();
|
|
2611
|
+
preferences.load()
|
|
2612
|
+
.then((config) => {
|
|
2613
|
+
const language = config?.settings?.language;
|
|
2614
|
+
if (language && language !== uiLanguage()) return changeInterfaceLanguage(language, { persist: false });
|
|
2615
|
+
return undefined;
|
|
2616
|
+
})
|
|
2617
|
+
.catch(error => console.error(error));
|
|
2618
|
+
loadOverview().then(() => {
|
|
2619
|
+
// Reopen the folder the user had open last time, without stealing a click
|
|
2620
|
+
// that landed on a saved conversation while the overview was loading.
|
|
2621
|
+
if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
|
|
2622
|
+
});
|
|
1148
2623
|
setInterval(() => loadOverview({ silent: true }), REFRESH_MS);
|