@xingjisoft/neutrino 0.4.0 → 0.5.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 +33 -397
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- package/dist/agents.js +22 -3
- package/dist/app.js +218 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +200 -42
- package/dist/config.js +7 -3
- package/dist/header.js +2 -1
- package/dist/llm/tools.js +3 -1
- package/dist/migration.js +33 -0
- package/dist/pro/config.js +135 -9
- package/dist/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/settings.js +43 -2
- package/dist/screens/start.js +26 -14
- package/dist/screens/updatePrompt.js +200 -0
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/ui.js +32 -16
- package/dist/update.js +213 -0
- package/dist/web/clientState.js +82 -0
- package/dist/web/hub.js +4 -0
- package/dist/web/server.js +330 -37
- package/dist/web/service.js +160 -0
- package/dist/web/session.js +24 -6
- package/dist/web/settings.js +15 -0
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +2 -2
- package/web/README.md +102 -23
- package/web/app.js +558 -22
- package/web/i18n.js +34 -0
- package/web/index.html +71 -9
- package/web/settings.js +223 -46
- package/web/style.css +145 -78
- package/assets/pro/model-template.json +0 -376
package/web/app.js
CHANGED
|
@@ -147,6 +147,7 @@ function showGate(message = "") {
|
|
|
147
147
|
closeStream();
|
|
148
148
|
setStatus("locked", "offline");
|
|
149
149
|
document.getElementById("settings-dialog").close();
|
|
150
|
+
document.getElementById("background-dialog")?.close?.();
|
|
150
151
|
|
|
151
152
|
// A modal dialog stays above streaming repaints and traps keyboard focus.
|
|
152
153
|
const gate = document.createElement("dialog");
|
|
@@ -184,6 +185,159 @@ function showGate(message = "") {
|
|
|
184
185
|
gate.showModal();
|
|
185
186
|
}
|
|
186
187
|
|
|
188
|
+
// -------------------------------------------------------- background invite
|
|
189
|
+
//
|
|
190
|
+
// Accepting changes the running console's lifetime immediately, including
|
|
191
|
+
// conversations already in flight. A resident console never offers the prompt.
|
|
192
|
+
|
|
193
|
+
let backgroundPromptAnswered = false;
|
|
194
|
+
|
|
195
|
+
function showBackgroundPrompt() {
|
|
196
|
+
if (backgroundPromptAnswered || !el.backgroundDialog?.showModal) return;
|
|
197
|
+
if (el.backgroundDialog.open) return;
|
|
198
|
+
if (el.backgroundFeedback) {
|
|
199
|
+
el.backgroundFeedback.textContent = "";
|
|
200
|
+
el.backgroundFeedback.classList.remove("error");
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
el.backgroundDialog.showModal();
|
|
204
|
+
} catch {
|
|
205
|
+
// An embedder without dialog support still gets the setting in Settings.
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function maybePromptBackground(config) {
|
|
210
|
+
if (!config?.settings?.background_prompt) return;
|
|
211
|
+
if (config.settings.background === true) return;
|
|
212
|
+
showBackgroundPrompt();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function answerBackgroundPrompt(enable) {
|
|
216
|
+
if (backgroundPromptAnswered) return;
|
|
217
|
+
backgroundPromptAnswered = true;
|
|
218
|
+
const feedback = el.backgroundFeedback;
|
|
219
|
+
const cancel = el.backgroundCancel;
|
|
220
|
+
const confirm = el.backgroundEnable;
|
|
221
|
+
if (cancel) cancel.disabled = true;
|
|
222
|
+
if (confirm) confirm.disabled = true;
|
|
223
|
+
if (feedback) {
|
|
224
|
+
feedback.textContent = "";
|
|
225
|
+
feedback.classList.remove("error");
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
if (enable) {
|
|
229
|
+
const response = await apiFetch("/api/settings", {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { "Content-Type": "application/json" },
|
|
232
|
+
body: JSON.stringify({ settings: { background: true } }),
|
|
233
|
+
});
|
|
234
|
+
const body = await response.json();
|
|
235
|
+
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
|
|
236
|
+
if (body.settings?.background_running !== true) {
|
|
237
|
+
throw new Error(tr("Background residency could not be applied. Restart the console and try again."));
|
|
238
|
+
}
|
|
239
|
+
try { await preferences.load(); } catch { /* Residency is already active. */ }
|
|
240
|
+
} else {
|
|
241
|
+
// Consume the invitation for this CLI run. A failed dismissal is
|
|
242
|
+
// harmless: the prompt simply returns on the next page load.
|
|
243
|
+
try {
|
|
244
|
+
await apiFetch("/api/background/dismiss", { method: "POST" });
|
|
245
|
+
} catch { /* Keep the close local. */ }
|
|
246
|
+
}
|
|
247
|
+
el.backgroundDialog?.close?.();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
backgroundPromptAnswered = false;
|
|
250
|
+
if (feedback) {
|
|
251
|
+
feedback.textContent = tr(error.message);
|
|
252
|
+
feedback.classList.add("error");
|
|
253
|
+
}
|
|
254
|
+
} finally {
|
|
255
|
+
if (cancel) cancel.disabled = false;
|
|
256
|
+
if (confirm) confirm.disabled = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ------------------------------------------------------------- update notice
|
|
261
|
+
//
|
|
262
|
+
// The server compares the version it runs with the one npm publishes, and both
|
|
263
|
+
// workspaces read that from the settings payload: a Pro tab announces a new
|
|
264
|
+
// release exactly like the regular console. Confirming installs it on this
|
|
265
|
+
// machine; the running process keeps the old code either way, so a successful
|
|
266
|
+
// update ends by telling the user to restart.
|
|
267
|
+
//
|
|
268
|
+
// Two dialogs never open at once: when an update is offered, the background
|
|
269
|
+
// invitation waits for the next page load (it is not consumed by being skipped).
|
|
270
|
+
|
|
271
|
+
let updatePrompted = false;
|
|
272
|
+
|
|
273
|
+
function maybePromptUpdate(config) {
|
|
274
|
+
const update = config?.update;
|
|
275
|
+
if (updatePrompted || !update?.has_update) return false;
|
|
276
|
+
if (!el.updateDialog || typeof el.updateDialog.showModal !== "function") return false;
|
|
277
|
+
updatePrompted = true;
|
|
278
|
+
if (el.updateVersions) {
|
|
279
|
+
el.updateVersions.textContent = tr("Running {current} · published {latest}", {
|
|
280
|
+
current: update.current_version, latest: update.latest_version,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
if (el.updateFeedback) {
|
|
284
|
+
el.updateFeedback.textContent = "";
|
|
285
|
+
el.updateFeedback.classList.remove("error");
|
|
286
|
+
}
|
|
287
|
+
el.updateCommandBlock?.classList.add("hidden");
|
|
288
|
+
el.updateLater?.classList.remove("hidden");
|
|
289
|
+
el.updateNow?.classList.remove("hidden");
|
|
290
|
+
for (const button of [el.updateLater, el.updateNow, el.updateClose]) {
|
|
291
|
+
if (button) button.disabled = false;
|
|
292
|
+
}
|
|
293
|
+
if (el.updateNow) el.updateNow.textContent = tr("Update now");
|
|
294
|
+
try {
|
|
295
|
+
if (!el.updateDialog.open) el.updateDialog.showModal();
|
|
296
|
+
} catch {
|
|
297
|
+
return false; // An embedder without modal support still gets the console.
|
|
298
|
+
}
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Run npm through the console's own server and report what happened. */
|
|
303
|
+
async function runUpdate() {
|
|
304
|
+
if (!el.updateNow || el.updateNow.disabled) return;
|
|
305
|
+
const buttons = [el.updateNow, el.updateLater, el.updateClose].filter(Boolean);
|
|
306
|
+
for (const button of buttons) button.disabled = true;
|
|
307
|
+
const feedback = el.updateFeedback;
|
|
308
|
+
if (feedback) {
|
|
309
|
+
feedback.textContent = tr("Updating with npm…");
|
|
310
|
+
feedback.classList.remove("error");
|
|
311
|
+
}
|
|
312
|
+
let body = {};
|
|
313
|
+
try {
|
|
314
|
+
const response = await apiFetch("/api/update", { method: "POST" });
|
|
315
|
+
body = await response.json().catch(() => ({}));
|
|
316
|
+
if (!response.ok || body.ok !== true) throw new Error(body.error || `HTTP ${response.status}`);
|
|
317
|
+
if (feedback) {
|
|
318
|
+
feedback.textContent = tr("Updated to {version}. Restart Neutrino to use it.", { version: body.version || "" });
|
|
319
|
+
feedback.classList.remove("error");
|
|
320
|
+
}
|
|
321
|
+
el.updateCommandBlock?.classList.add("hidden");
|
|
322
|
+
// The install is done: the only thing left is to close the notice.
|
|
323
|
+
el.updateLater?.classList.add("hidden");
|
|
324
|
+
el.updateNow?.classList.add("hidden");
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (feedback) {
|
|
327
|
+
feedback.textContent = tr("Update failed: {error}", { error: error.message });
|
|
328
|
+
feedback.classList.add("error");
|
|
329
|
+
}
|
|
330
|
+
// npm failures are usually something the user has to fix, so the dialog
|
|
331
|
+
// keeps the command that finishes the job by hand.
|
|
332
|
+
if (el.updateCommand && typeof body?.command === "string" && body.command) {
|
|
333
|
+
el.updateCommand.textContent = body.command;
|
|
334
|
+
}
|
|
335
|
+
el.updateCommandBlock?.classList.remove("hidden");
|
|
336
|
+
} finally {
|
|
337
|
+
for (const button of buttons) button.disabled = false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
187
341
|
const el = {
|
|
188
342
|
app: document.getElementById("app"),
|
|
189
343
|
back: document.getElementById("back-to-menu"),
|
|
@@ -195,6 +349,7 @@ const el = {
|
|
|
195
349
|
proTip: document.getElementById("pro-tip"),
|
|
196
350
|
proTipClose: document.getElementById("pro-tip-close"),
|
|
197
351
|
proExit: document.getElementById("pro-exit"),
|
|
352
|
+
header: document.getElementById("main-header"),
|
|
198
353
|
title: document.getElementById("session-title"),
|
|
199
354
|
sub: document.getElementById("session-sub"),
|
|
200
355
|
controls: document.getElementById("controls"),
|
|
@@ -252,8 +407,47 @@ const el = {
|
|
|
252
407
|
agentsList: document.getElementById("agents-list"),
|
|
253
408
|
agentsFeedback: document.getElementById("agents-feedback"),
|
|
254
409
|
agentsCwd: document.getElementById("agents-cwd"),
|
|
410
|
+
agentActions: document.getElementById("agent-actions"),
|
|
411
|
+
agentMigrate: document.getElementById("agent-migrate"),
|
|
412
|
+
agentMigrateFeedback: document.getElementById("agent-migrate-feedback"),
|
|
413
|
+
backgroundDialog: document.getElementById("background-dialog"),
|
|
414
|
+
backgroundFeedback: document.getElementById("background-feedback"),
|
|
415
|
+
backgroundCancel: document.getElementById("background-cancel"),
|
|
416
|
+
backgroundEnable: document.getElementById("background-enable"),
|
|
417
|
+
updateDialog: document.getElementById("update-dialog"),
|
|
418
|
+
updateVersions: document.getElementById("update-versions"),
|
|
419
|
+
updateCommandBlock: document.getElementById("update-command-block"),
|
|
420
|
+
updateCommand: document.getElementById("update-command"),
|
|
421
|
+
updateFeedback: document.getElementById("update-feedback"),
|
|
422
|
+
updateClose: document.getElementById("update-close"),
|
|
423
|
+
updateLater: document.getElementById("update-later"),
|
|
424
|
+
updateNow: document.getElementById("update-now"),
|
|
255
425
|
};
|
|
256
426
|
|
|
427
|
+
if (el.backgroundCancel) {
|
|
428
|
+
el.backgroundCancel.addEventListener("click", () => { void answerBackgroundPrompt(false); });
|
|
429
|
+
}
|
|
430
|
+
if (el.backgroundEnable) {
|
|
431
|
+
el.backgroundEnable.addEventListener("click", () => { void answerBackgroundPrompt(true); });
|
|
432
|
+
}
|
|
433
|
+
if (el.backgroundDialog) {
|
|
434
|
+
el.backgroundDialog.addEventListener("cancel", (event) => {
|
|
435
|
+
event.preventDefault();
|
|
436
|
+
void answerBackgroundPrompt(false);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (el.updateNow) el.updateNow.addEventListener("click", () => { void runUpdate(); });
|
|
441
|
+
if (el.updateLater) el.updateLater.addEventListener("click", () => el.updateDialog?.close?.());
|
|
442
|
+
if (el.updateClose) el.updateClose.addEventListener("click", () => el.updateDialog?.close?.());
|
|
443
|
+
if (el.updateDialog) {
|
|
444
|
+
// Esc closes it like "Later": the notice is an offer, never a gate.
|
|
445
|
+
el.updateDialog.addEventListener("cancel", (event) => {
|
|
446
|
+
event.preventDefault();
|
|
447
|
+
el.updateDialog.close();
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
257
451
|
const state = {
|
|
258
452
|
overview: null,
|
|
259
453
|
// { kind: "live", key } | { kind: "file", id } | { kind: "new", cwd } |
|
|
@@ -272,6 +466,7 @@ const state = {
|
|
|
272
466
|
fileData: null,
|
|
273
467
|
// Last loaded read-only transcript from another agent.
|
|
274
468
|
agentData: null,
|
|
469
|
+
agentMigration: null,
|
|
275
470
|
// { key, path } | null — the working directory the sidebar is scoped to.
|
|
276
471
|
workspace: storedWorkspace(),
|
|
277
472
|
// Plan / usage card visibility and the plan it was opened for.
|
|
@@ -283,6 +478,9 @@ const state = {
|
|
|
283
478
|
workspaces: null,
|
|
284
479
|
// Translation key and colour class of the sidebar status line.
|
|
285
480
|
status: { key: "connecting…", kind: "" },
|
|
481
|
+
// { identity: 1 } of finished conversations the reader has already opened,
|
|
482
|
+
// restored from the server before the first sidebar paint (see loadSeen).
|
|
483
|
+
seen: {},
|
|
286
484
|
};
|
|
287
485
|
|
|
288
486
|
function storedWorkspace() {
|
|
@@ -414,6 +612,11 @@ function announce(previous, next) {
|
|
|
414
612
|
else if (previous.busy && !next.busy && !next.approval) chime("turn");
|
|
415
613
|
}
|
|
416
614
|
|
|
615
|
+
/** Whether a live conversation has anything to show (and so belongs in the list). */
|
|
616
|
+
function liveHasContent(snapshot) {
|
|
617
|
+
return Boolean(snapshot.messages?.length || snapshot.streamingContent);
|
|
618
|
+
}
|
|
619
|
+
|
|
417
620
|
// ------------------------------------------------------------------ helpers
|
|
418
621
|
|
|
419
622
|
function escapeHtml(value) {
|
|
@@ -650,6 +853,154 @@ function isSelected(session) {
|
|
|
650
853
|
return !session.liveKey && session.id === selection.id;
|
|
651
854
|
}
|
|
652
855
|
|
|
856
|
+
// --------------------------------------------------- the "already seen" record
|
|
857
|
+
//
|
|
858
|
+
// A blue dot means "this conversation finished and you have not looked at it
|
|
859
|
+
// since". Opening a row records that, so the dot is not a permanent decoration;
|
|
860
|
+
// new activity forgets the record again, so the next finished turn gets a mark
|
|
861
|
+
// of its own. The record survives a reload and a restart of the console: a
|
|
862
|
+
// conversation the reader has already read must not keep asking.
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Identity of a row in the record. A conversation keeps its id while it becomes
|
|
866
|
+
* live, so the id is the stable name; a conversation with no history yet has
|
|
867
|
+
* only its live key.
|
|
868
|
+
*/
|
|
869
|
+
function sessionIdentity(session) {
|
|
870
|
+
if (session.id) return `id:${session.id}`;
|
|
871
|
+
return session.liveKey ? `live:${session.liveKey}` : "";
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Key of the record an older console kept in localStorage. It is only read once
|
|
875
|
+
// now, to hand that history over to the server.
|
|
876
|
+
const SEEN_KEY = "neutrino.seen";
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Turn the server's list into the lookup the renderer uses.
|
|
880
|
+
*
|
|
881
|
+
* The record lives in ~/.neutrino/web.json (src/web/clientState.ts): one file
|
|
882
|
+
* the console owns, so the terminal UI never carries data it does not read and
|
|
883
|
+
* the answer survives a restart. localStorage could not do that — it is scoped
|
|
884
|
+
* to the address and port the console happened to listen on.
|
|
885
|
+
*/
|
|
886
|
+
function seenMap(list) {
|
|
887
|
+
const out = {};
|
|
888
|
+
if (Array.isArray(list)) {
|
|
889
|
+
for (const identity of list) {
|
|
890
|
+
if (typeof identity === "string" && identity) out[identity] = 1;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return out;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/** Report a change. A write that fails costs one dot, so it is never surfaced. */
|
|
897
|
+
async function persistSeen(identities, seen) {
|
|
898
|
+
if (!identities.length) return true;
|
|
899
|
+
try {
|
|
900
|
+
const response = await apiFetch("/api/seen", {
|
|
901
|
+
method: "POST",
|
|
902
|
+
headers: { "Content-Type": "application/json" },
|
|
903
|
+
body: JSON.stringify({ identities, seen }),
|
|
904
|
+
});
|
|
905
|
+
return response.ok;
|
|
906
|
+
} catch {
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/** The record left in localStorage by an older console, if any. */
|
|
912
|
+
function legacySeen() {
|
|
913
|
+
try {
|
|
914
|
+
const stored = JSON.parse(readStorage(modeStorageKey(SEEN_KEY)) || "{}");
|
|
915
|
+
return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {};
|
|
916
|
+
} catch {
|
|
917
|
+
return {};
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Load the record before the first sidebar paint, so a reload never starts by
|
|
923
|
+
* marking every finished conversation again. The localStorage record of an
|
|
924
|
+
* older console is handed over once, and dropped as soon as the server has it.
|
|
925
|
+
*/
|
|
926
|
+
async function loadSeen() {
|
|
927
|
+
let stored;
|
|
928
|
+
try {
|
|
929
|
+
const response = await apiFetch("/api/seen", { cache: "no-store" });
|
|
930
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
931
|
+
stored = seenMap((await response.json())?.seen);
|
|
932
|
+
} catch {
|
|
933
|
+
// Without a record every finished conversation shows its dot again, which
|
|
934
|
+
// is a readable page rather than a broken one.
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
state.seen = stored;
|
|
938
|
+
const identities = Object.keys(legacySeen());
|
|
939
|
+
if (!identities.length) return;
|
|
940
|
+
const missing = identities.filter((identity) => !stored[identity]);
|
|
941
|
+
for (const identity of missing) state.seen[identity] = 1;
|
|
942
|
+
if (await persistSeen(missing, true)) writeStorage(modeStorageKey(SEEN_KEY), "");
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function isSeen(identity) {
|
|
946
|
+
return Boolean(identity) && Boolean(state.seen[identity]);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** The reader opened this row. */
|
|
950
|
+
function rememberSeen(identity) {
|
|
951
|
+
if (!identity || state.seen[identity]) return;
|
|
952
|
+
state.seen[identity] = 1;
|
|
953
|
+
// The dot is already gone from the page; the write only has to reach the disk.
|
|
954
|
+
void persistSeen([identity], true);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** New activity: the turn that comes next deserves a mark of its own. */
|
|
958
|
+
function forgetSeen(identity) {
|
|
959
|
+
if (!identity || !state.seen[identity]) return;
|
|
960
|
+
delete state.seen[identity];
|
|
961
|
+
void persistSeen([identity], false);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Status of a live conversation: a tool call waiting for the user's decision, a
|
|
966
|
+
* turn in flight, or nothing left to run. An automated review answers for the
|
|
967
|
+
* user, so only manual approval leaves a conversation waiting on a human.
|
|
968
|
+
*/
|
|
969
|
+
function statusOfLive(live) {
|
|
970
|
+
if (!live) return "done";
|
|
971
|
+
if (live.approval && (live.approvalMode ?? "request") === "request") return "approval";
|
|
972
|
+
return live.busy || live.paused || live.queued || live.compressing ? "running" : "done";
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Status of a sidebar row; a saved transcript has finished by definition. */
|
|
976
|
+
function sessionStatus(session) {
|
|
977
|
+
if (!session.liveKey) return "done";
|
|
978
|
+
// The open stream is newer than the last overview poll.
|
|
979
|
+
const snapshot = state.snapshot;
|
|
980
|
+
if (snapshot && snapshot.key === session.liveKey) return statusOfLive(snapshot);
|
|
981
|
+
return statusOfLive((state.overview?.live ?? []).find((entry) => entry.key === session.liveKey));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* The status mark of a row: blue dot when done, ring when running, amber when
|
|
986
|
+
* waiting. The blue dot asks for attention once — opening the conversation takes
|
|
987
|
+
* it away. Running and waiting are live states, so they are always drawn, and
|
|
988
|
+
* seeing one clears the record for the turn that follows.
|
|
989
|
+
*/
|
|
990
|
+
function sessionStatusMark(session) {
|
|
991
|
+
const status = sessionStatus(session);
|
|
992
|
+
const identity = sessionIdentity(session);
|
|
993
|
+
if (status === "done") {
|
|
994
|
+
if (isSeen(identity)) return "";
|
|
995
|
+
} else {
|
|
996
|
+
forgetSeen(identity);
|
|
997
|
+
}
|
|
998
|
+
const label = status === "running"
|
|
999
|
+
? tr("Running")
|
|
1000
|
+
: status === "approval" ? tr("Waiting for approval") : tr("Done");
|
|
1001
|
+
return `<span class="session-state ${status}" title="${escapeHtml(label)}"></span>`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
653
1004
|
function renderSession(session) {
|
|
654
1005
|
const item = document.createElement("button");
|
|
655
1006
|
item.className = "session";
|
|
@@ -661,15 +1012,16 @@ function renderSession(session) {
|
|
|
661
1012
|
item.title = title;
|
|
662
1013
|
item.innerHTML =
|
|
663
1014
|
`<div class="session-top"><span class="session-title">${escapeHtml(title)}</span>` +
|
|
664
|
-
`${session
|
|
1015
|
+
`${sessionStatusMark(session)}</div>` +
|
|
665
1016
|
`<div class="session-meta"><span class="model">${escapeHtml(session.model || "?")}</span>` +
|
|
666
1017
|
`<span class="time">${escapeHtml(formatTime(session.updated_at))}</span>` +
|
|
667
1018
|
`<span class="count">${escapeHtml(count === 1 ? tr("{n} message", { n: count }) : tr("{n} messages", { n: count }))}</span></div>`;
|
|
668
1019
|
|
|
669
1020
|
item.addEventListener("click", () => {
|
|
1021
|
+
if (!session.liveKey && !session.id) return;
|
|
1022
|
+
rememberSeen(sessionIdentity(session));
|
|
670
1023
|
if (session.liveKey) selectLive(session.liveKey);
|
|
671
|
-
else
|
|
672
|
-
else return;
|
|
1024
|
+
else selectFile(session.id);
|
|
673
1025
|
setConversationView(true);
|
|
674
1026
|
});
|
|
675
1027
|
return item;
|
|
@@ -679,13 +1031,99 @@ function renderSession(session) {
|
|
|
679
1031
|
|
|
680
1032
|
// Only explicit navigation changes the mobile pane. Background refreshes and
|
|
681
1033
|
// live-to-saved transitions must not take the user away from the menu.
|
|
682
|
-
|
|
1034
|
+
//
|
|
1035
|
+
// On a phone the two panes share one screen, so the browser's own Back button
|
|
1036
|
+
// has to walk the same levels as the back arrow in the header: from an open
|
|
1037
|
+
// conversation to the list, and Forward back into that conversation. Each
|
|
1038
|
+
// explicit navigation records one marked entry, and the entries beneath it are
|
|
1039
|
+
// the list.
|
|
1040
|
+
const CONVERSATION_ENTRY = "conversation";
|
|
1041
|
+
|
|
1042
|
+
function isConversationEntry(entry) {
|
|
1043
|
+
return Boolean(entry) && entry.neutrinoView === CONVERSATION_ENTRY;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** The narrow layout that swaps the panes; wider screens show both at once. */
|
|
1047
|
+
function stackedPanes() {
|
|
1048
|
+
try {
|
|
1049
|
+
return typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
1050
|
+
&& window.matchMedia("(max-width: 760px)").matches;
|
|
1051
|
+
} catch {
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Record the open conversation as a level the browser can return from. */
|
|
1057
|
+
function pushConversationEntry() {
|
|
1058
|
+
try {
|
|
1059
|
+
// Forward restored this entry already; recording it twice would take two
|
|
1060
|
+
// presses to leave the conversation.
|
|
1061
|
+
if (isConversationEntry(history.state)) return;
|
|
1062
|
+
history.pushState({ neutrinoView: CONVERSATION_ENTRY }, "", location.href);
|
|
1063
|
+
} catch {
|
|
1064
|
+
// Without history the header's back arrow still closes the pane.
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** The list is the entry beneath an open conversation: step back onto it. */
|
|
1069
|
+
function stepBackToList() {
|
|
1070
|
+
try {
|
|
1071
|
+
if (isConversationEntry(history.state)) history.back();
|
|
1072
|
+
} catch {
|
|
1073
|
+
// Without history the header's back arrow still closes the pane.
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function setConversationView(open, { record = true } = {}) {
|
|
1078
|
+
const wasOpen = el.app.classList.contains("conversation-open");
|
|
683
1079
|
el.app.classList.toggle("conversation-open", open);
|
|
684
|
-
|
|
1080
|
+
const stacked = stackedPanes();
|
|
1081
|
+
// A click records one level; Back and Forward already moved the browser.
|
|
1082
|
+
if (record && stacked && open !== wasOpen) {
|
|
1083
|
+
if (open) pushConversationEntry();
|
|
1084
|
+
else stepBackToList();
|
|
1085
|
+
}
|
|
1086
|
+
if (!stacked) return;
|
|
685
1087
|
if (open) el.back.focus();
|
|
686
1088
|
else (el.sessions.querySelector(".session.selected") || el.refresh).focus();
|
|
687
1089
|
}
|
|
688
1090
|
|
|
1091
|
+
/** Back and Forward: show the level the browser landed on, without recording it. */
|
|
1092
|
+
function followViewHistory(event) {
|
|
1093
|
+
setConversationView(isConversationEntry(event?.state), { record: false });
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* A reload starts on the list, but the browser is still on the entry that
|
|
1098
|
+
* recorded a conversation. Forget the mark, so the first Back press tells the
|
|
1099
|
+
* truth instead of leaving a screen that never looked like a conversation.
|
|
1100
|
+
*/
|
|
1101
|
+
function forgetStaleConversationEntry() {
|
|
1102
|
+
try {
|
|
1103
|
+
if (!isConversationEntry(history.state)) return;
|
|
1104
|
+
history.replaceState(null, "", location.href);
|
|
1105
|
+
} catch {
|
|
1106
|
+
// No history in this embedder: there is nothing to correct.
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Rotating a phone crosses the width that swaps the panes. A conversation
|
|
1112
|
+
* opened while both panes were shown never recorded a level, so record it on
|
|
1113
|
+
* the way into the single-pane layout: otherwise Back would leave the console
|
|
1114
|
+
* from a screen that shows nothing but a conversation.
|
|
1115
|
+
*/
|
|
1116
|
+
function watchPanes() {
|
|
1117
|
+
const query = typeof window !== "undefined" && typeof window.matchMedia === "function"
|
|
1118
|
+
? window.matchMedia("(max-width: 760px)")
|
|
1119
|
+
: null;
|
|
1120
|
+
if (!query || typeof query.addEventListener !== "function") return;
|
|
1121
|
+
query.addEventListener("change", (event) => {
|
|
1122
|
+
if (!event?.matches) return;
|
|
1123
|
+
if (el.app.classList.contains("conversation-open")) pushConversationEntry();
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
|
|
689
1127
|
function resetMessagePane(text) {
|
|
690
1128
|
el.messages.innerHTML = "";
|
|
691
1129
|
const empty = document.createElement("div");
|
|
@@ -737,6 +1175,16 @@ function openStream(key) {
|
|
|
737
1175
|
announce(previous, snapshot);
|
|
738
1176
|
setStatus("live", "live");
|
|
739
1177
|
renderLive();
|
|
1178
|
+
// A turn that finishes in the conversation on screen needs no blue dot:
|
|
1179
|
+
// recording it before the repaint keeps the mark off as the turn ends.
|
|
1180
|
+
if (snapshot.sessionId && statusOfLive(snapshot) === "done") rememberSeen(`id:${snapshot.sessionId}`);
|
|
1181
|
+
// The sidebar mirrors the open conversation, so a turn that starts, waits
|
|
1182
|
+
// for approval or finishes shows there instead of on the next poll.
|
|
1183
|
+
if (statusOfLive(previous) !== statusOfLive(snapshot)) renderSidebar();
|
|
1184
|
+
// A conversation stays out of the sidebar until it has content; the first
|
|
1185
|
+
// message makes it history, so list it now instead of on the next poll.
|
|
1186
|
+
const started = previous && !liveHasContent(previous) && liveHasContent(snapshot);
|
|
1187
|
+
if (started) loadOverview({ silent: true });
|
|
740
1188
|
});
|
|
741
1189
|
|
|
742
1190
|
source.addEventListener("gone", () => {
|
|
@@ -746,12 +1194,14 @@ function openStream(key) {
|
|
|
746
1194
|
|
|
747
1195
|
source.onopen = () => setStatus("live", "live");
|
|
748
1196
|
source.onerror = () => {
|
|
749
|
-
// EventSource retries automatically;
|
|
1197
|
+
// EventSource retries automatically; wait for a fresh snapshot to flash.
|
|
1198
|
+
el.header?.classList.remove("is-thinking");
|
|
750
1199
|
setStatus("reconnecting…", "offline");
|
|
751
1200
|
};
|
|
752
1201
|
}
|
|
753
1202
|
|
|
754
1203
|
function closeStream() {
|
|
1204
|
+
el.header?.classList.remove("is-thinking");
|
|
755
1205
|
if (state.eventSource) {
|
|
756
1206
|
state.eventSource.close();
|
|
757
1207
|
state.eventSource = null;
|
|
@@ -865,17 +1315,37 @@ async function continueSession(id, selection = preferences.selectionForFile(id))
|
|
|
865
1315
|
|
|
866
1316
|
function setComposerMode(mode) {
|
|
867
1317
|
state.composerMode = mode;
|
|
1318
|
+
el.agentActions.classList.add("hidden");
|
|
868
1319
|
el.composer.classList.remove("hidden");
|
|
869
1320
|
el.input.placeholder = mode === "resume"
|
|
870
1321
|
? tr("Continue this conversation…")
|
|
871
1322
|
: mode === "new"
|
|
872
1323
|
? tr("Message Neutrino in this folder…")
|
|
873
1324
|
: tr("Message Neutrino…");
|
|
1325
|
+
renderComposerAction();
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function canPauseFromComposer() {
|
|
1329
|
+
const selection = state.selection;
|
|
1330
|
+
const snapshot = state.snapshot;
|
|
1331
|
+
return Boolean(selection?.kind === "live" && snapshot?.key === selection.key
|
|
1332
|
+
&& snapshot.busy && !snapshot.paused && !el.input.value.trim());
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function renderComposerAction() {
|
|
1336
|
+
const pause = canPauseFromComposer();
|
|
1337
|
+
const label = pause ? "Pause" : "Send message";
|
|
1338
|
+
el.send.classList.toggle("is-pause", pause);
|
|
1339
|
+
el.send.setAttribute("data-i18n-title", label);
|
|
1340
|
+
el.send.setAttribute("data-i18n-aria-label", label);
|
|
1341
|
+
el.send.title = tr(label);
|
|
1342
|
+
el.send.setAttribute("aria-label", tr(label));
|
|
874
1343
|
}
|
|
875
1344
|
|
|
876
1345
|
function hideComposer() {
|
|
877
1346
|
preferences.clear();
|
|
878
1347
|
el.composer.classList.add("hidden");
|
|
1348
|
+
el.agentActions.classList.add("hidden");
|
|
879
1349
|
renderApproval(null);
|
|
880
1350
|
}
|
|
881
1351
|
|
|
@@ -1027,6 +1497,8 @@ function decideApproval(approved) {
|
|
|
1027
1497
|
function renderLive() {
|
|
1028
1498
|
const snapshot = state.snapshot;
|
|
1029
1499
|
if (!snapshot) return;
|
|
1500
|
+
renderComposerAction();
|
|
1501
|
+
el.header?.classList.toggle("is-thinking", Boolean(snapshot.busy && !snapshot.paused && !snapshot.approval));
|
|
1030
1502
|
state.fileData = null;
|
|
1031
1503
|
state.agentData = null;
|
|
1032
1504
|
el.title.textContent = snapshot.title || tr("New conversation");
|
|
@@ -1393,15 +1865,21 @@ async function postAction(key, action, body) {
|
|
|
1393
1865
|
}
|
|
1394
1866
|
}
|
|
1395
1867
|
|
|
1868
|
+
function activateComposer() {
|
|
1869
|
+
if (canPauseFromComposer()) return postAction(state.selection.key, "pause");
|
|
1870
|
+
return sendMessage();
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1396
1873
|
async function sendMessage() {
|
|
1397
1874
|
const selection = state.selection;
|
|
1398
1875
|
if (!selection) return;
|
|
1399
1876
|
const text = el.input.value;
|
|
1400
1877
|
if (!text.trim()) return;
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
}
|
|
1878
|
+
try {
|
|
1879
|
+
if (PRO_MODE) await proUI?.flush();
|
|
1880
|
+
else await preferences.flush?.();
|
|
1881
|
+
} catch (error) { el.sub.textContent = tr(error.message); return; }
|
|
1882
|
+
if (state.selection !== selection) return;
|
|
1405
1883
|
|
|
1406
1884
|
if (selection.kind === "live") {
|
|
1407
1885
|
el.input.value = "";
|
|
@@ -1444,6 +1922,7 @@ async function sendMessage() {
|
|
|
1444
1922
|
function autosize() {
|
|
1445
1923
|
el.input.style.height = "auto";
|
|
1446
1924
|
el.input.style.height = `${Math.min(el.input.scrollHeight, 220)}px`;
|
|
1925
|
+
renderComposerAction();
|
|
1447
1926
|
}
|
|
1448
1927
|
|
|
1449
1928
|
// ------------------------------------------------------- workspaces & dialogs
|
|
@@ -1530,13 +2009,14 @@ function showHome({ menu = true } = {}) {
|
|
|
1530
2009
|
|
|
1531
2010
|
/** Show a folder as an empty workspace ready for its first message. */
|
|
1532
2011
|
function renderWorkspaceLanding(path) {
|
|
2012
|
+
const sameLanding = state.selection?.kind === "new" && state.selection.cwd === path;
|
|
1533
2013
|
state.selection = { kind: "new", cwd: path };
|
|
1534
2014
|
state.snapshot = null;
|
|
1535
2015
|
state.fileData = null;
|
|
1536
2016
|
state.planOpen = false;
|
|
1537
2017
|
state.planSeen = "";
|
|
1538
2018
|
closeStream();
|
|
1539
|
-
preferences.clear();
|
|
2019
|
+
if (!sameLanding) preferences.clear();
|
|
1540
2020
|
renderSidebar();
|
|
1541
2021
|
renderWorkspaceChrome();
|
|
1542
2022
|
el.controls.innerHTML = "";
|
|
@@ -1545,6 +2025,7 @@ function renderWorkspaceLanding(path) {
|
|
|
1545
2025
|
el.title.textContent = workspaceName(path) || tr("Working directory");
|
|
1546
2026
|
el.sub.textContent = path;
|
|
1547
2027
|
setComposerMode("new");
|
|
2028
|
+
preferences.showConversation({}, `new:${path}`);
|
|
1548
2029
|
proUI?.show(null, `new:${path}`);
|
|
1549
2030
|
el.messages.innerHTML = "";
|
|
1550
2031
|
|
|
@@ -2192,7 +2673,7 @@ function renderAgentList(groups) {
|
|
|
2192
2673
|
"</span>";
|
|
2193
2674
|
item.addEventListener("click", () => {
|
|
2194
2675
|
el.agentsDialog.close();
|
|
2195
|
-
void selectAgentSession(group.id, session.id, session.title || "");
|
|
2676
|
+
void selectAgentSession(group.id, session.id, session.title || "", group.cwd);
|
|
2196
2677
|
});
|
|
2197
2678
|
section.appendChild(item);
|
|
2198
2679
|
}
|
|
@@ -2211,7 +2692,7 @@ async function openAgentsDialog() {
|
|
|
2211
2692
|
const response = await apiFetch(`/api/agents/${agent.id}/sessions?cwd=${encodeURIComponent(cwd)}`, { cache: "no-store" });
|
|
2212
2693
|
const data = await response.json().catch(() => ({}));
|
|
2213
2694
|
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2214
|
-
return { ...agent, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
|
|
2695
|
+
return { ...agent, cwd: data.cwd || cwd, sessions: Array.isArray(data.sessions) ? data.sessions : [] };
|
|
2215
2696
|
}));
|
|
2216
2697
|
el.agentsFeedback.textContent = "";
|
|
2217
2698
|
renderAgentList(groups);
|
|
@@ -2238,11 +2719,53 @@ function renderAgentView(data) {
|
|
|
2238
2719
|
renderApproval(null);
|
|
2239
2720
|
renderPlanCard(null);
|
|
2240
2721
|
hideComposer();
|
|
2722
|
+
renderAgentMigration(data);
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
function renderAgentMigration(data) {
|
|
2726
|
+
el.agentActions.classList.remove("hidden");
|
|
2727
|
+
el.agentMigrate.disabled = Boolean(state.agentMigration) || !data.cwd || !data.messages?.length;
|
|
2728
|
+
el.agentMigrate.textContent = state.agentMigration === state.selection ? tr("Migrating…") : tr("Migrate session");
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
async function migrateSelectedAgentSession() {
|
|
2732
|
+
const selection = state.selection;
|
|
2733
|
+
const source = state.agentData;
|
|
2734
|
+
if (selection?.kind !== "agent" || !source?.cwd || !source.messages?.length || state.agentMigration) return;
|
|
2735
|
+
state.agentMigration = selection;
|
|
2736
|
+
el.agentMigrateFeedback.textContent = "";
|
|
2737
|
+
renderAgentMigration(source);
|
|
2738
|
+
try {
|
|
2739
|
+
const payload = { cwd: source.cwd };
|
|
2740
|
+
if (PRO_MODE) payload.pro_preset = currentProPreset();
|
|
2741
|
+
const response = await apiFetch(`/api/agents/${encodeURIComponent(selection.agent)}/sessions/${encodeURIComponent(selection.id)}/migrate`, {
|
|
2742
|
+
method: "POST",
|
|
2743
|
+
headers: { "Content-Type": "application/json" },
|
|
2744
|
+
body: JSON.stringify(payload),
|
|
2745
|
+
});
|
|
2746
|
+
const data = await response.json().catch(() => ({}));
|
|
2747
|
+
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2748
|
+
if (state.selection === selection) {
|
|
2749
|
+
setWorkspace({ path: data.cwd });
|
|
2750
|
+
await selectLive(data.key);
|
|
2751
|
+
el.input.focus();
|
|
2752
|
+
}
|
|
2753
|
+
await loadOverview({ silent: true });
|
|
2754
|
+
} catch (error) {
|
|
2755
|
+
if (state.selection === selection) {
|
|
2756
|
+
el.agentMigrateFeedback.textContent = tr("Could not migrate this session: {error}", { error: error.message });
|
|
2757
|
+
}
|
|
2758
|
+
} finally {
|
|
2759
|
+
state.agentMigration = null;
|
|
2760
|
+
if (state.selection?.kind === "agent" && state.agentData) renderAgentMigration(state.agentData);
|
|
2761
|
+
}
|
|
2241
2762
|
}
|
|
2242
2763
|
|
|
2243
|
-
async function selectAgentSession(agent, id, title) {
|
|
2764
|
+
async function selectAgentSession(agent, id, title, cwd) {
|
|
2765
|
+
const scope = cwd || await agentsScopeCwd();
|
|
2244
2766
|
closeStream();
|
|
2245
|
-
|
|
2767
|
+
const selection = { kind: "agent", agent, id, title, cwd: scope };
|
|
2768
|
+
state.selection = selection;
|
|
2246
2769
|
state.snapshot = null;
|
|
2247
2770
|
state.fileData = null;
|
|
2248
2771
|
state.agentData = null;
|
|
@@ -2254,17 +2777,20 @@ async function selectAgentSession(agent, id, title) {
|
|
|
2254
2777
|
renderApproval(null);
|
|
2255
2778
|
renderPlanCard(null);
|
|
2256
2779
|
hideComposer();
|
|
2780
|
+
el.agentMigrateFeedback.textContent = "";
|
|
2257
2781
|
el.title.textContent = tr("Loading…");
|
|
2258
2782
|
el.sub.textContent = title || "";
|
|
2259
2783
|
resetMessagePane(tr("Loading…"));
|
|
2260
2784
|
setConversationView(true);
|
|
2261
2785
|
try {
|
|
2262
|
-
const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}`, { cache: "no-store" });
|
|
2786
|
+
const response = await apiFetch(`/api/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(id)}?cwd=${encodeURIComponent(scope)}`, { cache: "no-store" });
|
|
2263
2787
|
const data = await response.json().catch(() => ({}));
|
|
2788
|
+
if (state.selection !== selection) return;
|
|
2264
2789
|
if (!response.ok) throw new Error(apiError(data, `HTTP ${response.status}`));
|
|
2265
|
-
state.agentData = { ...data, agent, cwd:
|
|
2790
|
+
state.agentData = { ...data, agent, cwd: data.cwd || scope };
|
|
2266
2791
|
renderAgentView(state.agentData);
|
|
2267
2792
|
} catch (error) {
|
|
2793
|
+
if (state.selection !== selection) return;
|
|
2268
2794
|
el.title.textContent = title || tr("Conversation");
|
|
2269
2795
|
el.sub.textContent = tr("Could not load this session: {error}", { error: error.message });
|
|
2270
2796
|
resetMessagePane(tr("Could not load this session: {error}", { error: error.message }));
|
|
@@ -2505,7 +3031,7 @@ function initializeProWorkspace() {
|
|
|
2505
3031
|
|
|
2506
3032
|
el.refresh.addEventListener("click", () => loadOverview());
|
|
2507
3033
|
el.back.addEventListener("click", () => setConversationView(false));
|
|
2508
|
-
el.send.addEventListener("click",
|
|
3034
|
+
el.send.addEventListener("click", activateComposer);
|
|
2509
3035
|
el.input.addEventListener("input", autosize);
|
|
2510
3036
|
el.input.addEventListener("keydown", (event) => {
|
|
2511
3037
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
@@ -2528,6 +3054,7 @@ el.workspaceAdd.addEventListener("click", () => {
|
|
|
2528
3054
|
openFolderDialog({ start: state.workspace?.path || state.workspaces?.cwd || "", onPick: (path) => openWorkspace(path) });
|
|
2529
3055
|
});
|
|
2530
3056
|
el.agentsOpen.addEventListener("click", () => openAgentsDialog());
|
|
3057
|
+
el.agentMigrate.addEventListener("click", () => migrateSelectedAgentSession());
|
|
2531
3058
|
el.agentsClose.addEventListener("click", () => el.agentsDialog.close());
|
|
2532
3059
|
el.agentsDialog.addEventListener("cancel", (event) => {
|
|
2533
3060
|
event.preventDefault();
|
|
@@ -2605,17 +3132,26 @@ el.folderDialog.addEventListener("cancel", (event) => {
|
|
|
2605
3132
|
});
|
|
2606
3133
|
|
|
2607
3134
|
window.addEventListener("beforeunload", closeStream);
|
|
3135
|
+
// On the phone layout the browser's Back and Forward buttons walk the same
|
|
3136
|
+
// levels as the header's back arrow: an open conversation returns to the list.
|
|
3137
|
+
window.addEventListener("popstate", followViewHistory);
|
|
3138
|
+
watchPanes();
|
|
3139
|
+
forgetStaleConversationEntry();
|
|
2608
3140
|
|
|
2609
3141
|
initializeLanguage();
|
|
2610
3142
|
initializeProWorkspace();
|
|
2611
3143
|
preferences.load()
|
|
2612
|
-
.then((config) => {
|
|
3144
|
+
.then(async (config) => {
|
|
2613
3145
|
const language = config?.settings?.language;
|
|
2614
|
-
if (language && language !== uiLanguage())
|
|
2615
|
-
|
|
3146
|
+
if (language && language !== uiLanguage()) {
|
|
3147
|
+
await changeInterfaceLanguage(language, { persist: false });
|
|
3148
|
+
}
|
|
3149
|
+
// Offer the update first, and only then the background invitation: two
|
|
3150
|
+
// modal dialogs must never stack on top of each other.
|
|
3151
|
+
if (!maybePromptUpdate(config)) maybePromptBackground(config);
|
|
2616
3152
|
})
|
|
2617
3153
|
.catch(error => console.error(error));
|
|
2618
|
-
loadOverview().then(() => {
|
|
3154
|
+
loadSeen().then(() => loadOverview()).then(() => {
|
|
2619
3155
|
// Reopen the folder the user had open last time, without stealing a click
|
|
2620
3156
|
// that landed on a saved conversation while the overview was loading.
|
|
2621
3157
|
if (state.workspace && !state.selection) renderWorkspaceLanding(state.workspace.path);
|