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
@@ -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,12 +10,34 @@ 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
+ function sessionsFingerprint(list) {
20
+ if (!Array.isArray(list) || !list.length) return '';
21
+ return list.map(s => `${s.sessionId}:${s.updatedAt || 0}:${s.turnCount || 0}:${s.agent || ''}`).join('|');
22
+ }
23
+
24
+ export function markActiveSessionRow(sessionId, agent) {
25
+ const wantAgent = agent || 'claude';
26
+ document.querySelectorAll('#session-list .session-row').forEach(row => {
27
+ const on = row.dataset.sessionId === sessionId
28
+ && (row.dataset.sessionAgent || 'claude') === wantAgent;
29
+ row.classList.toggle('border-primary', on);
30
+ row.classList.toggle('bg-primary/5', on);
31
+ row.classList.toggle('border-transparent', !on);
32
+ const title = row.querySelector('.session-title-text');
33
+ if (title) {
34
+ title.classList.toggle('text-primary', on);
35
+ title.classList.toggle('font-medium', on);
36
+ title.classList.toggle('text-base-content/85', !on);
37
+ }
38
+ });
39
+ }
40
+
19
41
  function normalizeSessions(v) {
20
42
  if (Array.isArray(v)) return v;
21
43
  if (v && Array.isArray(v.results)) return v.results;
@@ -62,21 +84,47 @@ function orderBranchSessions(sessions) {
62
84
  return out;
63
85
  }
64
86
 
65
- 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 } = {}) {
66
108
  const q = new URLSearchParams();
67
- // Project index is small and should remain complete; the Hub/browser cache
68
- // keeps this inexpensive after the first fetch.
69
- q.set('limit', '2000');
70
- // This is a Hub-local, ETag-enabled catalog read. It never tunnels to an
71
- // Edge or invokes the expensive multi-agent session scanner.
72
- if (hubIndex && hubMode.peek()) q.set('index', '1');
109
+ q.set('limit', String(SESSION_PAGE_SIZE));
110
+ if (offset) q.set('offset', String(offset));
111
+ // A Hub keeps this catalog locally from Edge deltas. Always use it in Hub
112
+ // mode: an Edge scan can be slow while an agent is busy, and must never make
113
+ // the sidebar (or a page refresh) fail with a 502.
114
+ if (hubMode.peek() || hubIndex) q.set('index', '1');
73
115
  const s = q.toString();
74
116
  return s ? `/api/sessions?${s}` : '/api/sessions';
75
117
  }
76
118
 
77
119
  /** @param {{ waitFresh?: boolean, hubIndex?: boolean }} opts waitFresh=false → paint cache first, refresh in bg */
78
120
  export async function loadAllSessions({ waitFresh = true, hubIndex = false } = {}) {
79
- 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);
80
128
  if (cached.length) {
81
129
  // Hub: if we already selected a machine, prefer cache entries for that machine
82
130
  const mid = selectedMachineId.peek();
@@ -89,12 +137,22 @@ export async function loadAllSessions({ waitFresh = true, hubIndex = false } = {
89
137
  }
90
138
 
91
139
  const fetchFresh = async () => {
92
- const path = sessionsQuery({ hubIndex });
93
- const fresh = await api('GET', path, undefined, undefined, { etag: true });
94
- if (fresh === NOT_MODIFIED) return sessionsData.peek();
95
- const list = normalizeSessions(fresh);
96
- sessionsData.value = list;
97
- setCachedSessions(list);
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());
152
+ if (sessionsFingerprint(list) !== sessionsFingerprint(sessionsData.peek())) {
153
+ sessionsData.value = list;
154
+ setCachedSessions(list);
155
+ }
98
156
  return list;
99
157
  };
100
158
 
@@ -375,10 +433,8 @@ export function renderSessionList() {
375
433
  const view = sidebarView.value;
376
434
  const sessions = Array.isArray(filteredSessions.value) ? filteredSessions.value : [];
377
435
  const groups = Array.isArray(projectGroups.value) ? projectGroups.value : [];
378
- if (view === 'timeline') {
379
- return renderTimeline(sessions);
380
- }
381
- 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>' : '');
382
438
  }
383
439
 
384
440
  export function syncSidebarChrome() {
@@ -473,7 +529,9 @@ export function mdToHtml(text) {
473
529
  if (typeof marked === 'undefined') return `<pre class="whitespace-pre-wrap text-xs">${esc(text)}</pre>`;
474
530
  try {
475
531
  // Prefer shared renderer if chat has configured marked
476
- 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
+ });
477
535
  } catch {
478
536
  return `<pre class="whitespace-pre-wrap text-xs">${esc(text)}</pre>`;
479
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';
@@ -111,6 +112,7 @@ export function goHome() {
111
112
 
112
113
  const topbar = $('topbar-project');
113
114
  if (topbar) topbar.textContent = 'Select a session';
115
+ import('../notes.js').then(({ closeNotes }) => closeNotes({ restore: false })).catch(() => {});
114
116
  const welcome = $('welcome');
115
117
  if (welcome) {
116
118
  welcome.classList.remove('hidden');
@@ -152,6 +154,8 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
152
154
  const resolvedCwd = cwd || fromList?.cwd || fromLast?.cwd || null;
153
155
  const resolvedConfig = configDir || fromList?.configDir || fromLast?.configDir || null;
154
156
 
157
+ import('../notes.js').then(({ closeNotes }) => closeNotes({ restore: false })).catch(() => {});
158
+
155
159
  // Capture before changing ctx, then restore only after the new identity is set.
156
160
  stashComposerDraft();
157
161
  parkQueuedMessages();
@@ -199,10 +203,9 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
199
203
  // arrive immediately on socket-open and must belong to the new history.
200
204
  connectWS();
201
205
  switchTab(targetTab);
202
- {
203
- const cur = sessionsData.peek();
204
- sessionsData.value = Array.isArray(cur) ? [...cur] : [];
205
- }
206
+ import('./session-list.js').then(({ markActiveSessionRow }) => {
207
+ markActiveSessionRow(sid, resolvedAgent);
208
+ }).catch(() => {});
206
209
 
207
210
  setLastSessionContext({
208
211
  sessionId: sid,
@@ -227,13 +230,43 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
227
230
  });
228
231
 
229
232
  const label = AGENT_LABELS[resolvedAgent] || resolvedAgent;
230
- 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;
231
237
 
232
- const paintMessages = (msgs, context, meta = {}) => {
233
- if (ctx.sessionId !== sid) return;
234
- $('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('当前显示离线缓存,正在等待机器恢复连接');
235
267
  if (!msgs.length) {
236
268
  appendSystemMsg(`${label} · ${sid.slice(0, 8)}… — no history`);
269
+ endHistoryRender();
237
270
  return;
238
271
  }
239
272
  flushToolBatch();
@@ -241,7 +274,7 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
241
274
  const CHUNK = 40;
242
275
  let i = 0;
243
276
  const paintChunk = () => {
244
- if (ctx.sessionId !== sid) return;
277
+ if (ctx.sessionId !== sid || epoch !== paintEpoch) return;
245
278
  const end = Math.min(i + CHUNK, msgs.length);
246
279
  for (; i < end; i++) {
247
280
  const m = msgs[i];
@@ -256,7 +289,7 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
256
289
  ? m.content.map(p => p?.text || '').filter(Boolean).join('\n')
257
290
  : String(m.content ?? ''));
258
291
  if (content) {
259
- 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 });
260
293
  }
261
294
  } else if (typ === 'tool_use' || m.name) {
262
295
  renderToolUse({ name: m.name || 'tool', input: m.input ?? m.arguments ?? {} }, { ts });
@@ -277,15 +310,19 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
277
310
  ? ` (showing last ${msgs.length} of ${meta.total || '?'})`
278
311
  : '';
279
312
  appendSystemMsg(`── ${label} history · ${turnCount} turn${turnCount !== 1 ? 's' : ''}${more} ──`);
313
+ endHistoryRender(prepend ? previousTop + container.scrollHeight - previousHeight : (following ? null : previousTop));
280
314
  };
281
315
  paintChunk();
282
316
  };
283
317
 
284
318
  const loadHistory = async ({ preferCache = true } = {}) => {
319
+ const request = ++requestEpoch;
285
320
  // Instant re-open from in-memory cache (never trust empty cache — race/stale)
286
- const cached = historyCache.get(cacheKey);
287
- if (preferCache && cached && cached.msgs?.length && Date.now() - cached.at < HISTORY_CACHE_TTL) {
288
- 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);
289
326
  }
290
327
  if (!cached?.msgs?.length) appendSystemMsg(`Loading ${label} history…`);
291
328
  try {
@@ -296,10 +333,10 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
296
333
  }
297
334
  if (mid && ctx.machineId !== mid) setSelectedMachine(mid);
298
335
 
299
- // Full history (tail=0); tool inputs compacted server-side (~100 chars each).
336
+ // Recent messages first; older pages remain available on demand.
300
337
  const fetchMsgs = async (agent) => {
301
338
  const q = new URLSearchParams({
302
- tail: '0',
339
+ tail: '80',
303
340
  compact: '1',
304
341
  });
305
342
  if (agent) q.set('agent', agent);
@@ -315,6 +352,7 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
315
352
  };
316
353
 
317
354
  let data = await fetchMsgs(resolvedAgent);
355
+ if (request !== requestEpoch || ctx.sessionId !== sid) return;
318
356
  let msgs = Array.isArray(data) ? data : (data?.messages || []);
319
357
  // Prefer stated agent; if empty, retry scanning all agents on the edge.
320
358
  if (!msgs.length && resolvedAgent) {
@@ -323,8 +361,21 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
323
361
  }
324
362
 
325
363
  const context = Array.isArray(data) ? null : (data?.context || null);
326
- const meta = Array.isArray(data) ? {} : { truncated: data?.truncated, total: data?.total };
327
- 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
+ }
328
379
  else historyCache.delete(cacheKey);
329
380
  // A reconnect can receive new stream frames while this verification read
330
381
  // is in flight. Keep those live frames authoritative; the fresh response
@@ -332,9 +383,8 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
332
383
  if (isProcessing.peek() || $('messages')?.querySelector('.live-stream')) return;
333
384
  paintMessages(msgs, context, meta);
334
385
  } catch (e) {
335
- if (ctx.sessionId !== sid) return;
386
+ if (ctx.sessionId !== sid || request !== requestEpoch) return;
336
387
  historyCache.delete(cacheKey);
337
- $('messages').innerHTML = '';
338
388
  appendSystemMsg(`${label} · ${sid.slice(0, 8)}… (history unavailable: ${e.message})`);
339
389
  }
340
390
  };
@@ -406,7 +456,6 @@ function applyProjectPaths(edgeCwd, resolvedMachine, sid) {
406
456
 
407
457
  // In-memory chat history cache (instant re-open within TTL)
408
458
  const historyCache = new Map();
409
- const HISTORY_CACHE_TTL = 3 * 60 * 1000;
410
459
 
411
460
  // ── Tabs ──────────────────────────────────────────────────────────────────────
412
461
  export function switchTab(name) { currentTab.value = name; }
@@ -85,6 +85,9 @@ export const AppShell = (opts = {}) => {
85
85
  <button id="btn-meta-agent" class="btn btn-ghost btn-xs border border-primary/40 text-primary topbar-icon-btn" title="Meta Agent — 报告 / 问答 / VLM" aria-label="打开 Meta Agent">
86
86
  <svg class="topbar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><path d="m12 2 1.6 6.4L20 10l-6.4 1.6L12 18l-1.6-6.4L4 10l6.4-1.6L12 2Z"/><path d="m19 16 .7 2.3L22 19l-2.3.7L19 22l-.7-2.3L16 19l2.3-.7L19 16Z"/></svg><span class="topbar-icon-label">Agent</span>
87
87
  </button>
88
+ <button id="btn-notes" class="btn btn-ghost btn-xs border border-base-300 topbar-icon-btn" title="记事" aria-label="记事">
89
+ <svg class="topbar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><path d="M5 4h11l3 3v13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z"/><path d="M8 9h7M8 13h7M8 17h4"/></svg><span class="topbar-icon-label">记事</span>
90
+ </button>
88
91
  <button id="btn-new-session" class="btn btn-ghost btn-xs border border-base-300 topbar-icon-btn" title="New session" aria-label="新建会话">
89
92
  <svg class="topbar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="12" cy="12" r="8"/><path d="M12 8v8M8 12h8"/></svg><span class="topbar-icon-label">New</span>
90
93
  </button>
File without changes
package/public/shell.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from './chat.js';
23
23
  import { syncHash, currentSessionShareUrl } from './router.js';
24
24
  import { initSettings, openSettings } from './settings.js';
25
+ import { openNotes } from './notes.js';
25
26
  import { openMetaAgent, toggleMetaAgent } from './agent-panel.js';
26
27
  import {
27
28
  initNotifications, refreshNotifications, toggleNotificationsMenu, closeNotificationsMenu,
@@ -224,14 +225,25 @@ export function initShell(opts = {}) {
224
225
  void filteredSessions.value;
225
226
  void projectGroups.value;
226
227
  void sidebarView.value;
227
- void activityLoading.value;
228
228
  void activityHits.value;
229
229
  void sessionMetaMap.value;
230
230
  const el = $('session-list');
231
231
  if (!el) return;
232
232
  // Keep inline rename input mounted until blur/Enter
233
233
  if (el.querySelector('.session-rename-input')) return;
234
- el.innerHTML = renderSessionList();
234
+ const html = renderSessionList();
235
+ if (el._lastHtml === html) {
236
+ syncSidebarChrome();
237
+ return;
238
+ }
239
+ const scroll = el.scrollTop;
240
+ el.innerHTML = html;
241
+ el._lastHtml = html;
242
+ el.scrollTop = scroll;
243
+ syncSidebarChrome();
244
+ });
245
+ effect(() => {
246
+ void activityLoading.value;
235
247
  syncSidebarChrome();
236
248
  });
237
249
 
@@ -443,6 +455,7 @@ export function initShell(opts = {}) {
443
455
  document.getElementById('btn-pwa-install')?.classList.remove('hidden');
444
456
  });
445
457
  delegate.on('click', '#btn-settings', openSettings);
458
+ delegate.on('click', '#btn-notes', () => openNotes());
446
459
  delegate.on('click', '#btn-collapse-chat-chrome', () => setChatChromeCollapsed(true));
447
460
  delegate.on('click', '#btn-expand-chat-chrome', () => setChatChromeCollapsed(false));
448
461
  delegate.on('click', '#btn-meta-agent', () => toggleMetaAgent());
package/public/state.js CHANGED
@@ -56,7 +56,7 @@ export function setCurrentModel(model, agent = currentAgent.peek(), sessionId =
56
56
  }
57
57
 
58
58
  export const currentAgent = signal(initialAgent); // 'claude'|'codex'|'grok'|'gemini'|'cursor'
59
- export const currentModel = signal('claude-sonnet-4-5');
59
+ export const currentModel = signal('sonnet');
60
60
  export const currentEffort = signal('');
61
61
  // All local agents are trusted by default; users can still choose another mode
62
62
  // from the expanded composer options before starting a turn.
@@ -164,10 +164,10 @@ export const AGENT_LABELS = {
164
164
  /** Static fallbacks when /api/agents has not loaded yet. */
165
165
  export const AGENT_MODELS = {
166
166
  claude: [
167
- { value: 'claude-sonnet-4-5', label: 'sonnet-4-5' },
168
- { value: 'claude-sonnet-4-6', label: 'sonnet-4-6' },
169
- { value: 'claude-opus-4-5', label: 'opus-4-5' },
170
- { value: 'claude-haiku-4-5', label: 'haiku-4-5' },
167
+ { value: 'fable', label: 'fable' },
168
+ { value: 'opus', label: 'opus' },
169
+ { value: 'sonnet', label: 'sonnet' },
170
+ { value: 'haiku', label: 'haiku' },
171
171
  ],
172
172
  codex: [
173
173
  { value: 'gpt-5.4', label: 'gpt-5.4' },
@@ -204,7 +204,7 @@ export const AGENT_MODELS = {
204
204
  };
205
205
 
206
206
  export const AGENT_DEFAULT_MODEL = {
207
- claude: 'claude-sonnet-4-5',
207
+ claude: 'sonnet',
208
208
  codex: 'gpt-5.4',
209
209
  grok: 'grok-4.5',
210
210
  gemini: 'gemini-3.7-flash-high',
@@ -304,7 +304,7 @@ export function getDefaultModel(agent) {
304
304
  const a = (agent || 'claude').toLowerCase();
305
305
  const meta = agentsMeta.peek()?.defaults?.[a];
306
306
  if (meta?.model) return String(meta.model);
307
- return AGENT_DEFAULT_MODEL[a] || getModelsForAgent(a)[0]?.value || 'claude-sonnet-4-5';
307
+ return AGENT_DEFAULT_MODEL[a] || getModelsForAgent(a)[0]?.value || 'sonnet';
308
308
  }
309
309
 
310
310
  /** Effort levels for current (or given) model under agent. */