claude-simple 0.2.28 → 0.2.31

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 (77) hide show
  1. package/README.md +17 -1
  2. package/bin/edge-ctl.js +151 -4
  3. package/client.js +8 -2
  4. package/dist/addon-fit.min.js +0 -0
  5. package/dist/app.0.2.31.min.js +1308 -0
  6. package/dist/index.html +2 -2
  7. package/dist/style.0.2.31.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 +47 -20
  14. package/public/api.js +1 -1
  15. package/public/app.js +5 -5
  16. package/public/base.css +0 -0
  17. package/public/cache.js +19 -0
  18. package/public/chat.js +80 -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/notifications.js +0 -0
  32. package/public/offline.html +0 -0
  33. package/public/pwa.js +1 -1
  34. package/public/router.js +0 -0
  35. package/public/scroll-follow.js +78 -0
  36. package/public/settings.js +41 -0
  37. package/public/shell/boot.js +0 -0
  38. package/public/shell/dashboard.js +0 -0
  39. package/public/shell/hub.js +0 -0
  40. package/public/shell/layout.js +0 -0
  41. package/public/shell/notes.js +0 -0
  42. package/public/shell/session-context.js +0 -0
  43. package/public/shell/session-list.js +49 -16
  44. package/public/shell/session-nav.js +66 -18
  45. package/public/shell/templates.js +0 -0
  46. package/public/shell/util.js +0 -0
  47. package/public/shell.js +0 -0
  48. package/public/state.js +7 -7
  49. package/public/style.css +5 -0
  50. package/public/sw.js +10 -5
  51. package/public/terminal.js +0 -0
  52. package/public/wake-lock.js +0 -0
  53. package/public/workspace-path.js +61 -0
  54. package/public/xterm.min.css +0 -0
  55. package/public/xterm.min.js +0 -0
  56. package/src/agent-quota.js +228 -0
  57. package/src/agents/claude.js +0 -0
  58. package/src/agents/codex.js +2 -0
  59. package/src/agents/cursor.js +2 -1
  60. package/src/agents/gemini.js +2 -1
  61. package/src/agents/grok.js +6 -1
  62. package/src/agents/meta-agent.js +7 -4
  63. package/src/agents/models.js +21 -12
  64. package/src/agents/sessions.js +133 -69
  65. package/src/agents/stream-batch.js +0 -0
  66. package/src/app.js +80 -36
  67. package/src/auto-update.js +2 -0
  68. package/src/collab.js +3 -0
  69. package/src/platform.js +133 -0
  70. package/src/project-api-path.js +14 -0
  71. package/src/runtime/approvals.js +0 -0
  72. package/src/runtime/chat-broadcast.js +12 -0
  73. package/src/runtime/outbox.js +0 -0
  74. package/src/runtime/port-websocket.js +80 -0
  75. package/src/runtime/protocol.js +0 -0
  76. package/dist/app.0.2.28.min.js +0 -1220
  77. package/dist/style.0.2.28.min.css +0 -2007
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.28",
3
+ "version": "0.2.31",
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,6 +41,7 @@ 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
 
@@ -78,6 +77,10 @@ async function streamSSE(path, body, handlers) {
78
77
  let data;
79
78
  try { data = JSON.parse(raw); } catch { continue; }
80
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
+ }
81
84
  handlers.any?.(event, data);
82
85
  event = 'message';
83
86
  } else if (line.trim() === '') {
@@ -376,16 +379,19 @@ function appendMsg(kind, text, opts = {}) {
376
379
  } else if (kind === 'assistant') {
377
380
  el.innerHTML = `<div class="ma-bubble assistant md">${mdHtml(text)}</div>`;
378
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;
379
385
  } else if (kind === 'error') {
380
386
  el.innerHTML = `<div class="ma-bubble error">${esc(text)}</div>`;
381
387
  } else if (kind === 'system') {
382
388
  el.innerHTML = `<div class="ma-sys">${esc(text)}</div>`;
383
389
  } else if (kind === 'tool') {
384
- el.innerHTML = `<div class="ma-tool" data-tool-id="${esc(opts.id || '')}">
385
- <span class="ma-tool-name">${esc(opts.name || 'tool')}</span>
386
- <span class="ma-tool-phase">${esc(opts.phase || '')}</span>
387
- <pre class="ma-tool-args">${esc(JSON.stringify(opts.args || {}, null, 0).slice(0, 200))}</pre>
388
- </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>`;
389
395
  }
390
396
  box.appendChild(el);
391
397
  box.scrollTop = box.scrollHeight;
@@ -409,14 +415,15 @@ function updateToolChip(id, phase, result) {
409
415
  el.classList.toggle('error', phase === 'error');
410
416
  const ph = el.querySelector('.ma-tool-phase');
411
417
  if (ph) ph.textContent = phase;
412
- if (result && phase !== 'start') {
418
+ if (result !== undefined && phase !== 'start') {
413
419
  let pre = el.querySelector('.ma-tool-result');
414
420
  if (!pre) {
415
421
  pre = document.createElement('pre');
416
422
  pre.className = 'ma-tool-result';
423
+ pre.style.whiteSpace = 'pre-wrap';
417
424
  el.appendChild(pre);
418
425
  }
419
- pre.textContent = JSON.stringify(result, null, 0).slice(0, 500);
426
+ pre.textContent = JSON.stringify(result, null, 2);
420
427
  }
421
428
  }
422
429
 
@@ -806,6 +813,10 @@ function renderHistoryMessages(messages) {
806
813
  continue;
807
814
  }
808
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
+ }
809
820
  if (m.tool_calls?.length) {
810
821
  for (const tc of m.tool_calls) {
811
822
  const name = tc.function?.name || tc.name || 'tool';
@@ -821,9 +832,6 @@ function renderHistoryMessages(messages) {
821
832
  });
822
833
  }
823
834
  }
824
- if (m.content) {
825
- appendMsg('assistant', typeof m.content === 'string' ? m.content : userContentToText(m.content));
826
- }
827
835
  continue;
828
836
  }
829
837
  if (m.role === 'tool') {
@@ -833,7 +841,7 @@ function renderHistoryMessages(messages) {
833
841
  } catch { /* keep string */ }
834
842
  // attach to last matching tool chip if possible
835
843
  const id = m.tool_call_id || '';
836
- if (id) updateToolChip(id, 'done', result);
844
+ if (id) updateToolChip(id, typeof result === 'string' && result.startsWith('error:') ? 'error' : 'done', result);
837
845
  else {
838
846
  appendMsg('tool', '', {
839
847
  name: 'tool_result',
@@ -850,11 +858,12 @@ function renderHistoryMessages(messages) {
850
858
  if (!pre) {
851
859
  pre = document.createElement('pre');
852
860
  pre.className = 'ma-tool-result';
861
+ pre.style.whiteSpace = 'pre-wrap';
853
862
  last.appendChild(pre);
854
863
  }
855
864
  pre.textContent = typeof result === 'string'
856
865
  ? result.slice(0, 500)
857
- : JSON.stringify(result, null, 0).slice(0, 500);
866
+ : JSON.stringify(result, null, 2);
858
867
  }
859
868
  }
860
869
  }
@@ -942,6 +951,7 @@ async function sendChat() {
942
951
 
943
952
  let asstEl = null;
944
953
  let content = '';
954
+ let reasoningEl = null;
945
955
 
946
956
  try {
947
957
  await streamSSE('/api/ai/chat', {
@@ -959,6 +969,19 @@ async function sendChat() {
959
969
  localStorage.setItem(LAST_SESSION_KEY, d.id);
960
970
  updateSessionChrome();
961
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),
962
985
  content: (d) => {
963
986
  content = d.content || '';
964
987
  if (!asstEl) asstEl = appendMsg('assistant', '');
@@ -985,7 +1008,11 @@ async function sendChat() {
985
1008
  messageCount: (currentSessionMeta?.messageCount || 0) + 2,
986
1009
  };
987
1010
  }
988
- 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;
989
1016
  updateSessionChrome();
990
1017
  refreshHistoryCache();
991
1018
  },
package/public/api.js CHANGED
@@ -63,7 +63,7 @@ export async function api(method, path, body, signal, opts = {}) {
63
63
  method,
64
64
  signal,
65
65
  headers,
66
- ...(opts.cache ? { cache: opts.cache } : {}),
66
+ cache: opts.cache || (method.toUpperCase() === 'GET' ? 'no-store' : 'default'),
67
67
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
68
68
  });
69
69
  if (res.status === 304) return NOT_MODIFIED;
package/public/app.js CHANGED
@@ -1,17 +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
9
  import { initNotes } from './notes.js';
10
10
  import { initTerminal } from './terminal.js';
11
- import { initImagePaste } from './chat.js';
11
+ import { initImagePaste } from './chat.js?v=ui-24';
12
12
  import { initMetaAgent } from './agent-panel.js';
13
13
  import { initRouter, applyInitialRoute, parseHashPath } from './router.js';
14
- import { initPwa } from './pwa.js';
14
+ import { initPwa } from './pwa.js?v=ui-24';
15
15
  import { initMobileChrome } from './mobile.js';
16
16
 
17
17
  function markBootReady() {
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) ─────────────────
@@ -436,6 +457,9 @@ function markTurnDone() {
436
457
  flushPendingUserText();
437
458
  }
438
459
 
460
+ let chatOutputRevision = 0;
461
+ export function getChatOutputRevision() { return chatOutputRevision; }
462
+
439
463
  function handleWsMessage(msg) {
440
464
  // The socket can still receive a final frame from the previously viewed
441
465
  // session while its subscription is changing. Do not let that frame alter
@@ -464,11 +488,16 @@ function handleWsMessage(msg) {
464
488
  })[msg.type];
465
489
  if (noteEvent) window.dispatchEvent(new CustomEvent(noteEvent, { detail: msg }));
466
490
  }
491
+ if (msg._hubReplay === true) {
492
+ document.dispatchEvent(new CustomEvent('external-session-updated', { detail: msg }));
493
+ return;
494
+ }
467
495
  // Server outbox may pack multiple events into one frame
468
496
  if (msg.type === 'batch' && Array.isArray(msg.items)) {
469
497
  for (const item of msg.items) handleWsMessage(item);
470
498
  return;
471
499
  }
500
+ if (['assistant', 'assistant_stream_start', 'assistant_delta', 'thought_delta', 'tool_use'].includes(msg.type)) chatOutputRevision++;
472
501
  if (msg.type === 'session-created') {
473
502
  const selectedModel = currentModel.peek();
474
503
  ctx.sessionId = msg.sessionId;
@@ -542,7 +571,7 @@ function handleWsMessage(msg) {
542
571
  ctx.lock = { ...(ctx.lock || {}), held: false, foreign: true, holder: msg.lock?.holder || ctx.lock?.holder || 'another user' };
543
572
  applyComposerAccess();
544
573
  }
545
- appendMsg('error', 'Error', msg.message);
574
+ appendMsg('error', 'Error', formatTurnError(msg.message));
546
575
  markTurnDone();
547
576
  return;
548
577
  }
@@ -661,7 +690,7 @@ function ensureAssistantStream(ts) {
661
690
  liveAsstEl = msgs.lastElementChild;
662
691
  liveAsstBody = liveAsstEl.querySelector('.msg-stream-body');
663
692
  $('stream-status')?.remove();
664
- stickBottom(msgs, true);
693
+ stickBottom(msgs);
665
694
  }
666
695
 
667
696
  function appendAssistantDelta(chunk) {
@@ -670,7 +699,7 @@ function appendAssistantDelta(chunk) {
670
699
  if (!liveAsstBody) return;
671
700
  // Keep raw text while streaming; re-render markdown on finalize
672
701
  liveAsstBody.textContent += chunk;
673
- stickBottom($('messages'), true);
702
+ stickBottom($('messages'));
674
703
  }
675
704
 
676
705
  function finalizeAssistantStream() {
@@ -708,7 +737,7 @@ function ensureThoughtStream() {
708
737
  </div>`);
709
738
  liveThoughtEl = msgs.lastElementChild;
710
739
  liveThoughtBody = liveThoughtEl.querySelector('.thought-body');
711
- stickBottom(msgs, true);
740
+ stickBottom(msgs);
712
741
  }
713
742
 
714
743
  function appendThoughtDelta(chunk) {
@@ -718,7 +747,7 @@ function appendThoughtDelta(chunk) {
718
747
  liveThoughtBody.textContent += chunk;
719
748
  // Keep thought panel scrolled to end
720
749
  liveThoughtBody.scrollTop = liveThoughtBody.scrollHeight;
721
- stickBottom($('messages'), true);
750
+ stickBottom($('messages'));
722
751
  }
723
752
 
724
753
  function finalizeThoughtStream() {
@@ -1024,7 +1053,7 @@ export function sendMessage() {
1024
1053
  if (!ok) appendSystemMsg('连接未就绪,命令已入队,重连后发送');
1025
1054
  } else {
1026
1055
  flushToolBatch();
1027
- appendMsg('user', 'You', text, { ts: now });
1056
+ appendMsg('user', 'You', text, { ts: now, forceStick: true });
1028
1057
  const agent = currentAgent.peek() || 'claude';
1029
1058
  const effort = currentEffort.peek()
1030
1059
  || getDefaultEffort(agent, currentModel.peek())
@@ -1204,7 +1233,7 @@ export function appendMsg(role, _label, text, opts = {}) {
1204
1233
  msgs.insertAdjacentHTML('beforeend', bubbleHtml(role, text, ts));
1205
1234
  enhanceMarkdown(msgs.lastElementChild);
1206
1235
  if (role === 'assistant') addAssistantMessageActions(msgs.lastElementChild, text, opts);
1207
- stickBottom(msgs, role === 'user');
1236
+ stickBottom(msgs, !!opts.forceStick);
1208
1237
  }
1209
1238
 
1210
1239
  export function appendSystemMsg(text) { appendMsg('system', '', text); }
@@ -1494,9 +1523,29 @@ export function appendContextBar(ctxInfo) {
1494
1523
  appendTokenBar(`▣ ${parts.join(' · ')}`);
1495
1524
  }
1496
1525
 
1526
+ export function beginHistoryRender() {
1527
+ const msgs = $('messages');
1528
+ if (!msgs) return;
1529
+ msgs.dataset.historyPainting = '1';
1530
+ msgs.innerHTML = '';
1531
+ toolBatch = null;
1532
+ assistantMessageRefs.clear();
1533
+ }
1534
+
1535
+ export function endHistoryRender(scrollTop = null) {
1536
+ const msgs = $('messages');
1537
+ if (!msgs) return;
1538
+ delete msgs.dataset.historyPainting;
1539
+ if (scrollTop == null) stickBottom(msgs, true);
1540
+ else {
1541
+ msgs.scrollTop = Math.max(0, scrollTop);
1542
+ ensureMessageScroll(msgs).setFollowing(isNearBottom(msgs));
1543
+ }
1544
+ }
1545
+
1497
1546
  export function clearMessages() {
1498
1547
  const msgs = $('messages'), perms = $('permission-requests');
1499
- if (msgs) msgs.innerHTML = '';
1548
+ if (msgs) { msgs.innerHTML = ''; delete msgs.dataset.historyPainting; }
1500
1549
  if (perms) perms.innerHTML = '';
1501
1550
  ctx.shellBubble = null;
1502
1551
  toolBatch = null;
@@ -1517,7 +1566,7 @@ function appendQueuedGrokPrompt(text, sessionId = ctx.sessionId) {
1517
1566
  const key = queuedGrokPromptKey(text, sessionId);
1518
1567
  if (!text || renderedGrokQueuePrompts.has(key)) return;
1519
1568
  renderedGrokQueuePrompts.add(key);
1520
- appendMsg('user', 'You', text, { ts: Date.now() });
1569
+ appendMsg('user', 'You', text, { ts: Date.now(), forceStick: true });
1521
1570
  $('messages')?.lastElementChild?.classList.add('queued-user-msg');
1522
1571
  }
1523
1572
 
@@ -1756,7 +1805,7 @@ function renderPermissionRequest(msg) {
1756
1805
  <button class="btn btn-error btn-xs" data-perm-id="${id}" data-perm-allow="0">Deny</button>
1757
1806
  </div>
1758
1807
  </div>`);
1759
- $('messages').scrollTop = 999999;
1808
+ stickBottom($('messages'));
1760
1809
  }
1761
1810
 
1762
1811
  /** Configure marked once: GFM + code chrome + table wrap. */
@@ -1826,7 +1875,7 @@ function renderMarkdown(text) {
1826
1875
  if (typeof marked === 'undefined') return esc(text);
1827
1876
  try {
1828
1877
  ensureMarked();
1829
- return marked.parse(normalizeMarkdownText(text), { breaks: true, gfm: true });
1878
+ return parseMarkdownWithMath(text, { fallback: () => esc(text) });
1830
1879
  } catch {
1831
1880
  return esc(text);
1832
1881
  }
@@ -1883,6 +1932,15 @@ export function initChat() {
1883
1932
 
1884
1933
  watch(currentProject, () => syncWakeLock());
1885
1934
 
1935
+ delegate.on('click', '#messages a, #chat-log a, .md-body a', (e, el) => {
1936
+ const href = el.getAttribute('href') || '';
1937
+ const cwd = currentProject.peek()?.path || '';
1938
+ if (openWorkspaceFile(href, cwd)) {
1939
+ e.preventDefault();
1940
+ e.stopPropagation();
1941
+ }
1942
+ });
1943
+
1886
1944
  delegate.on('click', '#ws-banner-retry', e => {
1887
1945
  e.preventDefault();
1888
1946
  refreshConnection();
@@ -2006,7 +2064,10 @@ export function initChat() {
2006
2064
  new ResizeObserver(updateNav).observe(msgsEl);
2007
2065
  new MutationObserver(updateNav).observe(msgsEl, { childList: true, subtree: true });
2008
2066
  topBtn.addEventListener('click', () => msgsEl.scrollTo({ top: 0, behavior: 'smooth' }));
2009
- botBtn.addEventListener('click', () => msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' }));
2067
+ botBtn.addEventListener('click', () => {
2068
+ ensureMessageScroll(msgsEl)?.setFollowing(true);
2069
+ msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: 'smooth' });
2070
+ });
2010
2071
  updateNav();
2011
2072
  }
2012
2073
 
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) {
package/public/git.js CHANGED
@@ -74,7 +74,7 @@ export function initGitTab() {
74
74
  area.innerHTML = '<div class="p-4 text-sm text-base-content/50">Enter a project root above, or open a session with a working directory.</div>';
75
75
  return;
76
76
  }
77
- const id = proj?.id || '_';
77
+ const id = '_';
78
78
  area.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
79
79
  try {
80
80
  const data = await api('GET', `/api/projects/${id}/git/status?root=${encodeURIComponent(root)}`, undefined, signal);
@@ -126,7 +126,7 @@ function loadGraph() {
126
126
  const root = gitRoot.peek() || proj?.path;
127
127
  const content = $('git-graph-content');
128
128
  if (!root || !content) return;
129
- const id = proj?.id || '_';
129
+ const id = '_';
130
130
  content.innerHTML = '<span class="text-base-content/40">Loading…</span>';
131
131
  api('GET', `/api/projects/${id}/git/log?root=${encodeURIComponent(root)}`)
132
132
  .then(d => { content.innerHTML = d.graph ? renderGraph(d.graph) : '<span class="text-base-content/40">No commits</span>'; })
File without changes
File without changes
File without changes
File without changes
package/public/index.html CHANGED
@@ -85,7 +85,7 @@
85
85
  <script src="/vendor/mermaid.min.js"></script>
86
86
  <link rel="stylesheet" href="/vendor/katex.min.css">
87
87
  <script src="/vendor/katex.min.js"></script>
88
- <link rel="stylesheet" href="style.css?v=ui-22">
88
+ <link rel="stylesheet" href="style.css?v=ui-25">
89
89
  </head>
90
90
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
91
91
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
@@ -101,6 +101,6 @@
101
101
  }
102
102
  }, 10000);
103
103
  </script>
104
- <script type="module" src="app.js?v=ui-22"></script>
104
+ <script type="module" src="app.js?v=ui-25"></script>
105
105
  </body>
106
106
  </html>
package/public/lib.js CHANGED
File without changes
File without changes
@@ -3,6 +3,8 @@
3
3
  // placeholder. It is not HTML, but a Markdown parser will otherwise pass it
4
4
  // through as an element and a malformed placeholder can corrupt the rest of a
5
5
  // message's DOM.
6
+ import { protectMath, restoreMath } from './math.js';
7
+
6
8
  const TRUNCATED_TRANSCRIPT_MARKER = /(?:<|\[)\s*(?:…\s*)?truncated\s+([\d,]+)\s+bytes\s*(?:>|\])/gi;
7
9
 
8
10
  export function normalizeMarkdownText(value) {
@@ -10,3 +12,17 @@ export function normalizeMarkdownText(value) {
10
12
  return `\n\n> ⚠️ 原始记录已截断(${bytes} bytes)\n\n`;
11
13
  });
12
14
  }
15
+
16
+ export function parseMarkdownWithMath(value, { fallback } = {}) {
17
+ const { text, slots } = protectMath(normalizeMarkdownText(value));
18
+ if (typeof marked === 'undefined') {
19
+ const raw = fallback ? fallback(text) : text;
20
+ return restoreMath(raw, slots);
21
+ }
22
+ try {
23
+ return restoreMath(marked.parse(text, { breaks: true, gfm: true }), slots);
24
+ } catch {
25
+ const raw = fallback ? fallback(text) : text;
26
+ return restoreMath(raw, slots);
27
+ }
28
+ }