claude-simple 0.2.27 → 0.2.30

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.
Files changed (79) hide show
  1. package/README.md +17 -1
  2. package/bin/edge-ctl.js +151 -4
  3. package/client.js +29 -4
  4. package/dist/addon-fit.min.js +0 -0
  5. package/dist/app.0.2.30.min.js +1308 -0
  6. package/dist/index.html +2 -2
  7. package/dist/style.0.2.30.min.css +2012 -0
  8. package/dist/xterm.min.css +0 -0
  9. package/dist/xterm.min.js +0 -0
  10. package/gateway.js +0 -0
  11. package/package.json +2 -2
  12. package/public/addon-fit.min.js +0 -0
  13. package/public/agent-panel.js +54 -22
  14. package/public/api.js +8 -5
  15. package/public/app.js +7 -5
  16. package/public/base.css +0 -0
  17. package/public/cache.js +19 -0
  18. package/public/chat.js +110 -19
  19. package/public/files.js +26 -10
  20. package/public/git.js +2 -2
  21. package/public/icons/icon-180.png +0 -0
  22. package/public/icons/icon-192.png +0 -0
  23. package/public/icons/icon-512.png +0 -0
  24. package/public/icons/icon-maskable-512.png +0 -0
  25. package/public/index.html +2 -2
  26. package/public/lib.js +0 -0
  27. package/public/manifest.webmanifest +0 -0
  28. package/public/markdown.js +16 -0
  29. package/public/math.js +145 -7
  30. package/public/mobile.js +0 -0
  31. package/public/notes.js +316 -0
  32. package/public/notifications.js +0 -0
  33. package/public/offline.html +0 -0
  34. package/public/pwa.js +1 -1
  35. package/public/router.js +4 -0
  36. package/public/scroll-follow.js +78 -0
  37. package/public/settings.js +41 -0
  38. package/public/shell/boot.js +0 -0
  39. package/public/shell/dashboard.js +0 -0
  40. package/public/shell/hub.js +0 -0
  41. package/public/shell/layout.js +0 -0
  42. package/public/shell/notes.js +0 -0
  43. package/public/shell/session-context.js +0 -0
  44. package/public/shell/session-list.js +79 -21
  45. package/public/shell/session-nav.js +70 -21
  46. package/public/shell/templates.js +3 -0
  47. package/public/shell/util.js +0 -0
  48. package/public/shell.js +15 -2
  49. package/public/state.js +7 -7
  50. package/public/style.css +161 -0
  51. package/public/sw.js +10 -5
  52. package/public/terminal.js +0 -0
  53. package/public/wake-lock.js +0 -0
  54. package/public/workspace-path.js +61 -0
  55. package/public/xterm.min.css +0 -0
  56. package/public/xterm.min.js +0 -0
  57. package/src/agent-quota.js +228 -0
  58. package/src/agents/claude.js +0 -0
  59. package/src/agents/codex.js +2 -0
  60. package/src/agents/cursor.js +2 -1
  61. package/src/agents/gemini.js +2 -1
  62. package/src/agents/grok.js +22 -1
  63. package/src/agents/meta-agent.js +7 -4
  64. package/src/agents/models.js +21 -12
  65. package/src/agents/sessions.js +270 -81
  66. package/src/agents/stream-batch.js +0 -0
  67. package/src/app.js +111 -51
  68. package/src/auto-update.js +2 -0
  69. package/src/collab.js +3 -0
  70. package/src/platform.js +133 -0
  71. package/src/project-api-path.js +14 -0
  72. package/src/runtime/approvals.js +0 -0
  73. package/src/runtime/chat-broadcast.js +12 -0
  74. package/src/runtime/outbox.js +0 -0
  75. package/src/runtime/port-websocket.js +80 -0
  76. package/src/runtime/protocol.js +0 -0
  77. package/src/session-history.js +31 -0
  78. package/dist/app.0.2.27.min.js +0 -1182
  79. package/dist/style.0.2.27.min.css +0 -1851
File without changes
package/dist/xterm.min.js CHANGED
File without changes
package/gateway.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.27",
3
+ "version": "0.2.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
@@ -25,7 +25,7 @@
25
25
  "start": "NODE_ENV=production node server.js",
26
26
  "dev": "NODE_ENV=development node --watch server.js",
27
27
  "serve": "node server.js",
28
- "test": "node --test",
28
+ "test": "node --test test/*.test.js",
29
29
  "gateway": "node gateway.js",
30
30
  "gateway:go": "cd gateway && go run .",
31
31
  "gateway:build": "cd gateway && make build",
File without changes
@@ -6,7 +6,8 @@ import { esc, $ } from './lib.js';
6
6
  import { api, authHeaders, currentMachineId } from './api.js';
7
7
  import { hubMode, ctx, currentAgent, currentProject } from './state.js';
8
8
  import { renderMermaid } from './mermaid.js';
9
- import { normalizeMarkdownText } from './markdown.js';
9
+ import { parseMarkdownWithMath } from './markdown.js';
10
+ import { renderMath } from './math.js';
10
11
 
11
12
  let panelOpen = false;
12
13
  let mode = 'chat'; // chat | report | history | config
@@ -28,12 +29,9 @@ let currentNoteSnapshot = null;
28
29
  const LAST_SESSION_KEY = 'ma_last_session_id';
29
30
 
30
31
  function mdHtml(text) {
31
- if (typeof marked !== 'undefined') {
32
- try {
33
- return marked.parse(normalizeMarkdownText(text), { breaks: true, gfm: true });
34
- } catch { /* fallthrough */ }
35
- }
36
- return esc(text).replace(/\n/g, '<br>');
32
+ return parseMarkdownWithMath(text, {
33
+ fallback: (t) => esc(t).replace(/\n/g, '<br>'),
34
+ });
37
35
  }
38
36
 
39
37
  function highlight(root) {
@@ -43,12 +41,14 @@ function highlight(root) {
43
41
  try { hljs.highlightElement(el); } catch {}
44
42
  });
45
43
  }
44
+ renderMath(root);
46
45
  renderMermaid(root);
47
46
  }
48
47
 
49
48
  /** Fetch SSE with auth (+ hub machine routing). */
50
49
  async function streamSSE(path, body, handlers) {
51
- const headers = authHeaders({ 'Content-Type': 'application/json' });
50
+ // Meta Agent is hub-owned. Do not attach the currently selected edge.
51
+ const headers = authHeaders({ 'Content-Type': 'application/json' }, { hub: true });
52
52
  const res = await fetch(path, {
53
53
  method: 'POST',
54
54
  headers,
@@ -77,6 +77,10 @@ async function streamSSE(path, body, handlers) {
77
77
  let data;
78
78
  try { data = JSON.parse(raw); } catch { continue; }
79
79
  handlers[event]?.(data);
80
+ if (!handlers[event] && handlers.tool) {
81
+ if (event === 'tool_call') handlers.tool({name:data.name, args:data.args, id:data.callId, phase:'start'});
82
+ if (event === 'tool_result') handlers.tool({id:data.callId, phase:data.ok === false ? 'error' : 'done', result:data.ok === false ? data.error : data.result});
83
+ }
80
84
  handlers.any?.(event, data);
81
85
  event = 'message';
82
86
  } else if (line.trim() === '') {
@@ -375,16 +379,19 @@ function appendMsg(kind, text, opts = {}) {
375
379
  } else if (kind === 'assistant') {
376
380
  el.innerHTML = `<div class="ma-bubble assistant md">${mdHtml(text)}</div>`;
377
381
  highlight(el);
382
+ } else if (kind === 'reasoning') {
383
+ el.innerHTML = `<details class="ma-reasoning" open><summary>思考过程</summary><pre style="white-space:pre-wrap"></pre></details>`;
384
+ el.querySelector('pre').textContent = text;
378
385
  } else if (kind === 'error') {
379
386
  el.innerHTML = `<div class="ma-bubble error">${esc(text)}</div>`;
380
387
  } else if (kind === 'system') {
381
388
  el.innerHTML = `<div class="ma-sys">${esc(text)}</div>`;
382
389
  } else if (kind === 'tool') {
383
- el.innerHTML = `<div class="ma-tool" data-tool-id="${esc(opts.id || '')}">
384
- <span class="ma-tool-name">${esc(opts.name || 'tool')}</span>
385
- <span class="ma-tool-phase">${esc(opts.phase || '')}</span>
386
- <pre class="ma-tool-args">${esc(JSON.stringify(opts.args || {}, null, 0).slice(0, 200))}</pre>
387
- </div>`;
390
+ el.innerHTML = `<details class="ma-tool" data-tool-id="${esc(opts.id || '')}">
391
+ <summary><span class="ma-tool-name">${esc(opts.name || 'tool')}</span>
392
+ <span class="ma-tool-phase">${esc(opts.phase || '')}</span></summary>
393
+ <pre class="ma-tool-args" style="white-space:pre-wrap">${esc(JSON.stringify(opts.args || {}, null, 2))}</pre>
394
+ </details>`;
388
395
  }
389
396
  box.appendChild(el);
390
397
  box.scrollTop = box.scrollHeight;
@@ -408,14 +415,15 @@ function updateToolChip(id, phase, result) {
408
415
  el.classList.toggle('error', phase === 'error');
409
416
  const ph = el.querySelector('.ma-tool-phase');
410
417
  if (ph) ph.textContent = phase;
411
- if (result && phase !== 'start') {
418
+ if (result !== undefined && phase !== 'start') {
412
419
  let pre = el.querySelector('.ma-tool-result');
413
420
  if (!pre) {
414
421
  pre = document.createElement('pre');
415
422
  pre.className = 'ma-tool-result';
423
+ pre.style.whiteSpace = 'pre-wrap';
416
424
  el.appendChild(pre);
417
425
  }
418
- pre.textContent = JSON.stringify(result, null, 0).slice(0, 500);
426
+ pre.textContent = JSON.stringify(result, null, 2);
419
427
  }
420
428
  }
421
429
 
@@ -466,7 +474,11 @@ function insertAtCursor(ta, text) {
466
474
 
467
475
  /** Upload blob → server path; insert [imgN](path) into input. */
468
476
  async function uploadAndInsertImage(blob, filename) {
469
- const headers = authHeaders({ 'x-filename': filename || `paste-${Date.now()}.png` });
477
+ // Attachments used by Meta Agent belong to the Hub conversation too.
478
+ const headers = authHeaders(
479
+ { 'x-filename': filename || `paste-${Date.now()}.png` },
480
+ { hub: true },
481
+ );
470
482
  // raw body upload — do not set Content-Type (browser sets multipart/stream)
471
483
  delete headers['Content-Type'];
472
484
  const res = await fetch('/api/upload-image', {
@@ -801,6 +813,10 @@ function renderHistoryMessages(messages) {
801
813
  continue;
802
814
  }
803
815
  if (m.role === 'assistant') {
816
+ if (m.reasoning) appendMsg('reasoning', m.reasoning).querySelector('details').open = false;
817
+ if (m.content) {
818
+ appendMsg('assistant', typeof m.content === 'string' ? m.content : userContentToText(m.content));
819
+ }
804
820
  if (m.tool_calls?.length) {
805
821
  for (const tc of m.tool_calls) {
806
822
  const name = tc.function?.name || tc.name || 'tool';
@@ -816,9 +832,6 @@ function renderHistoryMessages(messages) {
816
832
  });
817
833
  }
818
834
  }
819
- if (m.content) {
820
- appendMsg('assistant', typeof m.content === 'string' ? m.content : userContentToText(m.content));
821
- }
822
835
  continue;
823
836
  }
824
837
  if (m.role === 'tool') {
@@ -828,7 +841,7 @@ function renderHistoryMessages(messages) {
828
841
  } catch { /* keep string */ }
829
842
  // attach to last matching tool chip if possible
830
843
  const id = m.tool_call_id || '';
831
- if (id) updateToolChip(id, 'done', result);
844
+ if (id) updateToolChip(id, typeof result === 'string' && result.startsWith('error:') ? 'error' : 'done', result);
832
845
  else {
833
846
  appendMsg('tool', '', {
834
847
  name: 'tool_result',
@@ -845,11 +858,12 @@ function renderHistoryMessages(messages) {
845
858
  if (!pre) {
846
859
  pre = document.createElement('pre');
847
860
  pre.className = 'ma-tool-result';
861
+ pre.style.whiteSpace = 'pre-wrap';
848
862
  last.appendChild(pre);
849
863
  }
850
864
  pre.textContent = typeof result === 'string'
851
865
  ? result.slice(0, 500)
852
- : JSON.stringify(result, null, 0).slice(0, 500);
866
+ : JSON.stringify(result, null, 2);
853
867
  }
854
868
  }
855
869
  }
@@ -937,6 +951,7 @@ async function sendChat() {
937
951
 
938
952
  let asstEl = null;
939
953
  let content = '';
954
+ let reasoningEl = null;
940
955
 
941
956
  try {
942
957
  await streamSSE('/api/ai/chat', {
@@ -954,6 +969,19 @@ async function sendChat() {
954
969
  localStorage.setItem(LAST_SESSION_KEY, d.id);
955
970
  updateSessionChrome();
956
971
  },
972
+ round_start: () => {
973
+ asstEl = null;
974
+ if (reasoningEl) reasoningEl.querySelector('details').open = false;
975
+ reasoningEl = null;
976
+ },
977
+ reasoning: (d) => {
978
+ if (!reasoningEl) reasoningEl = appendMsg('reasoning', '');
979
+ reasoningEl.querySelector('pre').textContent = d.content || '';
980
+ },
981
+ tool_call: (d) => {
982
+ appendMsg('tool', '', { name: d.name, args: d.args, phase: '执行中', id: d.callId });
983
+ },
984
+ tool_result: (d) => updateToolChip(d.callId, d.ok === false ? 'error' : 'done', d.ok === false ? d.error : d.result),
957
985
  content: (d) => {
958
986
  content = d.content || '';
959
987
  if (!asstEl) asstEl = appendMsg('assistant', '');
@@ -980,7 +1008,11 @@ async function sendChat() {
980
1008
  messageCount: (currentSessionMeta?.messageCount || 0) + 2,
981
1009
  };
982
1010
  }
983
- if (d.content && asstEl) updateAssistant(asstEl, d.content);
1011
+ if (d.content) {
1012
+ if (!asstEl) asstEl = appendMsg('assistant', '');
1013
+ updateAssistant(asstEl, d.content);
1014
+ }
1015
+ if (reasoningEl) reasoningEl.querySelector('details').open = false;
984
1016
  updateSessionChrome();
985
1017
  refreshHistoryCache();
986
1018
  },
package/public/api.js CHANGED
@@ -24,8 +24,11 @@ function isHubOnlyPath(path) {
24
24
  || p === '/api/machines'
25
25
  || p === '/api/notifications'
26
26
  || p === '/api/notifications/read'
27
- || p === '/api/ai/notes'
28
- || p.startsWith('/api/ai/notes/')
27
+ || p === '/api/notes'
28
+ || p.startsWith('/api/notes/')
29
+ || p === '/api/queue'
30
+ || p.startsWith('/api/queue/')
31
+ || p.startsWith('/api/ai/')
29
32
  || p.startsWith('/api/auth/')
30
33
  || p.startsWith('/api/collab/');
31
34
  }
@@ -60,7 +63,7 @@ export async function api(method, path, body, signal, opts = {}) {
60
63
  method,
61
64
  signal,
62
65
  headers,
63
- ...(opts.cache ? { cache: opts.cache } : {}),
66
+ cache: opts.cache || (method.toUpperCase() === 'GET' ? 'no-store' : 'default'),
64
67
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
65
68
  });
66
69
  if (res.status === 304) return NOT_MODIFIED;
@@ -114,11 +117,11 @@ export function authQuery(extra = {}) {
114
117
  }
115
118
 
116
119
  /** Headers for raw fetch (e.g. file upload) in hub mode. */
117
- export function authHeaders(extra = {}) {
120
+ export function authHeaders(extra = {}, opts = {}) {
118
121
  const h = { ...extra };
119
122
  if (ctx.token) h.Authorization = `Bearer ${ctx.token}`;
120
123
  const mid = currentMachineId();
121
- if (hubMode.peek() && mid) h['X-Machine-Id'] = mid;
124
+ if (!opts.hub && hubMode.peek() && mid) h['X-Machine-Id'] = mid;
122
125
  return h;
123
126
  }
124
127
 
package/public/app.js CHANGED
@@ -1,16 +1,17 @@
1
1
  // app.js — Entry point
2
2
  import { ctx } from './state.js';
3
- import { probeHub } from './api.js';
3
+ import { probeHub } from './api.js?v=ui-24';
4
4
  import { initShell, showApp, initAuth, resumeSession, switchTab, shouldRestoreSessionOnBoot } from './shell.js';
5
- import { initChat } from './chat.js';
6
- import { initFilesTab } from './files.js?v=ui-22';
5
+ import { initChat } from './chat.js?v=ui-24';
6
+ import { initFilesTab } from './files.js?v=ui-24';
7
7
  import { initGitTab } from './git.js';
8
8
  import { initSettings } from './settings.js';
9
+ import { initNotes } from './notes.js';
9
10
  import { initTerminal } from './terminal.js';
10
- import { initImagePaste } from './chat.js';
11
+ import { initImagePaste } from './chat.js?v=ui-24';
11
12
  import { initMetaAgent } from './agent-panel.js';
12
13
  import { initRouter, applyInitialRoute, parseHashPath } from './router.js';
13
- import { initPwa } from './pwa.js';
14
+ import { initPwa } from './pwa.js?v=ui-24';
14
15
  import { initMobileChrome } from './mobile.js';
15
16
 
16
17
  function markBootReady() {
@@ -36,6 +37,7 @@ initChat();
36
37
  initFilesTab();
37
38
  initGitTab();
38
39
  initSettings();
40
+ initNotes();
39
41
  initTerminal();
40
42
  initImagePaste();
41
43
  initMetaAgent();
package/public/base.css CHANGED
File without changes
package/public/cache.js CHANGED
@@ -24,4 +24,23 @@ export async function setCachedWorkspaces(workspaces) {
24
24
  catch {}
25
25
  }
26
26
 
27
+ export async function getCachedTranscript(key) {
28
+ try { return recentTranscript(await db.get(`transcript:${key}`)); }
29
+ catch { return null; }
30
+ }
31
+
32
+ export async function setCachedTranscript(key, transcript) {
33
+ try { await db.set(`transcript:${key}`, recentTranscript(transcript), { ttl: 24 * 60 * 60 * 1000 }); }
34
+ catch {}
35
+ }
36
+
27
37
  // lastSessionContext lives in shell/session-context.js (avoid stale cache.js in browsers)
38
+
39
+ export function recentTranscript(snapshot) {
40
+ if (!snapshot?.msgs) return snapshot ?? null;
41
+ const drop = Math.max(0, snapshot.msgs.length - 80);
42
+ return { ...snapshot, msgs: snapshot.msgs.slice(drop), meta: {
43
+ ...snapshot.meta, oldestIndex: (snapshot.meta?.oldestIndex || 0) + drop,
44
+ truncated: drop > 0 || !!snapshot.meta?.truncated,
45
+ } };
46
+ }
package/public/chat.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // chat.js — WebSocket, message rendering, send actions
2
2
  import { watch, delegate, esc, $ } from './lib.js';
3
+ import { openWorkspaceFile } from './files.js?v=ui-23';
3
4
  import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
4
5
  currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
5
6
  getDefaultEffort, addCustomModel, setCurrentModel, chatDensity, wsStatus, sessionMetaMap, metaKey } from './state.js';
@@ -9,22 +10,42 @@ import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
9
10
  import { pushNotification } from './notifications.js';
10
11
  import { renderMermaid } from './mermaid.js';
11
12
  import { renderMath } from './math.js';
12
- import { normalizeMarkdownText } from './markdown.js';
13
+ import { parseMarkdownWithMath } from './markdown.js';
14
+ import { bindScrollFollow, isNearBottom } from './scroll-follow.js';
13
15
 
14
16
  function assistantLabel() {
15
17
  return AGENT_LABELS[currentAgent.peek()] || 'Assistant';
16
18
  }
17
19
 
20
+ function formatTurnError(raw) {
21
+ const t = String(raw || '').trim();
22
+ if (/remote compact task|error decoding response body/i.test(t)) {
23
+ return 'Codex 会话压缩失败(网络中断)。再发一条消息重试,或开新会话。';
24
+ }
25
+ return t || '发送失败';
26
+ }
27
+
18
28
  // ── Scroll helpers ────────────────────────────────────────────────────────────
19
- // True when the user is at (or near) the bottom of the message list.
29
+ let messageScroll = null;
30
+
20
31
  function atBottom(el) {
21
- return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
32
+ return isNearBottom(el);
22
33
  }
23
- // Scroll to bottom only if the user was already following along; otherwise leave
24
- // their scroll position untouched so reading history isn't interrupted.
34
+
35
+ function ensureMessageScroll(el) {
36
+ if (!el) return null;
37
+ if (messageScroll && messageScroll.el === el) return messageScroll;
38
+ messageScroll?.destroy?.();
39
+ messageScroll = bindScrollFollow(el);
40
+ messageScroll.el = el;
41
+ return messageScroll;
42
+ }
43
+
44
+ // Scroll to bottom only if the user is following live output.
25
45
  function stickBottom(el, force) {
26
- if (!el) return;
27
- if (force || atBottom(el)) el.scrollTop = el.scrollHeight;
46
+ if (!el || el.dataset.historyPainting === '1') return;
47
+ const follow = ensureMessageScroll(el);
48
+ follow.stick(!!force);
28
49
  }
29
50
 
30
51
  // ── WebSocket (auto-reconnect for mobile radio / background) ─────────────────
@@ -441,6 +462,33 @@ function handleWsMessage(msg) {
441
462
  // session while its subscription is changing. Do not let that frame alter
442
463
  // the new session's transcript, processing state, or queue.
443
464
  if (msg?.sessionId && ctx.sessionId && msg.sessionId !== ctx.sessionId) return;
465
+ // Fan out note/queue WS events to any module that subscribes via the
466
+ // hub-ws-message custom event (e.g. personal notes, queue outbox). Done
467
+ // before session-scope filtering so global widgets (notes page) still
468
+ // receive updates for non-active sessions. We also dispatch the
469
+ // type-specific hub:note-* events used by the legacy notes page.
470
+ if (msg && typeof msg.type === 'string' && (
471
+ msg.type === 'note.added' ||
472
+ msg.type === 'note.updated' ||
473
+ msg.type === 'note.removed' ||
474
+ msg.type === 'queue.item.added' ||
475
+ msg.type === 'queue.item.updated' ||
476
+ msg.type === 'queue.item.removed' ||
477
+ msg.type === 'queue.item.moved' ||
478
+ msg.type === 'queue.item.sending'
479
+ )) {
480
+ window.dispatchEvent(new CustomEvent('hub-ws-message', { detail: msg }));
481
+ const noteEvent = ({
482
+ 'note.added': 'hub:note-added',
483
+ 'note.updated': 'hub:note-updated',
484
+ 'note.removed': 'hub:note-removed',
485
+ })[msg.type];
486
+ if (noteEvent) window.dispatchEvent(new CustomEvent(noteEvent, { detail: msg }));
487
+ }
488
+ if (msg._hubReplay === true) {
489
+ document.dispatchEvent(new CustomEvent('external-session-updated', { detail: msg }));
490
+ return;
491
+ }
444
492
  // Server outbox may pack multiple events into one frame
445
493
  if (msg.type === 'batch' && Array.isArray(msg.items)) {
446
494
  for (const item of msg.items) handleWsMessage(item);
@@ -519,7 +567,7 @@ function handleWsMessage(msg) {
519
567
  ctx.lock = { ...(ctx.lock || {}), held: false, foreign: true, holder: msg.lock?.holder || ctx.lock?.holder || 'another user' };
520
568
  applyComposerAccess();
521
569
  }
522
- appendMsg('error', 'Error', msg.message);
570
+ appendMsg('error', 'Error', formatTurnError(msg.message));
523
571
  markTurnDone();
524
572
  return;
525
573
  }
@@ -551,10 +599,21 @@ function handleWsMessage(msg) {
551
599
 
552
600
  // ── Live streaming (Grok / future agents) ─────────────────────────────────
553
601
  if (msg.type === 'assistant_stream_start') {
602
+ // A replay can arrive before the edge's run-status snapshot. Mark the
603
+ // turn busy as soon as its first stream frame reaches us so the concurrent
604
+ // history read cannot repaint the transcript over live output.
605
+ if (!isProcessing.peek()) {
606
+ isProcessing.value = true;
607
+ syncWakeLock();
608
+ }
554
609
  ensureAssistantStream(msg.ts);
555
610
  return;
556
611
  }
557
612
  if (msg.type === 'assistant_delta') {
613
+ if (!isProcessing.peek()) {
614
+ isProcessing.value = true;
615
+ syncWakeLock();
616
+ }
558
617
  appendAssistantDelta(msg.data || '');
559
618
  return;
560
619
  }
@@ -627,7 +686,7 @@ function ensureAssistantStream(ts) {
627
686
  liveAsstEl = msgs.lastElementChild;
628
687
  liveAsstBody = liveAsstEl.querySelector('.msg-stream-body');
629
688
  $('stream-status')?.remove();
630
- stickBottom(msgs, true);
689
+ stickBottom(msgs);
631
690
  }
632
691
 
633
692
  function appendAssistantDelta(chunk) {
@@ -636,7 +695,7 @@ function appendAssistantDelta(chunk) {
636
695
  if (!liveAsstBody) return;
637
696
  // Keep raw text while streaming; re-render markdown on finalize
638
697
  liveAsstBody.textContent += chunk;
639
- stickBottom($('messages'), true);
698
+ stickBottom($('messages'));
640
699
  }
641
700
 
642
701
  function finalizeAssistantStream() {
@@ -674,7 +733,7 @@ function ensureThoughtStream() {
674
733
  </div>`);
675
734
  liveThoughtEl = msgs.lastElementChild;
676
735
  liveThoughtBody = liveThoughtEl.querySelector('.thought-body');
677
- stickBottom(msgs, true);
736
+ stickBottom(msgs);
678
737
  }
679
738
 
680
739
  function appendThoughtDelta(chunk) {
@@ -684,7 +743,7 @@ function appendThoughtDelta(chunk) {
684
743
  liveThoughtBody.textContent += chunk;
685
744
  // Keep thought panel scrolled to end
686
745
  liveThoughtBody.scrollTop = liveThoughtBody.scrollHeight;
687
- stickBottom($('messages'), true);
746
+ stickBottom($('messages'));
688
747
  }
689
748
 
690
749
  function finalizeThoughtStream() {
@@ -990,7 +1049,7 @@ export function sendMessage() {
990
1049
  if (!ok) appendSystemMsg('连接未就绪,命令已入队,重连后发送');
991
1050
  } else {
992
1051
  flushToolBatch();
993
- appendMsg('user', 'You', text, { ts: now });
1052
+ appendMsg('user', 'You', text, { ts: now, forceStick: true });
994
1053
  const agent = currentAgent.peek() || 'claude';
995
1054
  const effort = currentEffort.peek()
996
1055
  || getDefaultEffort(agent, currentModel.peek())
@@ -1170,7 +1229,7 @@ export function appendMsg(role, _label, text, opts = {}) {
1170
1229
  msgs.insertAdjacentHTML('beforeend', bubbleHtml(role, text, ts));
1171
1230
  enhanceMarkdown(msgs.lastElementChild);
1172
1231
  if (role === 'assistant') addAssistantMessageActions(msgs.lastElementChild, text, opts);
1173
- stickBottom(msgs, role === 'user');
1232
+ stickBottom(msgs, !!opts.forceStick);
1174
1233
  }
1175
1234
 
1176
1235
  export function appendSystemMsg(text) { appendMsg('system', '', text); }
@@ -1460,9 +1519,29 @@ export function appendContextBar(ctxInfo) {
1460
1519
  appendTokenBar(`▣ ${parts.join(' · ')}`);
1461
1520
  }
1462
1521
 
1522
+ export function beginHistoryRender() {
1523
+ const msgs = $('messages');
1524
+ if (!msgs) return;
1525
+ msgs.dataset.historyPainting = '1';
1526
+ msgs.innerHTML = '';
1527
+ toolBatch = null;
1528
+ assistantMessageRefs.clear();
1529
+ }
1530
+
1531
+ export function endHistoryRender(scrollTop = null) {
1532
+ const msgs = $('messages');
1533
+ if (!msgs) return;
1534
+ delete msgs.dataset.historyPainting;
1535
+ if (scrollTop == null) stickBottom(msgs, true);
1536
+ else {
1537
+ msgs.scrollTop = Math.max(0, scrollTop);
1538
+ ensureMessageScroll(msgs).setFollowing(isNearBottom(msgs));
1539
+ }
1540
+ }
1541
+
1463
1542
  export function clearMessages() {
1464
1543
  const msgs = $('messages'), perms = $('permission-requests');
1465
- if (msgs) msgs.innerHTML = '';
1544
+ if (msgs) { msgs.innerHTML = ''; delete msgs.dataset.historyPainting; }
1466
1545
  if (perms) perms.innerHTML = '';
1467
1546
  ctx.shellBubble = null;
1468
1547
  toolBatch = null;
@@ -1483,7 +1562,7 @@ function appendQueuedGrokPrompt(text, sessionId = ctx.sessionId) {
1483
1562
  const key = queuedGrokPromptKey(text, sessionId);
1484
1563
  if (!text || renderedGrokQueuePrompts.has(key)) return;
1485
1564
  renderedGrokQueuePrompts.add(key);
1486
- appendMsg('user', 'You', text, { ts: Date.now() });
1565
+ appendMsg('user', 'You', text, { ts: Date.now(), forceStick: true });
1487
1566
  $('messages')?.lastElementChild?.classList.add('queued-user-msg');
1488
1567
  }
1489
1568
 
@@ -1722,7 +1801,7 @@ function renderPermissionRequest(msg) {
1722
1801
  <button class="btn btn-error btn-xs" data-perm-id="${id}" data-perm-allow="0">Deny</button>
1723
1802
  </div>
1724
1803
  </div>`);
1725
- $('messages').scrollTop = 999999;
1804
+ stickBottom($('messages'));
1726
1805
  }
1727
1806
 
1728
1807
  /** Configure marked once: GFM + code chrome + table wrap. */
@@ -1792,7 +1871,7 @@ function renderMarkdown(text) {
1792
1871
  if (typeof marked === 'undefined') return esc(text);
1793
1872
  try {
1794
1873
  ensureMarked();
1795
- return marked.parse(normalizeMarkdownText(text), { breaks: true, gfm: true });
1874
+ return parseMarkdownWithMath(text, { fallback: () => esc(text) });
1796
1875
  } catch {
1797
1876
  return esc(text);
1798
1877
  }
@@ -1849,6 +1928,15 @@ export function initChat() {
1849
1928
 
1850
1929
  watch(currentProject, () => syncWakeLock());
1851
1930
 
1931
+ delegate.on('click', '#messages a, #chat-log a, .md-body a', (e, el) => {
1932
+ const href = el.getAttribute('href') || '';
1933
+ const cwd = currentProject.peek()?.path || '';
1934
+ if (openWorkspaceFile(href, cwd)) {
1935
+ e.preventDefault();
1936
+ e.stopPropagation();
1937
+ }
1938
+ });
1939
+
1852
1940
  delegate.on('click', '#ws-banner-retry', e => {
1853
1941
  e.preventDefault();
1854
1942
  refreshConnection();
@@ -1972,7 +2060,10 @@ export function initChat() {
1972
2060
  new ResizeObserver(updateNav).observe(msgsEl);
1973
2061
  new MutationObserver(updateNav).observe(msgsEl, { childList: true, subtree: true });
1974
2062
  topBtn.addEventListener('click', () => msgsEl.scrollTo({ top: 0, behavior: 'smooth' }));
1975
- botBtn.addEventListener('click', () => msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' }));
2063
+ botBtn.addEventListener('click', () => {
2064
+ ensureMessageScroll(msgsEl)?.setFollowing(true);
2065
+ msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' });
2066
+ });
1976
2067
  updateNav();
1977
2068
  }
1978
2069
 
package/public/files.js CHANGED
@@ -2,8 +2,10 @@
2
2
  import { effect, asyncEffect, delegate, esc, $ } from './lib.js';
3
3
  import { currentProject, currentTab, filesRoot, filesPath, viewingFile, ctx } from './state.js';
4
4
  import { api, authHeaders, authQuery } from './api.js';
5
+ import { resolveWorkspaceFile } from './workspace-path.js?v=ui-23';
5
6
  import { renderMermaid } from './mermaid.js';
6
7
  import { renderMath } from './math.js';
8
+ import { parseMarkdownWithMath } from './markdown.js';
7
9
 
8
10
  // ── Helpers ───────────────────────────────────────────────────────────────────
9
11
  function fileIcon(name) {
@@ -153,12 +155,9 @@ function renderTextFile(path, content, size) {
153
155
  }
154
156
 
155
157
  function markdownHtml(content) {
156
- if (typeof marked !== 'undefined') {
157
- try {
158
- return marked.parse(content, { breaks: true, gfm: true });
159
- } catch { /* Show the source below if parsing fails. */ }
160
- }
161
- return esc(content).replace(/\n/g, '<br>');
158
+ return parseMarkdownWithMath(content, {
159
+ fallback: (text) => esc(text).replace(/\n/g, '<br>'),
160
+ });
162
161
  }
163
162
 
164
163
  function renderMarkdownFile(path, content, size) {
@@ -357,7 +356,7 @@ function triggerUpload() {
357
356
  input.multiple = true;
358
357
  input.addEventListener('change', async () => {
359
358
  const dir = filesPath.peek();
360
- const id = proj?.id || '_';
359
+ const id = '_';
361
360
  const files = [...input.files];
362
361
  let failed = false;
363
362
  for (const file of files) {
@@ -388,7 +387,7 @@ async function downloadFile(proj, filePath) {
388
387
  alert('No path selected — set root first');
389
388
  return;
390
389
  }
391
- const id = proj?.id || '_';
390
+ const id = '_';
392
391
  const name = (filePath || '').split('/').pop() || 'download';
393
392
  // Prefer Authorization header (no token in URL / logs). authQuery kept as
394
393
  // fallback for environments that only support plain navigations.
@@ -433,7 +432,7 @@ async function downloadFile(proj, filePath) {
433
432
  async function deleteFile(proj, filePath) {
434
433
  if (!confirm(`Delete "${filePath.split('/').pop()}"?`)) return;
435
434
  const root = filesRoot.peek() || proj?.path || '';
436
- const id = proj?.id || '_';
435
+ const id = '_';
437
436
  try {
438
437
  await api('DELETE', `/api/projects/${id}/file?root=${encodeURIComponent(root)}&path=${encodeURIComponent(filePath)}`);
439
438
  filesPath.value = filesPath.peek() + ' ';
@@ -469,6 +468,23 @@ async function showFile(area, file, data, signal) {
469
468
  }
470
469
 
471
470
  // ── Init ──────────────────────────────────────────────────────────────────────
471
+ /** Open a machine-local path from a chat markdown link. */
472
+ export function openWorkspaceFile(href, cwd) {
473
+ const target = resolveWorkspaceFile(href, cwd || filesRoot.peek() || currentProject.peek()?.path || '');
474
+ if (!target) return false;
475
+ if (target.root) filesRoot.value = target.root;
476
+ if (target.isDir) {
477
+ viewingFile.value = null;
478
+ filesPath.value = target.relative;
479
+ } else {
480
+ const parts = target.relative.split('/').filter(Boolean);
481
+ filesPath.value = parts.slice(0, -1).join('/');
482
+ viewingFile.value = target.relative;
483
+ }
484
+ currentTab.value = 'files';
485
+ return true;
486
+ }
487
+
472
488
  export function initFilesTab() {
473
489
  effect(() => {
474
490
  const bc = $('files-breadcrumb');
@@ -494,7 +510,7 @@ export function initFilesTab() {
494
510
  area.innerHTML = '<div class="p-4 text-xs text-base-content/40">No path yet — open a session (uses that machine&apos;s cwd) or type an absolute path and press Go.</div>';
495
511
  return;
496
512
  }
497
- const id = proj?.id || '_';
513
+ const id = '_';
498
514
  area.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
499
515
  try {
500
516
  if (file !== null) {