@oversk7/pi-web-dsh-style 1.3.3 → 1.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 CHANGED
@@ -40,6 +40,12 @@ pi --web
40
40
 
41
41
  You can also enter `/web` in an existing Pi terminal. The page opens on your computer; the default local address is `http://127.0.0.1:18789`. To stop the service, run `/web --stop` or exit the host Pi process. Closing the browser tab alone does not stop it.
42
42
 
43
+ Each visible page subscribes to its selected session, receives an initial snapshot, and then receives only changes. Hidden tabs pause live updates and notifications; returning to the page synchronizes the latest state. Pi and its tools continue running while no page is viewing them.
44
+
45
+ Switching back to a newly created or previously loaded session shows its cached content immediately and keeps the composer available while refreshing. Repeated opens share pending requests, and sessions with a running process return their current snapshot without waiting for RPC refreshes.
46
+
47
+ In the in-app workspace directory browser, click the pencil to paste a full path. The list loads the corresponding directory and filters matching folders, including deeply nested paths. Click a folder to select it, or press Enter to navigate directly; a trailing separator previews the folder's contents. Relative file links in replies resolve against the selected session's workspace.
48
+
43
49
  The interface starts in Chinese. To switch to English, open **设置 (Settings) → 外观 (Appearance) → 界面语言 (Interface language) → English**. Your choice is saved in this browser.
44
50
 
45
51
  ### Phone and remote access
@@ -85,6 +91,12 @@ pi --web
85
91
 
86
92
  也可以在已有的 Pi 终端中输入 `/web`。网页会在本机打开,默认地址为 `http://127.0.0.1:18789`。执行 `/web --stop` 或退出宿主 Pi 可停止服务;仅关闭浏览器标签页不会停止服务。
87
93
 
94
+ 每个可见网页只订阅正在查看的会话,首次接收快照,随后只传输变化内容。隐藏标签页会暂停实时更新和通知,返回网页后同步最新状态。没有网页查看时,Pi 和工具仍会继续运行。
95
+
96
+ 切回新建或已加载的会话时,立即显示缓存内容,后台刷新期间仍可编辑输入。重复打开会复用待完成请求;已有运行进程的会话直接返回当前快照,避免等待 RPC 刷新。
97
+
98
+ 在内置工作区目录浏览器中,点击铅笔图标即可粘贴完整路径,列表会加载对应目录并筛选匹配的文件夹,支持多层嵌套路径。点击文件夹选择,或按 Enter 直接跳转;路径末尾带分隔符时预览该文件夹的内容。回答中的相对文件链接按当前会话的工作区解析。
99
+
88
100
  界面默认使用中文。打开 **设置 → 外观 → 界面语言**,即可一键切换中文或 English;选择会保存在当前浏览器。
89
101
 
90
102
  ### 手机与远程访问
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oversk7/pi-web-dsh-style",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "description": "An unofficial DSH-styled Windows browser UI for pi coding agent with configurable LAN and native frp relay access",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/server.ts CHANGED
@@ -183,6 +183,7 @@ interface RuntimeSession {
183
183
  streaming: boolean;
184
184
  needsAttention: boolean;
185
185
  starting?: Promise<void>;
186
+ openRefresh?: Promise<void>;
186
187
  modelRefresh?: Promise<void>;
187
188
  commandRefresh?: Promise<void>;
188
189
  lastUsed: number;
@@ -620,7 +621,14 @@ function firstMessageTitle(session: SessionInfo): string {
620
621
  // ---------------------------------------------------------------------------
621
622
 
622
623
  function broadcastEvent(event: Json): void {
623
- for (const client of [...sseClients]) sendSseEvent(client, event);
624
+ for (const client of [...sseClients]) {
625
+ if (event.sessionId && client.sessionId !== event.sessionId) continue;
626
+ sendSseEvent(client, event);
627
+ }
628
+ }
629
+
630
+ function hasSessionSubscribers(sessionId: string): boolean {
631
+ return [...sseClients].some((client) => !client.closed && client.sessionId === sessionId);
624
632
  }
625
633
 
626
634
  function sessionSummary(record: RuntimeSession): Json {
@@ -684,12 +692,17 @@ function setPendingQueue(record: RuntimeSession, queue: RpcQueueSnapshot): void
684
692
  const snapshotScheduler = createSnapshotScheduler<RuntimeSession>(emitSessionSnapshot);
685
693
 
686
694
  function broadcastSessionSnapshot(record: RuntimeSession): void {
687
- if (shuttingDown) return;
695
+ if (shuttingDown || !hasSessionSubscribers(record.id)) return;
688
696
  snapshotScheduler.schedule(record.id, record, !record.streaming || record.dialogs.length > 0);
689
697
  }
690
698
 
691
699
  function emitSessionSnapshot(record: RuntimeSession): void {
692
- broadcastEvent({
700
+ if (shuttingDown || !hasSessionSubscribers(record.id)) return;
701
+ broadcastEvent(sessionSnapshot(record));
702
+ }
703
+
704
+ function sessionSnapshot(record: RuntimeSession): Json {
705
+ return {
693
706
  type: "snapshot",
694
707
  sessionId: record.id,
695
708
  session: sessionSummary(record),
@@ -703,7 +716,7 @@ function emitSessionSnapshot(record: RuntimeSession): void {
703
716
  messages: browserMessages(snapshotMessages(record)),
704
717
  pendingQueue: pendingQueueSnapshot(record),
705
718
  dialogs: record.dialogs,
706
- });
719
+ };
707
720
  }
708
721
 
709
722
  function serializeWorkspaces(): Json[] {
@@ -1473,6 +1486,19 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
1473
1486
  return task;
1474
1487
  }
1475
1488
 
1489
+ function refreshOpenedSession(record: RuntimeSession): void {
1490
+ if (record.openRefresh || record.streaming || record.starting) return;
1491
+ record.openRefresh = (async () => {
1492
+ await Promise.allSettled([refreshState(record), refreshMessages(record)]);
1493
+ if (record.models.length === 0) await refreshModels(record).catch(() => {});
1494
+ applyCachedModelInfo(record);
1495
+ await refreshStats(record);
1496
+ broadcastSessionSnapshot(record);
1497
+ })().catch((error) => {
1498
+ console.error("[pi-web] open session refresh failed:", error);
1499
+ }).finally(() => { record.openRefresh = undefined; });
1500
+ }
1501
+
1476
1502
  async function openSessionRecord(record: RuntimeSession): Promise<void> {
1477
1503
  record.lastUsed = Date.now();
1478
1504
  applyCachedCommands(record);
@@ -1535,6 +1561,14 @@ async function openSessionRecord(record: RuntimeSession): Promise<void> {
1535
1561
  return;
1536
1562
  }
1537
1563
 
1564
+ if (record.proc && record.state) {
1565
+ // Live events keep this snapshot current; browsing must not wait for RPC.
1566
+ applyCachedModelInfo(record);
1567
+ refreshOpenedSession(record);
1568
+ broadcastSessionSnapshot(record);
1569
+ return;
1570
+ }
1571
+
1538
1572
  if (!record.proc) {
1539
1573
  await ensureProcess(record);
1540
1574
  } else {
@@ -1909,21 +1943,11 @@ async function listDirectories(input: string): Promise<Json> {
1909
1943
  .map((e) => ({ name: e.name, path: join(target, e.name), hidden: e.name.startsWith(".") }))
1910
1944
  .sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
1911
1945
  const crumbs: { name: string; path: string; hidden: boolean }[] = [];
1912
- {
1913
- const parts: string[] = [];
1914
- let current = target;
1915
- for (;;) {
1916
- parts.unshift(current);
1917
- const up = dirname(current);
1918
- if (up === current) break;
1919
- current = up;
1920
- }
1921
- let acc = parts[0];
1922
- crumbs.push({ name: parts[0], path: parts[0], hidden: false });
1923
- for (const part of parts.slice(1)) {
1924
- acc = join(acc, part);
1925
- crumbs.push({ name: part, path: acc, hidden: part.startsWith(".") });
1926
- }
1946
+ for (let current = target; ; current = dirname(current)) {
1947
+ const isRoot = dirname(current) === current;
1948
+ const name = isRoot ? current : basename(current);
1949
+ crumbs.unshift({ name, path: current, hidden: !isRoot && name.startsWith(".") });
1950
+ if (isRoot) break;
1927
1951
  }
1928
1952
  const parent = dirname(target) === target ? null : dirname(target);
1929
1953
  return { path: target, home, parent, crumbs, entries: dirs, truncated: false };
@@ -2593,6 +2617,9 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2593
2617
  }
2594
2618
 
2595
2619
  if (method === "GET" && path === "/api/events") {
2620
+ const sessionId = url.searchParams.get("sessionId") || undefined;
2621
+ const subscribedSession = sessionId ? sessions.get(sessionId) : undefined;
2622
+ if (sessionId && !subscribedSession) return sendJson(res, 404, { error: "session not found" });
2596
2623
  res.writeHead(200, {
2597
2624
  "content-type": "text/event-stream; charset=utf-8",
2598
2625
  "cache-control": "no-store",
@@ -2604,10 +2631,13 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2604
2631
  const removeClient = () => {
2605
2632
  closeSseClient(client);
2606
2633
  sseClients.delete(client);
2634
+ if (sessionId && !hasSessionSubscribers(sessionId)) snapshotScheduler.cancel(sessionId);
2607
2635
  };
2608
2636
  client = createSseClient(res, removeClient);
2637
+ client.sessionId = sessionId;
2609
2638
  sseClients.add(client);
2610
2639
  req.on("close", removeClient);
2640
+ if (subscribedSession) sendSseEvent(client, sessionSnapshot(subscribedSession));
2611
2641
  return true;
2612
2642
  }
2613
2643
 
package/sse-channel.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { canonicalizeJson, diffSessionSnapshots, type JsonValue } from "./web/session-sync.js";
2
+
1
3
  export type SseEvent = Record<string, unknown>;
2
4
 
3
5
  // Coalesce before serializing full histories, so a burst of tokens does not
@@ -44,6 +46,9 @@ export interface SseWritable {
44
46
 
45
47
  export interface SseClient {
46
48
  res: SseWritable;
49
+ sessionId?: string;
50
+ snapshotBaseline?: Record<string, JsonValue>;
51
+ snapshotRevision: number;
47
52
  waitingForDrain: boolean;
48
53
  pendingEvents: Map<string, SseEvent>;
49
54
  closed: boolean;
@@ -59,6 +64,7 @@ function eventKey(event: SseEvent): string {
59
64
  export function createSseClient(res: SseWritable, onError: () => void): SseClient {
60
65
  return {
61
66
  res,
67
+ snapshotRevision: 0,
62
68
  waitingForDrain: false,
63
69
  pendingEvents: new Map(),
64
70
  closed: false,
@@ -68,7 +74,24 @@ export function createSseClient(res: SseWritable, onError: () => void): SseClien
68
74
 
69
75
  function writeEvent(client: SseClient, event: SseEvent): boolean {
70
76
  try {
71
- const accepted = client.res.write(`data: ${JSON.stringify(event)}\n\n`);
77
+ let outgoing = event;
78
+ const snapshot = event.type === "snapshot" ? event as Record<string, JsonValue> : undefined;
79
+ if (snapshot) {
80
+ const revision = client.snapshotRevision + 1;
81
+ if (client.snapshotBaseline && client.snapshotBaseline.sessionId === snapshot.sessionId) {
82
+ const patches = diffSessionSnapshots(client.snapshotBaseline, snapshot);
83
+ if (patches.length === 0) return true;
84
+ outgoing = { type: "session_patch", sessionId: snapshot.sessionId, baseRevision: client.snapshotRevision, revision, patches };
85
+ } else {
86
+ outgoing = { ...snapshot, revision };
87
+ }
88
+ }
89
+ const accepted = client.res.write(`data: ${JSON.stringify(outgoing)}\n\n`);
90
+ // A false return still means Node accepted the bytes into its buffer.
91
+ if (snapshot) {
92
+ client.snapshotBaseline = snapshot;
93
+ client.snapshotRevision++;
94
+ }
72
95
  if (accepted) return true;
73
96
  client.waitingForDrain = true;
74
97
  client.res.once("drain", () => flushPendingEvents(client));
@@ -93,7 +116,18 @@ function flushPendingEvents(client: SseClient): void {
93
116
 
94
117
  export function sendSseEvent(client: SseClient, event: SseEvent): void {
95
118
  if (client.closed) return;
119
+ if (event.type === "snapshot") {
120
+ try {
121
+ const { revision: _revision, ...body } = event;
122
+ event = canonicalizeJson(body) as Record<string, JsonValue>;
123
+ } catch {
124
+ closeSseClient(client);
125
+ client.onError();
126
+ return;
127
+ }
128
+ }
96
129
  if (client.waitingForDrain) {
130
+ // Snapshot entries are detached full targets, diffed only when written.
97
131
  // Keep only the newest event of each kind while the browser catches up.
98
132
  // This bounds memory even when snapshots contain a long conversation.
99
133
  client.pendingEvents.set(eventKey(event), event);
@@ -106,4 +140,6 @@ export function closeSseClient(client: SseClient): void {
106
140
  client.closed = true;
107
141
  client.waitingForDrain = false;
108
142
  client.pendingEvents.clear();
143
+ client.snapshotBaseline = undefined;
144
+ client.snapshotRevision = 0;
109
145
  }
package/web/app.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { markdown as renderMarkdown } from "./assets/markdown.js";
4
4
  import { observeMarkdownDiagrams } from "./markdown-diagrams.js";
5
+ import { applySessionPatches } from "./session-sync.js";
5
6
 
6
7
  observeMarkdownDiagrams();
7
8
  import { webSlashCommands, unsupportedSlashCommands, parseSlashCommand } from "./slash-commands.js";
@@ -20,6 +21,8 @@ const S = {
20
21
  currentWorkspaceId: null,
21
22
  currentSessionId: null,
22
23
  snapshots: new Map(),
24
+ loadedSessions: new Set(),
25
+ sessionOpens: new Map(),
23
26
  newSessionSnapshot: null,
24
27
  newSessionPreferenceWorkspaceId: null,
25
28
  collapsed: new Set(),
@@ -108,8 +111,8 @@ async function api(path, options = {}) {
108
111
  return data;
109
112
  }
110
113
 
111
- function post(path, body) {
112
- return api(path, { method: "POST", body: JSON.stringify(body || {}) });
114
+ function post(path, body, options = {}) {
115
+ return api(path, { ...options, method: "POST", body: JSON.stringify(body || {}) });
113
116
  }
114
117
 
115
118
  function timeLabel(iso) {
@@ -1456,6 +1459,7 @@ function renderActive() {
1456
1459
  }
1457
1460
 
1458
1461
  function renderConversation() {
1462
+ syncEventSubscription();
1459
1463
  const mount = $("#conversationMount");
1460
1464
  if (!mount) return;
1461
1465
  const previousInput = $("#composerInput");
@@ -3127,7 +3131,14 @@ async function openSession(id, opts = {}) {
3127
3131
  if (!opts.skipRemember) rememberCurrentDraft();
3128
3132
  const generation = ++S.openGeneration;
3129
3133
  S.currentSessionId = id;
3130
- S.loadingSession = opts.initialSnapshot ? null : id;
3134
+ if (opts.initialSnapshot) {
3135
+ S.snapshots.set(id, opts.initialSnapshot);
3136
+ S.loadedSessions.add(id);
3137
+ }
3138
+ const before = S.snapshots.get(id);
3139
+ const cached = S.loadedSessions.has(id) && before?.session?.id === id && !before.error;
3140
+ S.loadingSession = cached ? null : id;
3141
+ if (before?.session?.workspaceId) S.currentWorkspaceId = before.session.workspaceId;
3131
3142
  if (!opts.silent) {
3132
3143
  const key = draftKey(id);
3133
3144
  const savedDraft = S.drafts.get(key) || "";
@@ -3142,14 +3153,24 @@ async function openSession(id, opts = {}) {
3142
3153
  }
3143
3154
  renderSidebar();
3144
3155
  renderConversation();
3156
+ let request = S.sessionOpens.get(id);
3145
3157
  try {
3146
- const data = opts.initialSnapshot ?? await post(`/api/sessions/${encodeURIComponent(id)}/open`);
3147
- S.snapshots.set(id, data);
3158
+ if (!opts.initialSnapshot && !request) {
3159
+ const promise = post(`/api/sessions/${encodeURIComponent(id)}/open`, {}, { signal: AbortSignal.timeout(15_000) })
3160
+ .finally(() => { S.sessionOpens.delete(id); });
3161
+ request = { promise, snapshot: before };
3162
+ S.sessionOpens.set(id, request);
3163
+ }
3164
+ const data = opts.initialSnapshot ?? await request.promise;
3165
+ if (S.snapshots.get(id) === (opts.initialSnapshot ? before : request.snapshot)) S.snapshots.set(id, data);
3166
+ S.loadedSessions.add(id);
3148
3167
  if (generation !== S.openGeneration) return;
3149
3168
  const ws = data.session?.workspaceId;
3150
3169
  if (ws) S.currentWorkspaceId = ws;
3151
3170
  } catch (error) {
3152
- S.snapshots.set(id, { ...snapshotFor(id), session: { id, title: "会话", status: "error" }, error: String(error) });
3171
+ if (!cached && S.snapshots.get(id) === (request ? request.snapshot : before)) {
3172
+ S.snapshots.set(id, { ...snapshotFor(id), session: { id, title: "会话", status: "error" }, error: String(error) });
3173
+ }
3153
3174
  } finally {
3154
3175
  if (generation === S.openGeneration) {
3155
3176
  S.loadingSession = null;
@@ -3257,6 +3278,7 @@ async function ensureWorkspaceThen(action) {
3257
3278
  let browser = null;
3258
3279
  let pickerResolve = null;
3259
3280
  let browserKeyHandler = null;
3281
+ let directoryRequest = null;
3260
3282
 
3261
3283
  async function adoptWorkspacePath(path) {
3262
3284
  const added = await post("/api/workspaces", { path });
@@ -3333,6 +3355,7 @@ function promptWorkspaceBrowse() {
3333
3355
  }
3334
3356
 
3335
3357
  function closeWorkspacePicker() {
3358
+ directoryRequest = null;
3336
3359
  if (browserKeyHandler) {
3337
3360
  document.removeEventListener("keydown", browserKeyHandler);
3338
3361
  browserKeyHandler = null;
@@ -3366,10 +3389,44 @@ function draftDirectory(draft) {
3366
3389
  function readDraft(listing, draft) {
3367
3390
  const directory = draftDirectory(draft);
3368
3391
  if (directory === null) return { directory: null, tail: null };
3369
- const answers = directory === levelDirectory(listing);
3392
+ const fold = (value) => separatorOf() === "\\" ? value.replaceAll("/", "\\").toLowerCase() : value;
3393
+ const answers = fold(directory) === fold(levelDirectory(listing));
3370
3394
  return { directory, tail: answers ? draft.slice(directory.length) : null };
3371
3395
  }
3372
3396
 
3397
+ async function previewPathDraft(draft) {
3398
+ const current = browser;
3399
+ const request = {};
3400
+ directoryRequest = request;
3401
+ if (!current || current.pathDraft !== draft) return;
3402
+ const listing = current.child ?? current.parent;
3403
+ const directory = draftDirectory(draft);
3404
+ current.error = null;
3405
+ if (directory === null || (listing && readDraft(listing, draft).tail !== null)) {
3406
+ current.loading = false;
3407
+ renderBrowser();
3408
+ return;
3409
+ }
3410
+ current.loading = true;
3411
+ renderBrowser();
3412
+ try {
3413
+ const data = await api(`/api/fs/list?path=${encodeURIComponent(directory)}`);
3414
+ if (browser !== current || directoryRequest !== request) return;
3415
+ if (data.error) throw new Error(data.error);
3416
+ current.parent = data;
3417
+ current.selected = null;
3418
+ current.child = null;
3419
+ current.home = data.home || current.home;
3420
+ current.loading = false;
3421
+ renderBrowser();
3422
+ } catch (error) {
3423
+ if (browser !== current || directoryRequest !== request) return;
3424
+ current.loading = false;
3425
+ current.error = error.message || String(error);
3426
+ renderBrowser();
3427
+ }
3428
+ }
3429
+
3373
3430
  function visibleEntries(entries, selectedPath, showHidden, filterPrefix) {
3374
3431
  const needle = filterPrefix === null ? "" : filterPrefix.toLowerCase();
3375
3432
  const displayable = (entry) => showHidden || !entry.hidden || needle.startsWith(".");
@@ -3408,6 +3465,9 @@ function browserTargetPath() {
3408
3465
 
3409
3466
  async function navigate(path) {
3410
3467
  if (!browser || !path) return;
3468
+ const current = browser;
3469
+ const request = {};
3470
+ directoryRequest = request;
3411
3471
  browser.loading = true;
3412
3472
  browser.error = null;
3413
3473
  browser.selected = null;
@@ -3416,7 +3476,7 @@ async function navigate(path) {
3416
3476
  let target = null;
3417
3477
  try {
3418
3478
  const data = await api(`/api/fs/list?path=${encodeURIComponent(path)}`);
3419
- if (!browser) return;
3479
+ if (browser !== current || directoryRequest !== request) return;
3420
3480
  if (data.error) throw new Error(data.error);
3421
3481
  target = data;
3422
3482
  browser.home = data.home || browser.home;
@@ -3425,7 +3485,7 @@ async function navigate(path) {
3425
3485
  browser.loading = false;
3426
3486
  renderBrowser();
3427
3487
  } catch (error) {
3428
- if (!browser) return;
3488
+ if (browser !== current || directoryRequest !== request) return;
3429
3489
  browser.loading = false;
3430
3490
  browser.error = error.message || String(error);
3431
3491
  renderBrowser();
@@ -3438,7 +3498,7 @@ async function navigate(path) {
3438
3498
  if (!parentCrumb) return;
3439
3499
  try {
3440
3500
  const data = await api(`/api/fs/list?path=${encodeURIComponent(parentCrumb.path)}`);
3441
- if (!browser || data.error) return;
3501
+ if (browser !== current || directoryRequest !== request || data.error) return;
3442
3502
  const sep = separatorOf();
3443
3503
  const fold = (value) => (sep === "\\" ? value.toLowerCase() : value);
3444
3504
  const match = (data.entries || []).find((entry) => fold(entry.path) === fold(target.path));
@@ -3454,6 +3514,10 @@ async function navigate(path) {
3454
3514
 
3455
3515
  async function selectEntry(entry) {
3456
3516
  if (!browser) return;
3517
+ const current = browser;
3518
+ const request = {};
3519
+ directoryRequest = request;
3520
+ browser.pathDraft = null;
3457
3521
  browser.selected = entry;
3458
3522
  browser.child = null;
3459
3523
  browser.loading = true;
@@ -3461,13 +3525,13 @@ async function selectEntry(entry) {
3461
3525
  renderBrowser();
3462
3526
  try {
3463
3527
  const data = await api(`/api/fs/list?path=${encodeURIComponent(entry.path)}`);
3464
- if (!browser) return;
3528
+ if (browser !== current || directoryRequest !== request) return;
3465
3529
  if (data.error) throw new Error(data.error);
3466
3530
  browser.child = data;
3467
3531
  browser.loading = false;
3468
3532
  renderBrowser();
3469
3533
  } catch (error) {
3470
- if (!browser) return;
3534
+ if (browser !== current || directoryRequest !== request) return;
3471
3535
  browser.loading = false;
3472
3536
  browser.error = error.message || String(error);
3473
3537
  browser.selected = null;
@@ -3492,6 +3556,8 @@ function openPathEditor() {
3492
3556
 
3493
3557
  function cancelPathEdit() {
3494
3558
  if (!browser) return;
3559
+ directoryRequest = null;
3560
+ browser.loading = false;
3495
3561
  browser.pathDraft = null;
3496
3562
  browser.error = null;
3497
3563
  renderBrowser();
@@ -3607,6 +3673,8 @@ function renderBrowser() {
3607
3673
  </div>
3608
3674
  </div>` : "";
3609
3675
 
3676
+ const previousPathInput = $("#dbPathInput");
3677
+ const pathSelection = previousPathInput ? [previousPathInput.selectionStart, previousPathInput.selectionEnd] : null;
3610
3678
  mount.innerHTML = `
3611
3679
  <div class="db-modal">
3612
3680
  <div class="db-modalMask" data-action="db-cancel"></div>
@@ -3633,10 +3701,9 @@ function renderBrowser() {
3633
3701
 
3634
3702
  const pathInput = $("#dbPathInput");
3635
3703
  if (pathInput) {
3636
- const start = pathInput.selectionStart;
3637
- const end = pathInput.selectionEnd;
3704
+ const [start, end] = pathSelection ?? [pathInput.selectionStart, pathInput.selectionEnd];
3638
3705
  pathInput.focus();
3639
- if (start != null && end != null && start === end) pathInput.setSelectionRange(start, end);
3706
+ if (start != null && end != null) pathInput.setSelectionRange(start, end);
3640
3707
  pathInput.addEventListener("keydown", (event) => {
3641
3708
  if (event.key === "Enter" && !event.isComposing) {
3642
3709
  event.preventDefault();
@@ -3651,7 +3718,7 @@ function renderBrowser() {
3651
3718
  pathInput.addEventListener("input", () => {
3652
3719
  const caret = pathInput.selectionStart ?? pathInput.value.length;
3653
3720
  b.pathDraft = pathInput.value;
3654
- renderBrowser();
3721
+ void previewPathDraft(b.pathDraft);
3655
3722
  const next = $("#dbPathInput");
3656
3723
  if (next) next.setSelectionRange(caret, caret);
3657
3724
  });
@@ -4869,6 +4936,7 @@ async function onClick(ev) {
4869
4936
  ];
4870
4937
  await Promise.all(orphanedAttachments.map((attachment) => deleteAttachment(attachment.id)));
4871
4938
  S.snapshots.delete(sessionId);
4939
+ S.loadedSessions.delete(sessionId);
4872
4940
  S.drafts.delete(sessionDraftKey);
4873
4941
  S.failedDrafts.delete(sessionId);
4874
4942
  S.attachmentDrafts.delete(sessionDraftKey);
@@ -5124,7 +5192,8 @@ let loginPromise;
5124
5192
 
5125
5193
  function requireLogin() {
5126
5194
  if (loginPromise) return loginPromise;
5127
- eventSource?.close();
5195
+ let authenticated = false;
5196
+ disconnectEvents();
5128
5197
  loginPromise = (async () => {
5129
5198
  let { passwordConfigured } = await api("/api/auth");
5130
5199
  const dialog = el(`<div class="pi-loginBackdrop">
@@ -5163,6 +5232,7 @@ function requireLogin() {
5163
5232
  input.value = "";
5164
5233
  dialog.remove();
5165
5234
  root.inert = false;
5235
+ authenticated = true;
5166
5236
  resolve();
5167
5237
  } catch (error) {
5168
5238
  message.textContent = error.message || String(error);
@@ -5172,8 +5242,10 @@ function requireLogin() {
5172
5242
  }
5173
5243
  };
5174
5244
  });
5175
- if (eventSource) connectEvents();
5176
- })().finally(() => { loginPromise = null; });
5245
+ })().finally(() => {
5246
+ loginPromise = null;
5247
+ if (authenticated && !document.hidden) connectEvents();
5248
+ });
5177
5249
  return loginPromise;
5178
5250
  }
5179
5251
 
@@ -5182,6 +5254,17 @@ let eventWatchdog;
5182
5254
  let lastEventAt = 0;
5183
5255
  let resyncPending;
5184
5256
 
5257
+ function disconnectEvents() {
5258
+ eventSource?.close();
5259
+ eventSource = null;
5260
+ clearInterval(eventWatchdog);
5261
+ }
5262
+
5263
+ function syncEventSubscription() {
5264
+ if (document.hidden || !eventSource || loginPromise) return;
5265
+ if (eventSource.sessionId !== (S.currentSessionId || null)) connectEvents(false);
5266
+ }
5267
+
5185
5268
  async function resyncAfterReconnect() {
5186
5269
  if (resyncPending) return resyncPending;
5187
5270
  resyncPending = (async () => {
@@ -5200,7 +5283,7 @@ async function resyncAfterReconnect() {
5200
5283
  const data = await api(`/api/sessions/${encodeURIComponent(id)}/open`, {
5201
5284
  method: "POST", body: "{}", signal: AbortSignal.timeout(15_000),
5202
5285
  });
5203
- if (S.snapshots.get(id) === before) S.snapshots.set(id, data);
5286
+ if (id === S.currentSessionId && generation === S.openGeneration && S.snapshots.get(id) === before) S.snapshots.set(id, data);
5204
5287
  } else if (id && generation === S.openGeneration) {
5205
5288
  rememberCurrentDraft();
5206
5289
  ++S.openGeneration;
@@ -5215,21 +5298,31 @@ async function resyncAfterReconnect() {
5215
5298
  return resyncPending;
5216
5299
  }
5217
5300
 
5218
- function connectEvents() {
5219
- eventSource?.close();
5220
- const es = new EventSource("/api/events");
5301
+ function connectEvents(resync = true) {
5302
+ disconnectEvents();
5303
+ if (document.hidden || loginPromise) return;
5304
+ const sessionId = S.currentSessionId || null;
5305
+ const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
5306
+ const es = new EventSource(`/api/events${query}`);
5307
+ es.sessionId = sessionId;
5221
5308
  eventSource = es;
5309
+ let streamedSnapshot;
5310
+ let streamRevision = 0;
5311
+ let opened = false;
5222
5312
  lastEventAt = Date.now();
5223
- clearInterval(eventWatchdog);
5224
5313
  // Heartbeats arrive every 25s, including when the model is idle. Some mobile
5225
5314
  // networks leave a dead stream open without notifying EventSource.
5226
5315
  eventWatchdog = setInterval(() => {
5227
- if (document.hidden || loginPromise || eventSource !== es) return;
5316
+ if (eventSource !== es || document.hidden || loginPromise) return;
5228
5317
  if (Date.now() - lastEventAt > 60_000) connectEvents();
5229
5318
  }, 5_000);
5230
5319
  es.onopen = () => {
5320
+ if (eventSource !== es) return;
5321
+ streamedSnapshot = undefined;
5322
+ streamRevision = 0;
5231
5323
  lastEventAt = Date.now();
5232
- void resyncAfterReconnect();
5324
+ if (resync || opened) void resyncAfterReconnect();
5325
+ opened = true;
5233
5326
  };
5234
5327
  es.onmessage = (ev) => {
5235
5328
  if (eventSource !== es) return;
@@ -5265,9 +5358,26 @@ function connectEvents() {
5265
5358
  }
5266
5359
  return;
5267
5360
  }
5268
- if (event.type === "snapshot") {
5269
- if (!event.sessionId) return;
5270
- S.snapshots.set(event.sessionId, { ...snapshotFor(event.sessionId), ...event });
5361
+ if (event.type === "snapshot" || event.type === "session_patch") {
5362
+ if (!event.sessionId || event.sessionId !== es.sessionId || event.sessionId !== S.currentSessionId) return;
5363
+ if (event.type === "snapshot") {
5364
+ const { revision, ...snapshot } = event;
5365
+ streamedSnapshot = snapshot;
5366
+ streamRevision = revision;
5367
+ } else {
5368
+ if (!streamedSnapshot || event.baseRevision !== streamRevision || event.revision !== streamRevision + 1) {
5369
+ connectEvents(false);
5370
+ return;
5371
+ }
5372
+ try {
5373
+ streamedSnapshot = applySessionPatches(streamedSnapshot, event.patches);
5374
+ } catch {
5375
+ connectEvents(false);
5376
+ return;
5377
+ }
5378
+ streamRevision = event.revision;
5379
+ }
5380
+ S.snapshots.set(event.sessionId, { ...snapshotFor(event.sessionId), error: undefined, ...streamedSnapshot });
5271
5381
  const current = event.sessionId === S.currentSessionId;
5272
5382
  // Settings do not depend on session snapshots. Rebuilding the panel on
5273
5383
  // every streamed chunk replaces controls between pointerdown and click.
@@ -5300,15 +5410,18 @@ function connectEvents() {
5300
5410
  // ignore malformed event
5301
5411
  }
5302
5412
  };
5303
- es.onerror = () => { void resyncAfterReconnect(); };
5413
+ es.onerror = () => {
5414
+ if (eventSource === es && !document.hidden) void resyncAfterReconnect();
5415
+ };
5304
5416
  }
5305
5417
 
5306
5418
  window.addEventListener("resize", () => { updateViewport(); applyFrameLayout(); });
5307
5419
  window.visualViewport?.addEventListener("resize", updateViewport);
5308
5420
  window.visualViewport?.addEventListener("scroll", updateViewport);
5309
- window.addEventListener("online", () => { if (eventSource && !loginPromise) connectEvents(); });
5421
+ window.addEventListener("online", () => { if (!document.hidden && !loginPromise) connectEvents(); });
5310
5422
  document.addEventListener("visibilitychange", () => {
5311
- if (!document.hidden && eventSource && !loginPromise) connectEvents();
5423
+ if (document.hidden) disconnectEvents();
5424
+ else if (!loginPromise) connectEvents();
5312
5425
  });
5313
5426
  setLanguage(getLanguage());
5314
5427
  observeUi();
@@ -0,0 +1,12 @@
1
+ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
2
+ export type SessionPatch =
3
+ | { op: "set"; path: (string | number)[]; value: JsonValue }
4
+ | { op: "delete"; path: (string | number)[] }
5
+ | { op: "append"; path: (string | number)[]; value: string }
6
+ | { op: "truncate"; path: (string | number)[]; length: number };
7
+ /** Detached JSON normalization: undefined object fields disappear; array holes become null. */
8
+ export function canonicalizeJson(value: unknown): JsonValue | undefined;
9
+ /** Compare canonical JSON, emitting string suffixes and changes to individual paths. */
10
+ export function diffSessionSnapshots(previous: JsonValue, next: JsonValue): SessionPatch[];
11
+ /** Immutable application with structural sharing; invalid or unsafe paths throw. */
12
+ export function applySessionPatches<T extends JsonValue>(snapshot: T, patches: readonly SessionPatch[]): T;
@@ -0,0 +1,105 @@
1
+ // Paths contain object keys or array indices. Operations are set, delete (object
2
+ // field), append (string suffix), and truncate (array length). A root set resets
3
+ // structure. Paths traverse only own properties, including ordinary JSON keys
4
+ // such as __proto__; assignments cannot invoke prototype setters.
5
+ const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
6
+ function setOwn(value, key, item) {
7
+ Object.defineProperty(value, key, { value: item, enumerable: true, writable: true, configurable: true });
8
+ }
9
+
10
+ export function canonicalizeJson(value) {
11
+ const ancestors = new Set();
12
+ function visit(input, key = "") {
13
+ if (input && typeof input.toJSON === "function") input = input.toJSON(key);
14
+ if (input === null || typeof input === "string" || typeof input === "boolean") return input;
15
+ if (typeof input === "number") return Number.isFinite(input) ? input : null;
16
+ if (typeof input === "bigint") throw new TypeError("BigInt is not JSON data");
17
+ if (typeof input !== "object") return undefined;
18
+ if (ancestors.has(input)) throw new TypeError("Circular JSON data");
19
+ ancestors.add(input);
20
+ let result;
21
+ if (Array.isArray(input)) {
22
+ result = Array.from({ length: input.length }, (_, i) => visit(input[i], String(i)) ?? null);
23
+ } else {
24
+ result = {};
25
+ for (const key of Object.keys(input)) {
26
+ const item = visit(input[key], key);
27
+ if (item !== undefined) setOwn(result, key, item);
28
+ }
29
+ }
30
+ ancestors.delete(input);
31
+ return result;
32
+ }
33
+ return visit(value);
34
+ }
35
+
36
+ // Compare detached canonical JSON without serializing unchanged history strings.
37
+ export function diffSessionSnapshots(previous, next) {
38
+ const patches = [];
39
+ function walk(before, after, path) {
40
+ if (before === after) return;
41
+ if (typeof before === "string" && typeof after === "string" && after.startsWith(before)) {
42
+ patches.push({ op: "append", path, value: after.slice(before.length) });
43
+ } else if (Array.isArray(before) && Array.isArray(after)) {
44
+ const common = Math.min(before.length, after.length);
45
+ for (let i = 0; i < common; i++) walk(before[i], after[i], [...path, i]);
46
+ for (let i = common; i < after.length; i++) patches.push({ op: "set", path: [...path, i], value: after[i] });
47
+ if (after.length < before.length) patches.push({ op: "truncate", path, length: after.length });
48
+ } else if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
49
+ for (const key of Object.keys(before)) {
50
+ if (!own(after, key)) patches.push({ op: "delete", path: [...path, key] });
51
+ }
52
+ for (const key of Object.keys(after)) {
53
+ if (own(before, key)) walk(before[key], after[key], [...path, key]);
54
+ else patches.push({ op: "set", path: [...path, key], value: after[key] });
55
+ }
56
+ } else {
57
+ patches.push({ op: "set", path, value: after });
58
+ }
59
+ }
60
+ walk(previous, next, []);
61
+ return patches;
62
+ }
63
+
64
+ // Rebuild only modified paths; the input snapshot and unchanged branches retain
65
+ // their identity. Invalid operations throw before any input can be mutated.
66
+ export function applySessionPatches(snapshot, patches) {
67
+ if (!Array.isArray(patches)) throw new TypeError("Expected patches array");
68
+ let result = snapshot;
69
+ for (const patch of patches) {
70
+ if (!patch || !["set", "delete", "append", "truncate"].includes(patch.op) || !Array.isArray(patch.path)) throw new TypeError("Invalid patch");
71
+ for (const key of patch.path) {
72
+ if (typeof key !== "string" && (!Number.isSafeInteger(key) || key < 0)) throw new TypeError("Invalid patch path");
73
+ }
74
+ function update(current, depth) {
75
+ if (depth === patch.path.length) {
76
+ if (patch.op === "set") {
77
+ if (!own(patch, "value")) throw new TypeError("Missing patch value");
78
+ const value = canonicalizeJson(patch.value);
79
+ if (value === undefined) throw new TypeError("Invalid patch value");
80
+ return value;
81
+ }
82
+ if (patch.op === "append" && typeof current === "string" && typeof patch.value === "string") return current + patch.value;
83
+ if (patch.op === "truncate" && Array.isArray(current) && Number.isSafeInteger(patch.length) && patch.length >= 0 && patch.length <= current.length) return current.slice(0, patch.length);
84
+ throw new TypeError("Invalid patch target");
85
+ }
86
+ const key = patch.path[depth];
87
+ if (!current || typeof current !== "object") throw new TypeError("Invalid patch parent");
88
+ const array = Array.isArray(current);
89
+ if (array ? typeof key !== "number" || key > current.length : typeof key !== "string") throw new TypeError("Invalid patch key");
90
+ const leaf = depth === patch.path.length - 1;
91
+ const exists = own(current, key);
92
+ if (!exists && !(leaf && patch.op === "set")) throw new TypeError("Missing patch path");
93
+ const copy = array ? current.slice() : { ...current };
94
+ if (leaf && patch.op === "delete") {
95
+ if (array) throw new TypeError("Use truncate for arrays");
96
+ delete copy[key];
97
+ } else {
98
+ setOwn(copy, key, update(exists ? current[key] : undefined, depth + 1));
99
+ }
100
+ return copy;
101
+ }
102
+ result = update(result, 0);
103
+ }
104
+ return result;
105
+ }