claude-simple 0.2.28 → 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 (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.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 +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 +76 -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 +64 -17
  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
package/public/math.js CHANGED
@@ -1,5 +1,137 @@
1
- // KaTeX enhancement for Markdown output. The parser deliberately runs after
2
- // Marked so fenced code and Mermaid source stay literal.
1
+ // KaTeX enhancement for Markdown output. Math is extracted before Marked so
2
+ // `\[...\]` is not treated as an escaped bracket and `_` inside TeX is not
3
+ // turned into emphasis. A leftover pass still runs on the DOM for any tokens
4
+ // that survive as text.
5
+
6
+ const LATEXISH = /\\[a-zA-Z]+|[_^]\{|\\[,;!]|\\[{}()[\]]|\\leq\b|\\geq\b|\\neq\b|\\in\b|\\sum\b|\\int\b|\\frac\b|\\min\b|\\max\b|\\mathrm\b|\\text\b|\\left\b|\\right\b/;
7
+
8
+ export function looksLikeLatex(s) {
9
+ return LATEXISH.test(String(s || ''));
10
+ }
11
+
12
+ function htmlEscape(s) {
13
+ return String(s)
14
+ .replace(/&/g, '&')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;');
17
+ }
18
+
19
+ function atLineStart(text, i) {
20
+ return i === 0 || text[i - 1] === '\n';
21
+ }
22
+
23
+ function matchFence(text, i) {
24
+ if (atLineStart(text, i) && (text.startsWith('```', i) || text.startsWith('~~~', i))) {
25
+ const fence = text.startsWith('```', i) ? '```' : '~~~';
26
+ const close = text.indexOf('\n' + fence, i + 3);
27
+ if (close === -1) return { end: text.length };
28
+ const after = close + 1 + fence.length;
29
+ return { end: after };
30
+ }
31
+ if (text[i] === '`') {
32
+ let n = 1;
33
+ while (text[i + n] === '`') n++;
34
+ let j = i + n;
35
+ while (j < text.length) {
36
+ if (text[j] === '`') {
37
+ let k = 0;
38
+ while (text[j + k] === '`') k++;
39
+ if (k === n) return { end: j + k };
40
+ j += k;
41
+ continue;
42
+ }
43
+ j++;
44
+ }
45
+ return { end: i + n };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function matchMath(text, i) {
51
+ const rest = text.slice(i);
52
+
53
+ if (rest.startsWith('$$')) {
54
+ const end = rest.indexOf('$$', 2);
55
+ if (end >= 2) {
56
+ const body = rest.slice(2, end);
57
+ if (body.trim()) return { body, display: true, end: i + end + 2 };
58
+ }
59
+ }
60
+
61
+ if (rest.startsWith('\\[')) {
62
+ const end = rest.indexOf('\\]', 2);
63
+ if (end >= 2) {
64
+ const body = rest.slice(2, end);
65
+ if (body.trim()) return { body, display: true, end: i + end + 2 };
66
+ }
67
+ }
68
+
69
+ if (rest.startsWith('\\(')) {
70
+ const end = rest.indexOf('\\)', 2);
71
+ if (end >= 2) {
72
+ const body = rest.slice(2, end);
73
+ if (body.trim()) return { body, display: false, end: i + end + 2 };
74
+ }
75
+ }
76
+
77
+ if (rest[0] === '$' && rest[1] !== '$') {
78
+ const m = rest.match(/^\$([^$\n]|\\\$)+?(?<!\s)\$/);
79
+ if (m && m[0][1] !== ' ') {
80
+ const body = m[0].slice(1, -1);
81
+ if (body.trim()) return { body, display: false, end: i + m[0].length };
82
+ }
83
+ }
84
+
85
+ if (atLineStart(text, i)) {
86
+ const block = rest.match(/^[ \t]*\[[ \t]*\n([\s\S]*?)\n[ \t]*\][ \t]*(?=\n|$)/);
87
+ if (block && looksLikeLatex(block[1])) {
88
+ return { body: block[1], display: true, end: i + block[0].length };
89
+ }
90
+ const line = rest.match(/^[ \t]*\[([^\]\n]+)\](?!\()/);
91
+ if (line && looksLikeLatex(line[1])) {
92
+ return { body: line[1], display: true, end: i + line[0].length };
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ export function protectMath(src) {
100
+ const slots = [];
101
+ const text = String(src ?? '');
102
+ let out = '';
103
+ let i = 0;
104
+ while (i < text.length) {
105
+ const fence = matchFence(text, i);
106
+ if (fence) {
107
+ out += text.slice(i, fence.end);
108
+ i = fence.end;
109
+ continue;
110
+ }
111
+ const math = matchMath(text, i);
112
+ if (math) {
113
+ const id = slots.length;
114
+ slots.push({ body: math.body, display: math.display });
115
+ out += `@@MATH${id}@@`;
116
+ i = math.end;
117
+ continue;
118
+ }
119
+ out += text[i];
120
+ i++;
121
+ }
122
+ return { text: out, slots };
123
+ }
124
+
125
+ export function restoreMath(html, slots) {
126
+ if (!slots?.length) return html;
127
+ return String(html).replace(/@@MATH(\d+)@@/g, (full, id) => {
128
+ const slot = slots[Number(id)];
129
+ if (!slot) return full;
130
+ const open = slot.display ? '\\[' : '\\(';
131
+ const close = slot.display ? '\\]' : '\\)';
132
+ return htmlEscape(`${open}${slot.body}${close}`);
133
+ });
134
+ }
3
135
 
4
136
  function ignored(node) {
5
137
  return node.parentElement?.closest('pre, code, .mermaid-diagram, .katex, script, style');
@@ -10,9 +142,9 @@ function nextToken(text, from) {
10
142
  { re: /\\\[([\s\S]+?)\\\]/g, display: true },
11
143
  { re: /\$\$([\s\S]+?)\$\$/g, display: true },
12
144
  { re: /\\\(([\s\S]+?)\\\)/g, display: false },
13
- // A closing dollar must not be escaped or preceded by whitespace. This
14
- // leaves ordinary currency such as "$5 and $6" untouched.
15
145
  { re: /(?<!\\)\$([^$\n]|\\\$)+?(?<!\s)(?<!\\)\$/g, display: false },
146
+ { re: /(?:^|\n)[ \t]*\[[ \t]*\n([\s\S]*?)\n[ \t]*\][ \t]*(?=\n|$)/g, display: true, latex: true },
147
+ { re: /(?:^|\n)[ \t]*\[([^\]\n]+\\[a-zA-Z{}_^][^\]\n]*)\](?!\()/g, display: true, latex: true },
16
148
  ];
17
149
  let best = null;
18
150
  for (const pattern of patterns) {
@@ -20,10 +152,16 @@ function nextToken(text, from) {
20
152
  const match = pattern.re.exec(text);
21
153
  if (!match || (best && match.index >= best.match.index)) continue;
22
154
  const raw = match[0];
23
- const body = raw.startsWith('\\[') || raw.startsWith('\\(')
24
- ? raw.slice(2, -2)
25
- : raw.startsWith('$$') ? raw.slice(2, -2) : raw.slice(1, -1);
155
+ let body;
156
+ if (match[1] != null && (pattern.latex || raw.startsWith('\\[') || raw.startsWith('\\(') || raw.startsWith('$$') || raw.startsWith('$'))) {
157
+ body = match[1];
158
+ } else {
159
+ body = raw.startsWith('\\[') || raw.startsWith('\\(')
160
+ ? raw.slice(2, -2)
161
+ : raw.startsWith('$$') ? raw.slice(2, -2) : raw.slice(1, -1);
162
+ }
26
163
  if (!body.trim()) continue;
164
+ if (pattern.latex && !looksLikeLatex(body)) continue;
27
165
  best = { match, body, display: pattern.display };
28
166
  }
29
167
  return best;
package/public/mobile.js CHANGED
File without changes
File without changes
File without changes
package/public/pwa.js CHANGED
@@ -69,7 +69,7 @@ export function initPwa() {
69
69
 
70
70
  // Register after first paint so install doesn't compete with boot.
71
71
  const register = () => {
72
- navigator.serviceWorker.register('/sw.js', { scope: '/' })
72
+ navigator.serviceWorker.register('/sw.js?v=33', { scope: '/' })
73
73
  .then((reg) => {
74
74
  reg.addEventListener('updatefound', () => {
75
75
  const sw = reg.installing;
package/public/router.js CHANGED
File without changes
@@ -0,0 +1,78 @@
1
+ /** Pure helpers for chat scroll-follow. Keep reading history from being yanked. */
2
+
3
+ export const NEAR_BOTTOM_PX = 80;
4
+
5
+ export function isNearBottom(el, threshold = NEAR_BOTTOM_PX) {
6
+ if (!el) return true;
7
+ return el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
8
+ }
9
+
10
+ /** Wheel toward older messages (negative deltaY) unpins immediately. */
11
+ export function followAfterWheel(deltaY, following) {
12
+ if (deltaY < 0) return false;
13
+ return following;
14
+ }
15
+
16
+ export function followAfterScroll(nearBottom) {
17
+ return nearBottom;
18
+ }
19
+
20
+ export function shouldStick({ force = false, following = true, userScrolling = false } = {}) {
21
+ if (force) return true;
22
+ if (userScrolling) return false;
23
+ return following;
24
+ }
25
+
26
+ /**
27
+ * Bind a messages container. Returns { stick, setFollowing, isFollowing, destroy }.
28
+ */
29
+ export function bindScrollFollow(el) {
30
+ let following = true;
31
+ let userScrolling = false;
32
+ let touchStartY = 0;
33
+
34
+ const onScroll = () => {
35
+ following = isNearBottom(el);
36
+ userScrolling = false;
37
+ };
38
+ const onWheel = (e) => {
39
+ following = followAfterWheel(e.deltaY, following);
40
+ if (e.deltaY < 0) userScrolling = true;
41
+ };
42
+ const onTouchStart = (e) => {
43
+ touchStartY = e.touches?.[0]?.clientY || 0;
44
+ };
45
+ const onTouchMove = (e) => {
46
+ const y = e.touches?.[0]?.clientY || 0;
47
+ if (y - touchStartY > 8) {
48
+ following = false;
49
+ userScrolling = true;
50
+ }
51
+ };
52
+
53
+ el.addEventListener('scroll', onScroll, { passive: true });
54
+ el.addEventListener('wheel', onWheel, { passive: true });
55
+ el.addEventListener('touchstart', onTouchStart, { passive: true });
56
+ el.addEventListener('touchmove', onTouchMove, { passive: true });
57
+
58
+ return {
59
+ stick(force = false) {
60
+ if (shouldStick({ force, following, userScrolling })) {
61
+ el.scrollTop = el.scrollHeight;
62
+ following = true;
63
+ }
64
+ },
65
+ setFollowing(v) {
66
+ following = !!v;
67
+ },
68
+ isFollowing() {
69
+ return following;
70
+ },
71
+ destroy() {
72
+ el.removeEventListener('scroll', onScroll);
73
+ el.removeEventListener('wheel', onWheel);
74
+ el.removeEventListener('touchstart', onTouchStart);
75
+ el.removeEventListener('touchmove', onTouchMove);
76
+ },
77
+ };
78
+ }
@@ -33,11 +33,19 @@ function settingsMarkup() {
33
33
  <nav class="set-nav" aria-label="设置分区">
34
34
  <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start is-on" data-set-sec="claude">Claude</button>
35
35
  <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="workspaces">Workspaces</button>
36
+ <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="quota">额度</button>
36
37
  <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="users" id="set-nav-users">Users</button>
37
38
  <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="password">Password</button>
38
39
  <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="mcp">MCP</button>
39
40
  </nav>
40
41
  <div class="set-main">
42
+ <section class="set-panel" data-set-panel="quota" hidden>
43
+ <h2 class="text-lg font-semibold">订阅额度</h2>
44
+ <p class="text-sm text-base-content/60 mb-4">读取当前机器的 CLI 登录账号。额度每 5 分钟更新,API Key 和第三方网关可能无法提供订阅额度。</p>
45
+ <button type="button" id="quota-refresh" class="btn btn-sm btn-ghost">刷新</button>
46
+ <select id="quota-profile" class="select select-bordered select-sm" aria-label="Claude 配置账号"><option value="">Claude 默认账号</option></select>
47
+ <div id="quota-list" class="set-list mt-3"></div>
48
+ </section>
41
49
  <section class="set-panel" data-set-panel="claude">
42
50
  <h2 class="text-lg font-semibold">Claude env</h2>
43
51
  <p class="text-sm text-base-content/60 mb-4">Workspace 的 <b>name</b> 对应一套 ANTHROPIC_*。Default 是底,同名覆盖。</p>
@@ -119,6 +127,8 @@ function settingsMarkup() {
119
127
  }
120
128
 
121
129
  function bind() {
130
+ $('quota-refresh')?.addEventListener('click', loadQuotas);
131
+ $('quota-profile')?.addEventListener('change', loadQuotas);
122
132
  $('set-back')?.addEventListener('click', closeSettings);
123
133
  document.querySelectorAll('[data-set-sec]').forEach(btn => {
124
134
  btn.addEventListener('click', () => showSection(btn.dataset.setSec));
@@ -144,6 +154,37 @@ function showSection(id) {
144
154
  document.querySelectorAll('[data-set-panel]').forEach(p => {
145
155
  p.hidden = p.dataset.setPanel !== activeSection;
146
156
  });
157
+ if (activeSection === 'quota') void loadQuotas();
158
+ }
159
+
160
+ let quotaEpoch = 0;
161
+ async function loadQuotas() {
162
+ const epoch = ++quotaEpoch;
163
+ const list = $('quota-list');
164
+ if (!list) return;
165
+ list.textContent = '正在读取当前机器的额度…';
166
+ const machine = selectedMachineId.peek();
167
+ const profile = $('quota-profile');
168
+ const selected = profile?.value || '';
169
+ if (profile) {
170
+ profile.innerHTML = '<option value="">Claude 默认账号</option>' + (workspacesData.peek() || []).map(w => `<option value="${esc(w.configDir)}">${esc(w.name || w.configDir)}</option>`).join('');
171
+ profile.value = selected;
172
+ }
173
+ if (hubMode.peek() && !machine) { list.textContent = '请先选择机器'; return; }
174
+ const rows = await Promise.all(['claude', 'grok', 'codex'].map(async agent => {
175
+ const query = new URLSearchParams({ agent });
176
+ if (agent === 'claude' && profile?.value) query.set('configDir', profile.value);
177
+ try { return await api('GET', `/api/agents/quota?${query}`, undefined, undefined, { machineId: machine || undefined }); }
178
+ catch (e) { return { agent, meters: [], message: e.message }; }
179
+ }));
180
+ if (epoch !== quotaEpoch || machine !== selectedMachineId.peek()) return;
181
+ list.innerHTML = rows.map(row => `<div class="border border-base-300 rounded-lg p-4 mb-3">
182
+ <h3 class="font-semibold">${esc(row.agent)} ${esc(row.plan || '')}</h3>
183
+ ${(row.meters || []).map(m => `<div class="mt-3"><div class="text-sm">${esc(m.label)} · 已用 ${esc(m.usedPercent)}% · 剩余 ${esc(m.remainingPercent)}%</div>
184
+ <progress class="progress progress-primary w-full" max="100" value="${Number(m.usedPercent)}"></progress>
185
+ ${m.resetsAt ? `<div class="text-xs opacity-60">重置:${esc(new Date(m.resetsAt).toLocaleString())}</div>` : ''}</div>`).join('') || `<p class="text-sm opacity-60 mt-2">${esc(row.message || '暂时无法读取额度')}</p>`}
186
+ ${row.fetchedAt ? `<div class="text-xs opacity-50 mt-2">更新于 ${esc(new Date(row.fetchedAt).toLocaleString())}</div>` : ''}
187
+ </div>`).join('');
147
188
  }
148
189
 
149
190
  export function openSettings(section) {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -10,10 +10,10 @@ import {
10
10
  getModelsForAgent, getDefaultModel, getEffortsForModel, getDefaultEffort,
11
11
  addCustomModel, removeCustomModel, getCustomModels, setCurrentModel, ctx,
12
12
  } from '../state.js';
13
- import { api, NOT_MODIFIED } from '../api.js';
13
+ import { api } from '../api.js';
14
14
  import { getCachedSessions, setCachedSessions, getCachedWorkspaces, setCachedWorkspaces } from '../cache.js';
15
15
  import { formatTime, agentBadge, agentCountPills, shortPath } from './util.js';
16
- import { normalizeMarkdownText } from '../markdown.js';
16
+ import { parseMarkdownWithMath } from '../markdown.js';
17
17
 
18
18
 
19
19
  function sessionsFingerprint(list) {
@@ -84,11 +84,30 @@ function orderBranchSessions(sessions) {
84
84
  return out;
85
85
  }
86
86
 
87
- function sessionsQuery({ hubIndex = false } = {}) {
87
+ const SESSION_PAGE_SIZE = 200;
88
+ let sessionPageCount = 1;
89
+ let sessionListMachine = null;
90
+ let sessionListRequest = 0;
91
+ let sessionListHasMore = false;
92
+ let loadingSessionPage = false;
93
+
94
+ async function loadMoreSessions() {
95
+ if (loadingSessionPage) return;
96
+ loadingSessionPage = true;
97
+ try {
98
+ sessionPageCount++;
99
+ await loadAllSessions();
100
+ } finally { loadingSessionPage = false; }
101
+ }
102
+
103
+ document.addEventListener('click', (event) => {
104
+ if (event.target.closest?.('[data-load-more-sessions]')) loadMoreSessions().catch(console.warn);
105
+ });
106
+
107
+ function sessionsQuery({ hubIndex = false, offset = 0 } = {}) {
88
108
  const q = new URLSearchParams();
89
- // Project index is small and should remain complete; the Hub/browser cache
90
- // keeps this inexpensive after the first fetch.
91
- q.set('limit', '2000');
109
+ q.set('limit', String(SESSION_PAGE_SIZE));
110
+ if (offset) q.set('offset', String(offset));
92
111
  // A Hub keeps this catalog locally from Edge deltas. Always use it in Hub
93
112
  // mode: an Edge scan can be slow while an agent is busy, and must never make
94
113
  // the sidebar (or a page refresh) fail with a 502.
@@ -99,7 +118,13 @@ function sessionsQuery({ hubIndex = false } = {}) {
99
118
 
100
119
  /** @param {{ waitFresh?: boolean, hubIndex?: boolean }} opts waitFresh=false → paint cache first, refresh in bg */
101
120
  export async function loadAllSessions({ waitFresh = true, hubIndex = false } = {}) {
102
- const cached = normalizeSessions(await getCachedSessions());
121
+ const machine = selectedMachineId.peek() || null;
122
+ if (machine !== sessionListMachine) {
123
+ sessionListMachine = machine;
124
+ sessionPageCount = 1;
125
+ }
126
+ const request = ++sessionListRequest;
127
+ const cached = normalizeSessions(await getCachedSessions()).slice(0, SESSION_PAGE_SIZE * sessionPageCount);
103
128
  if (cached.length) {
104
129
  // Hub: if we already selected a machine, prefer cache entries for that machine
105
130
  const mid = selectedMachineId.peek();
@@ -112,10 +137,18 @@ export async function loadAllSessions({ waitFresh = true, hubIndex = false } = {
112
137
  }
113
138
 
114
139
  const fetchFresh = async () => {
115
- const path = sessionsQuery({ hubIndex });
116
- const fresh = await api('GET', path, undefined, undefined, { etag: true });
117
- if (fresh === NOT_MODIFIED) return sessionsData.peek();
118
- const list = normalizeSessions(fresh);
140
+ const list = [];
141
+ for (let page = 0; page < sessionPageCount; page++) {
142
+ const path = sessionsQuery({ hubIndex, offset: page * SESSION_PAGE_SIZE });
143
+ const fresh = await api('GET', path, undefined, undefined, { machineId: machine || undefined });
144
+ if (request !== sessionListRequest) return sessionsData.peek();
145
+ const rows = normalizeSessions(fresh);
146
+ list.push(...rows);
147
+ sessionListHasMore = rows.length === SESSION_PAGE_SIZE;
148
+ if (!sessionListHasMore) break;
149
+ }
150
+ const unique = new Map(list.map(s => [`${s.machineId || ''}:${s.agent}:${s.sessionId}`, s]));
151
+ list.splice(0, list.length, ...unique.values());
119
152
  if (sessionsFingerprint(list) !== sessionsFingerprint(sessionsData.peek())) {
120
153
  sessionsData.value = list;
121
154
  setCachedSessions(list);
@@ -400,10 +433,8 @@ export function renderSessionList() {
400
433
  const view = sidebarView.value;
401
434
  const sessions = Array.isArray(filteredSessions.value) ? filteredSessions.value : [];
402
435
  const groups = Array.isArray(projectGroups.value) ? projectGroups.value : [];
403
- if (view === 'timeline') {
404
- return renderTimeline(sessions);
405
- }
406
- return renderProjectGroups(groups);
436
+ const body = view === 'timeline' ? renderTimeline(sessions) : renderProjectGroups(groups);
437
+ return body + (sessionListHasMore ? '<button class="btn btn-sm btn-ghost w-full" data-load-more-sessions>加载更多会话</button>' : '');
407
438
  }
408
439
 
409
440
  export function syncSidebarChrome() {
@@ -498,7 +529,9 @@ export function mdToHtml(text) {
498
529
  if (typeof marked === 'undefined') return `<pre class="whitespace-pre-wrap text-xs">${esc(text)}</pre>`;
499
530
  try {
500
531
  // Prefer shared renderer if chat has configured marked
501
- return marked.parse(normalizeMarkdownText(text), { breaks: true, gfm: true });
532
+ return parseMarkdownWithMath(text, {
533
+ fallback: (t) => `<pre class="whitespace-pre-wrap text-xs">${esc(t)}</pre>`,
534
+ });
502
535
  } catch {
503
536
  return `<pre class="whitespace-pre-wrap text-xs">${esc(text)}</pre>`;
504
537
  }
@@ -6,9 +6,10 @@ import {
6
6
  selectedMachineId, setSelectedMachine, AGENT_LABELS, ctx, hubMode,
7
7
  } from '../state.js';
8
8
  import { api } from '../api.js';
9
+ import { getCachedTranscript, setCachedTranscript, recentTranscript } from '../cache.js';
9
10
  import { getLastSessionContext, setLastSessionContext } from './session-context.js';
10
11
  import {
11
- connectWS, clearMessages, appendMsg, appendSystemMsg, renderToolUse,
12
+ connectWS, clearMessages, beginHistoryRender, endHistoryRender, appendMsg, appendSystemMsg, renderToolUse,
12
13
  appendHistoryTokenBar, appendContextBar, flushToolBatch, coerceTs,
13
14
  stashComposerDraft, restoreComposerDraft, discardComposerAttachments, parkQueuedMessages, detachProcessingForSessionSwitch, setExternalSessionReadOnly,
14
15
  } from '../chat.js';
@@ -229,13 +230,43 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
229
230
  });
230
231
 
231
232
  const label = AGENT_LABELS[resolvedAgent] || resolvedAgent;
232
- const cacheKey = `${resolvedMachine || ''}:${resolvedAgent}:${sid}`;
233
+ const cacheKey = `${ctx.token || ''}:${resolvedMachine || ''}:${resolvedAgent}:${sid}`;
234
+ let paintEpoch = 0;
235
+ let requestEpoch = 0;
236
+ let historyWindow = null;
233
237
 
234
- const paintMessages = (msgs, context, meta = {}) => {
235
- if (ctx.sessionId !== sid) return;
236
- $('messages').innerHTML = '';
238
+ const paintMessages = (msgs, context, meta = {}, prepend = false) => {
239
+ if (ctx.sessionId !== sid || ctx.agent !== resolvedAgent || (ctx.machineId || null) !== resolvedMachine) return;
240
+ const epoch = ++paintEpoch;
241
+ const container = $('messages');
242
+ const hadRows = !!container.querySelector('.msg-row');
243
+ const previousTop = container.scrollTop;
244
+ const previousHeight = container.scrollHeight;
245
+ const following = !hadRows || container.scrollHeight - container.scrollTop - container.clientHeight < 80;
246
+ beginHistoryRender();
247
+ if (meta.oldestIndex > 0) {
248
+ const older = document.createElement('button');
249
+ older.className = 'btn btn-sm btn-ghost';
250
+ older.textContent = '加载更早的消息';
251
+ older.onclick = async () => {
252
+ older.disabled = true;
253
+ try {
254
+ const q = new URLSearchParams({ tail: '80', compact: '1', before: String(meta.oldestIndex), agent: resolvedAgent });
255
+ if (resolvedMachine) q.set('machine', resolvedMachine);
256
+ const page = await api('GET', `/api/sessions/${sid}/messages?${q}`, undefined, undefined, { machineId: resolvedMachine || undefined });
257
+ if (epoch !== paintEpoch || ctx.sessionId !== sid) return;
258
+ const merged = [...page.messages, ...msgs];
259
+ const next = { ...meta, oldestIndex: page.oldestIndex, truncated: page.oldestIndex > 0 };
260
+ historyWindow = { msgs: merged, context, meta: next };
261
+ paintMessages(merged, context, next, true);
262
+ } catch (e) { older.disabled = false; older.textContent = `加载失败,点击重试 (${e.message})`; }
263
+ };
264
+ $('messages').appendChild(older);
265
+ }
266
+ if (meta.stale) appendSystemMsg('当前显示离线缓存,正在等待机器恢复连接');
237
267
  if (!msgs.length) {
238
268
  appendSystemMsg(`${label} · ${sid.slice(0, 8)}… — no history`);
269
+ endHistoryRender();
239
270
  return;
240
271
  }
241
272
  flushToolBatch();
@@ -243,7 +274,7 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
243
274
  const CHUNK = 40;
244
275
  let i = 0;
245
276
  const paintChunk = () => {
246
- if (ctx.sessionId !== sid) return;
277
+ if (ctx.sessionId !== sid || epoch !== paintEpoch) return;
247
278
  const end = Math.min(i + CHUNK, msgs.length);
248
279
  for (; i < end; i++) {
249
280
  const m = msgs[i];
@@ -258,7 +289,7 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
258
289
  ? m.content.map(p => p?.text || '').filter(Boolean).join('\n')
259
290
  : String(m.content ?? ''));
260
291
  if (content) {
261
- appendMsg(m.role === 'user' ? 'user' : 'assistant', m.role === 'user' ? 'You' : label, content, { ts, historyIndex: i });
292
+ appendMsg(m.role === 'user' ? 'user' : 'assistant', m.role === 'user' ? 'You' : label, content, { ts, historyIndex: (meta.oldestIndex || 0) + i });
262
293
  }
263
294
  } else if (typ === 'tool_use' || m.name) {
264
295
  renderToolUse({ name: m.name || 'tool', input: m.input ?? m.arguments ?? {} }, { ts });
@@ -279,15 +310,19 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
279
310
  ? ` (showing last ${msgs.length} of ${meta.total || '?'})`
280
311
  : '';
281
312
  appendSystemMsg(`── ${label} history · ${turnCount} turn${turnCount !== 1 ? 's' : ''}${more} ──`);
313
+ endHistoryRender(prepend ? previousTop + container.scrollHeight - previousHeight : (following ? null : previousTop));
282
314
  };
283
315
  paintChunk();
284
316
  };
285
317
 
286
318
  const loadHistory = async ({ preferCache = true } = {}) => {
319
+ const request = ++requestEpoch;
287
320
  // Instant re-open from in-memory cache (never trust empty cache — race/stale)
288
- const cached = historyCache.get(cacheKey);
289
- if (preferCache && cached && cached.msgs?.length && Date.now() - cached.at < HISTORY_CACHE_TTL) {
290
- paintMessages(cached.msgs, cached.context, cached.meta);
321
+ const cached = historyCache.get(cacheKey) || (preferCache ? await getCachedTranscript(cacheKey) : null);
322
+ if (request !== requestEpoch || ctx.sessionId !== sid) return;
323
+ if (preferCache && cached && cached.msgs?.length) {
324
+ const recent = recentTranscript(cached);
325
+ paintMessages(recent.msgs, recent.context, recent.meta);
291
326
  }
292
327
  if (!cached?.msgs?.length) appendSystemMsg(`Loading ${label} history…`);
293
328
  try {
@@ -298,10 +333,10 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
298
333
  }
299
334
  if (mid && ctx.machineId !== mid) setSelectedMachine(mid);
300
335
 
301
- // Full history (tail=0); tool inputs compacted server-side (~100 chars each).
336
+ // Recent messages first; older pages remain available on demand.
302
337
  const fetchMsgs = async (agent) => {
303
338
  const q = new URLSearchParams({
304
- tail: '0',
339
+ tail: '80',
305
340
  compact: '1',
306
341
  });
307
342
  if (agent) q.set('agent', agent);
@@ -317,6 +352,7 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
317
352
  };
318
353
 
319
354
  let data = await fetchMsgs(resolvedAgent);
355
+ if (request !== requestEpoch || ctx.sessionId !== sid) return;
320
356
  let msgs = Array.isArray(data) ? data : (data?.messages || []);
321
357
  // Prefer stated agent; if empty, retry scanning all agents on the edge.
322
358
  if (!msgs.length && resolvedAgent) {
@@ -325,8 +361,21 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
325
361
  }
326
362
 
327
363
  const context = Array.isArray(data) ? null : (data?.context || null);
328
- const meta = Array.isArray(data) ? {} : { truncated: data?.truncated, total: data?.total };
329
- if (msgs.length) historyCache.set(cacheKey, { msgs, context, meta, at: Date.now() });
364
+ if (request !== requestEpoch || ctx.sessionId !== sid) return;
365
+ const meta = Array.isArray(data) ? {} : { truncated: data?.truncated, total: data?.total, oldestIndex: data?.oldestIndex || 0, stale: data?.stale };
366
+ if (historyWindow && meta.oldestIndex > historyWindow.meta.oldestIndex && historyWindow.meta.oldestIndex > 0) {
367
+ const keep = meta.oldestIndex - historyWindow.meta.oldestIndex;
368
+ if (keep <= historyWindow.msgs.length) {
369
+ msgs = [...historyWindow.msgs.slice(0, keep), ...msgs];
370
+ meta.oldestIndex = historyWindow.meta.oldestIndex;
371
+ }
372
+ }
373
+ historyWindow = { msgs, context, meta };
374
+ if (msgs.length && !meta.stale) {
375
+ const snapshot = { msgs, context, meta, at: Date.now() };
376
+ historyCache.set(cacheKey, recentTranscript(snapshot));
377
+ setCachedTranscript(cacheKey, snapshot).catch(() => {});
378
+ }
330
379
  else historyCache.delete(cacheKey);
331
380
  // A reconnect can receive new stream frames while this verification read
332
381
  // is in flight. Keep those live frames authoritative; the fresh response
@@ -334,9 +383,8 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
334
383
  if (isProcessing.peek() || $('messages')?.querySelector('.live-stream')) return;
335
384
  paintMessages(msgs, context, meta);
336
385
  } catch (e) {
337
- if (ctx.sessionId !== sid) return;
386
+ if (ctx.sessionId !== sid || request !== requestEpoch) return;
338
387
  historyCache.delete(cacheKey);
339
- $('messages').innerHTML = '';
340
388
  appendSystemMsg(`${label} · ${sid.slice(0, 8)}… (history unavailable: ${e.message})`);
341
389
  }
342
390
  };
@@ -408,7 +456,6 @@ function applyProjectPaths(edgeCwd, resolvedMachine, sid) {
408
456
 
409
457
  // In-memory chat history cache (instant re-open within TTL)
410
458
  const historyCache = new Map();
411
- const HISTORY_CACHE_TTL = 3 * 60 * 1000;
412
459
 
413
460
  // ── Tabs ──────────────────────────────────────────────────────────────────────
414
461
  export function switchTab(name) { currentTab.value = name; }
File without changes
File without changes
package/public/shell.js CHANGED
File without changes