claude-simple 0.2.27 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.27",
3
+ "version": "0.2.28",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
@@ -48,7 +48,8 @@ function highlight(root) {
48
48
 
49
49
  /** Fetch SSE with auth (+ hub machine routing). */
50
50
  async function streamSSE(path, body, handlers) {
51
- const headers = authHeaders({ 'Content-Type': 'application/json' });
51
+ // Meta Agent is hub-owned. Do not attach the currently selected edge.
52
+ const headers = authHeaders({ 'Content-Type': 'application/json' }, { hub: true });
52
53
  const res = await fetch(path, {
53
54
  method: 'POST',
54
55
  headers,
@@ -466,7 +467,11 @@ function insertAtCursor(ta, text) {
466
467
 
467
468
  /** Upload blob → server path; insert [imgN](path) into input. */
468
469
  async function uploadAndInsertImage(blob, filename) {
469
- const headers = authHeaders({ 'x-filename': filename || `paste-${Date.now()}.png` });
470
+ // Attachments used by Meta Agent belong to the Hub conversation too.
471
+ const headers = authHeaders(
472
+ { 'x-filename': filename || `paste-${Date.now()}.png` },
473
+ { hub: true },
474
+ );
470
475
  // raw body upload — do not set Content-Type (browser sets multipart/stream)
471
476
  delete headers['Content-Type'];
472
477
  const res = await fetch('/api/upload-image', {
package/public/api.js CHANGED
@@ -24,8 +24,11 @@ function isHubOnlyPath(path) {
24
24
  || p === '/api/machines'
25
25
  || p === '/api/notifications'
26
26
  || p === '/api/notifications/read'
27
- || p === '/api/ai/notes'
28
- || p.startsWith('/api/ai/notes/')
27
+ || p === '/api/notes'
28
+ || p.startsWith('/api/notes/')
29
+ || p === '/api/queue'
30
+ || p.startsWith('/api/queue/')
31
+ || p.startsWith('/api/ai/')
29
32
  || p.startsWith('/api/auth/')
30
33
  || p.startsWith('/api/collab/');
31
34
  }
@@ -114,11 +117,11 @@ export function authQuery(extra = {}) {
114
117
  }
115
118
 
116
119
  /** Headers for raw fetch (e.g. file upload) in hub mode. */
117
- export function authHeaders(extra = {}) {
120
+ export function authHeaders(extra = {}, opts = {}) {
118
121
  const h = { ...extra };
119
122
  if (ctx.token) h.Authorization = `Bearer ${ctx.token}`;
120
123
  const mid = currentMachineId();
121
- if (hubMode.peek() && mid) h['X-Machine-Id'] = mid;
124
+ if (!opts.hub && hubMode.peek() && mid) h['X-Machine-Id'] = mid;
122
125
  return h;
123
126
  }
124
127
 
package/public/app.js CHANGED
@@ -6,6 +6,7 @@ import { initChat } from './chat.js';
6
6
  import { initFilesTab } from './files.js?v=ui-22';
7
7
  import { initGitTab } from './git.js';
8
8
  import { initSettings } from './settings.js';
9
+ import { initNotes } from './notes.js';
9
10
  import { initTerminal } from './terminal.js';
10
11
  import { initImagePaste } from './chat.js';
11
12
  import { initMetaAgent } from './agent-panel.js';
@@ -36,6 +37,7 @@ initChat();
36
37
  initFilesTab();
37
38
  initGitTab();
38
39
  initSettings();
40
+ initNotes();
39
41
  initTerminal();
40
42
  initImagePaste();
41
43
  initMetaAgent();
package/public/chat.js CHANGED
@@ -441,6 +441,29 @@ function handleWsMessage(msg) {
441
441
  // session while its subscription is changing. Do not let that frame alter
442
442
  // the new session's transcript, processing state, or queue.
443
443
  if (msg?.sessionId && ctx.sessionId && msg.sessionId !== ctx.sessionId) return;
444
+ // Fan out note/queue WS events to any module that subscribes via the
445
+ // hub-ws-message custom event (e.g. personal notes, queue outbox). Done
446
+ // before session-scope filtering so global widgets (notes page) still
447
+ // receive updates for non-active sessions. We also dispatch the
448
+ // type-specific hub:note-* events used by the legacy notes page.
449
+ if (msg && typeof msg.type === 'string' && (
450
+ msg.type === 'note.added' ||
451
+ msg.type === 'note.updated' ||
452
+ msg.type === 'note.removed' ||
453
+ msg.type === 'queue.item.added' ||
454
+ msg.type === 'queue.item.updated' ||
455
+ msg.type === 'queue.item.removed' ||
456
+ msg.type === 'queue.item.moved' ||
457
+ msg.type === 'queue.item.sending'
458
+ )) {
459
+ window.dispatchEvent(new CustomEvent('hub-ws-message', { detail: msg }));
460
+ const noteEvent = ({
461
+ 'note.added': 'hub:note-added',
462
+ 'note.updated': 'hub:note-updated',
463
+ 'note.removed': 'hub:note-removed',
464
+ })[msg.type];
465
+ if (noteEvent) window.dispatchEvent(new CustomEvent(noteEvent, { detail: msg }));
466
+ }
444
467
  // Server outbox may pack multiple events into one frame
445
468
  if (msg.type === 'batch' && Array.isArray(msg.items)) {
446
469
  for (const item of msg.items) handleWsMessage(item);
@@ -551,10 +574,21 @@ function handleWsMessage(msg) {
551
574
 
552
575
  // ── Live streaming (Grok / future agents) ─────────────────────────────────
553
576
  if (msg.type === 'assistant_stream_start') {
577
+ // A replay can arrive before the edge's run-status snapshot. Mark the
578
+ // turn busy as soon as its first stream frame reaches us so the concurrent
579
+ // history read cannot repaint the transcript over live output.
580
+ if (!isProcessing.peek()) {
581
+ isProcessing.value = true;
582
+ syncWakeLock();
583
+ }
554
584
  ensureAssistantStream(msg.ts);
555
585
  return;
556
586
  }
557
587
  if (msg.type === 'assistant_delta') {
588
+ if (!isProcessing.peek()) {
589
+ isProcessing.value = true;
590
+ syncWakeLock();
591
+ }
558
592
  appendAssistantDelta(msg.data || '');
559
593
  return;
560
594
  }
@@ -0,0 +1,316 @@
1
+ // notes.js — personal notes in the main pane (sidebar stays visible),
2
+ // matching the Flutter HomePage "记事" chip: list + add/edit/delete cards
3
+ // over the same /api/notes store.
4
+
5
+ import { api } from './api.js';
6
+ import { ctx } from './state.js';
7
+
8
+ let notesCache = null;
9
+ let notesEditingId = null;
10
+ let notesBooted = false;
11
+ let notesShowForm = false;
12
+
13
+ export function openNotes() {
14
+ ensureNotesBoot();
15
+ const page = document.getElementById('notes-page');
16
+ if (!page) return;
17
+ page.hidden = false;
18
+ document.getElementById('welcome')?.classList.add('hidden');
19
+ const pv = document.getElementById('project-view');
20
+ if (pv) {
21
+ pv.classList.add('hidden');
22
+ pv.style.display = 'none';
23
+ }
24
+ document.documentElement.classList.remove('notes-open');
25
+ if ((location.hash || '') !== '#/notes') location.hash = '/notes';
26
+ loadNotes({ keepList: true });
27
+ }
28
+
29
+ export function closeNotes({ restore = true } = {}) {
30
+ const page = document.getElementById('notes-page');
31
+ if (!page) return;
32
+ page.hidden = true;
33
+ document.documentElement.classList.remove('notes-open');
34
+ notesEditingId = null;
35
+ notesShowForm = false;
36
+ const form = document.getElementById('notes-add-form');
37
+ if (form) form.hidden = true;
38
+ if (!restore) return;
39
+ if (ctx.sessionId) {
40
+ const pv = document.getElementById('project-view');
41
+ if (pv) {
42
+ pv.classList.remove('hidden');
43
+ pv.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden';
44
+ }
45
+ } else {
46
+ const welcome = document.getElementById('welcome');
47
+ if (welcome) {
48
+ welcome.classList.remove('hidden');
49
+ welcome.style.display = '';
50
+ }
51
+ }
52
+ }
53
+
54
+ function ensureNotesBoot() {
55
+ if (notesBooted) return;
56
+ notesBooted = true;
57
+ const main = document.getElementById('main') || document.getElementById('root');
58
+ main.insertAdjacentHTML('beforeend', notesMarkup());
59
+ document.addEventListener('router:notes', () => openNotes());
60
+
61
+ const page = document.getElementById('notes-page');
62
+ page.addEventListener('click', (event) => {
63
+ const close = event.target.closest('[data-notes-close]');
64
+ if (close) {
65
+ closeNotes();
66
+ if ((location.hash || '') === '#/notes') history.back();
67
+ return;
68
+ }
69
+ const add = event.target.closest('[data-notes-add]');
70
+ if (add) {
71
+ event.preventDefault();
72
+ notesShowForm = !notesShowForm;
73
+ const form = document.getElementById('notes-add-form');
74
+ if (form) form.hidden = !notesShowForm;
75
+ add.textContent = notesShowForm ? '收起' : '+ 新增';
76
+ if (notesShowForm) document.getElementById('notes-add-text')?.focus();
77
+ return;
78
+ }
79
+ const submit = event.target.closest('[data-notes-submit]');
80
+ if (submit) {
81
+ event.preventDefault();
82
+ submitNote();
83
+ return;
84
+ }
85
+ const edit = event.target.closest('[data-notes-edit]');
86
+ if (edit) {
87
+ event.preventDefault();
88
+ startEditNote(edit.dataset.notesEdit);
89
+ return;
90
+ }
91
+ const save = event.target.closest('[data-notes-save]');
92
+ if (save) {
93
+ event.preventDefault();
94
+ saveEditNote();
95
+ return;
96
+ }
97
+ const cancel = event.target.closest('[data-notes-cancel]');
98
+ if (cancel) {
99
+ event.preventDefault();
100
+ if (notesEditingId) cancelEditNote();
101
+ else {
102
+ notesShowForm = false;
103
+ const form = document.getElementById('notes-add-form');
104
+ if (form) form.hidden = true;
105
+ const addBtn = document.querySelector('[data-notes-add]');
106
+ if (addBtn) addBtn.textContent = '+ 新增';
107
+ }
108
+ return;
109
+ }
110
+ const remove = event.target.closest('[data-notes-remove]');
111
+ if (remove) {
112
+ event.preventDefault();
113
+ const id = remove.dataset.notesRemove;
114
+ if (confirm('删除这条记录?')) deleteNote(id);
115
+ }
116
+ });
117
+
118
+ // Submit on Enter inside the textarea (Cmd/Ctrl+Enter), plain Enter inserts a newline.
119
+ const ta = document.getElementById('notes-add-text');
120
+ if (ta) {
121
+ ta.addEventListener('keydown', (e) => {
122
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
123
+ e.preventDefault();
124
+ submitNote();
125
+ }
126
+ });
127
+ }
128
+ }
129
+
130
+ function notesMarkup() {
131
+ return `
132
+ <section id="notes-page" class="notes-page" hidden>
133
+ <header class="notes-header">
134
+ <div>
135
+ <h2>记事</h2>
136
+ <p id="notes-count" class="notes-count">多端实时同步</p>
137
+ </div>
138
+ <div class="notes-header-actions">
139
+ <button class="btn btn-sm btn-ghost border border-base-300" data-notes-add>+ 新增</button>
140
+ <button class="btn btn-sm btn-ghost border border-base-300" data-notes-close>关闭</button>
141
+ </div>
142
+ </header>
143
+ <form id="notes-add-form" class="notes-add" hidden>
144
+ <textarea id="notes-add-text" placeholder="记一条… (Cmd/Ctrl+Enter 提交)" rows="3" maxlength="8000"></textarea>
145
+ <div class="notes-form-actions">
146
+ <button class="btn btn-sm btn-primary" data-notes-submit>保存</button>
147
+ <button class="btn btn-sm btn-ghost" type="button" data-notes-cancel>取消</button>
148
+ </div>
149
+ </form>
150
+ <ul id="notes-list" class="notes-list">
151
+ <li class="notes-empty">加载中…</li>
152
+ </ul>
153
+ </section>`;
154
+ }
155
+
156
+ async function loadNotes({ keepList = false } = {}) {
157
+ const list = document.getElementById('notes-list');
158
+ if (!list) return;
159
+ if (!keepList || !notesCache) {
160
+ list.innerHTML = '<li class="notes-empty">加载中…</li>';
161
+ }
162
+ try {
163
+ const res = await api('GET', '/api/notes?limit=200', undefined, undefined, { hub: true });
164
+ notesCache = res?.items || [];
165
+ renderNotes();
166
+ } catch (err) {
167
+ list.innerHTML = `<li class="notes-empty notes-error">加载失败:${err?.message || err}</li>`;
168
+ }
169
+ }
170
+
171
+ function renderNotes() {
172
+ const list = document.getElementById('notes-list');
173
+ if (!list) return;
174
+ const count = document.getElementById('notes-count');
175
+ if (count) {
176
+ count.textContent = notesCache?.length
177
+ ? `${notesCache.length} 条记事 · 多端实时同步`
178
+ : '多端实时同步';
179
+ }
180
+ const form = document.getElementById('notes-add-form');
181
+ if (form) form.hidden = !notesShowForm;
182
+ if (!notesCache || notesCache.length === 0) {
183
+ list.innerHTML = '<li class="notes-empty">还没有记事。点上面的「新增」,或者在 Meta Agent 里输入「记: xxx」</li>';
184
+ return;
185
+ }
186
+ list.innerHTML = '';
187
+ for (const note of notesCache) {
188
+ const li = document.createElement('li');
189
+ li.className = 'notes-item';
190
+ li.dataset.id = note.id;
191
+ if (notesEditingId === note.id) {
192
+ li.innerHTML = `
193
+ <textarea class="notes-edit-text" rows="4">${escapeHtml(note.text)}</textarea>
194
+ <div class="notes-item-actions">
195
+ <button class="btn btn-sm btn-primary" data-notes-save>保存</button>
196
+ <button class="btn btn-sm btn-ghost" data-notes-cancel>取消</button>
197
+ </div>`;
198
+ } else {
199
+ li.innerHTML = `
200
+ <div class="notes-text">${escapeHtml(note.text)}</div>
201
+ <div class="notes-item-foot">
202
+ <span class="notes-meta">${formatTime(note.createdAt)}</span>
203
+ <div class="notes-item-actions">
204
+ <button class="btn btn-ghost btn-xs" data-notes-edit="${note.id}" title="编辑">编辑</button>
205
+ <button class="btn btn-ghost btn-xs text-error" data-notes-remove="${note.id}" title="删除">删除</button>
206
+ </div>
207
+ </div>`;
208
+ }
209
+ list.appendChild(li);
210
+ }
211
+ }
212
+
213
+ function startEditNote(id) {
214
+ notesEditingId = id;
215
+ renderNotes();
216
+ }
217
+
218
+ function cancelEditNote() {
219
+ notesEditingId = null;
220
+ renderNotes();
221
+ }
222
+
223
+ async function saveEditNote() {
224
+ const list = document.getElementById('notes-list');
225
+ if (!list) return;
226
+ const li = list.querySelector(`li[data-id="${cssEscape(notesEditingId)}"]`);
227
+ if (!li) return;
228
+ const ta = li.querySelector('.notes-edit-text');
229
+ if (!ta) return;
230
+ const text = (ta.value || '').trim();
231
+ if (!text) {
232
+ if (confirm('文本为空,删除这条记录?')) {
233
+ await deleteNote(notesEditingId);
234
+ }
235
+ return;
236
+ }
237
+ try {
238
+ await api('PATCH', `/api/notes/${notesEditingId}`, { text }, undefined, { hub: true });
239
+ notesEditingId = null;
240
+ await loadNotes({ keepList: true });
241
+ } catch (err) {
242
+ alert('保存失败:' + (err?.message || err));
243
+ }
244
+ }
245
+
246
+ async function submitNote() {
247
+ const ta = document.getElementById('notes-add-text');
248
+ if (!ta) return;
249
+ const text = (ta.value || '').trim();
250
+ if (!text) {
251
+ ta.focus();
252
+ return;
253
+ }
254
+ try {
255
+ await api('POST', '/api/notes', { text }, undefined, { hub: true });
256
+ ta.value = '';
257
+ notesShowForm = false;
258
+ const form = document.getElementById('notes-add-form');
259
+ if (form) form.hidden = true;
260
+ const addBtn = document.querySelector('[data-notes-add]');
261
+ if (addBtn) addBtn.textContent = '+ 新增';
262
+ await loadNotes({ keepList: true });
263
+ } catch (err) {
264
+ alert('保存失败:' + (err?.message || err));
265
+ }
266
+ }
267
+
268
+ async function deleteNote(id) {
269
+ try {
270
+ await api('DELETE', `/api/notes/${id}`, undefined, undefined, { hub: true });
271
+ if (notesEditingId === id) notesEditingId = null;
272
+ await loadNotes({ keepList: true });
273
+ } catch (err) {
274
+ alert('删除失败:' + (err?.message || err));
275
+ }
276
+ }
277
+
278
+ function formatTime(ms) {
279
+ if (!ms) return '';
280
+ const d = new Date(ms);
281
+ const pad = (n) => String(n).padStart(2, '0');
282
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
283
+ }
284
+
285
+ // cssEscape polyfill (modern browsers expose CSS.escape on globalThis).
286
+ function cssEscape(value) {
287
+ if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value);
288
+ return String(value).replace(/[^a-zA-Z0-9_-]/g, (c) => '\\' + c);
289
+ }
290
+
291
+ function escapeHtml(value) {
292
+ return String(value ?? '')
293
+ .replace(/&/g, '&amp;')
294
+ .replace(/</g, '&lt;')
295
+ .replace(/>/g, '&gt;')
296
+ .replace(/"/g, '&quot;')
297
+ .replace(/'/g, '&#39;');
298
+ }
299
+
300
+ // Re-render if a Hub note event arrives while the page is open. The events
301
+ // come from the chat WebSocket, so we listen on ctx.ws indirectly via state.
302
+ if (typeof window !== 'undefined') {
303
+ window.addEventListener('hub:note-added', () => {
304
+ if (!document.getElementById('notes-page')?.hidden) loadNotes();
305
+ });
306
+ window.addEventListener('hub:note-updated', () => {
307
+ if (!document.getElementById('notes-page')?.hidden) loadNotes();
308
+ });
309
+ window.addEventListener('hub:note-removed', () => {
310
+ if (!document.getElementById('notes-page')?.hidden) loadNotes();
311
+ });
312
+ }
313
+
314
+ export function initNotes() {
315
+ ensureNotesBoot();
316
+ }
package/public/router.js CHANGED
@@ -59,6 +59,10 @@ function applyHash(path) {
59
59
  document.dispatchEvent(new CustomEvent('router:settings'));
60
60
  return;
61
61
  }
62
+ if (pathname === '/notes') {
63
+ document.dispatchEvent(new CustomEvent('router:notes'));
64
+ return;
65
+ }
62
66
  // /session/:id or /session/:id/:tab
63
67
  const parsed = parseHashPath(path);
64
68
  if (!parsed) {
@@ -16,6 +16,28 @@ import { formatTime, agentBadge, agentCountPills, shortPath } from './util.js';
16
16
  import { normalizeMarkdownText } 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;
@@ -67,9 +89,10 @@ function sessionsQuery({ hubIndex = false } = {}) {
67
89
  // Project index is small and should remain complete; the Hub/browser cache
68
90
  // keeps this inexpensive after the first fetch.
69
91
  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');
92
+ // A Hub keeps this catalog locally from Edge deltas. Always use it in Hub
93
+ // mode: an Edge scan can be slow while an agent is busy, and must never make
94
+ // the sidebar (or a page refresh) fail with a 502.
95
+ if (hubMode.peek() || hubIndex) q.set('index', '1');
73
96
  const s = q.toString();
74
97
  return s ? `/api/sessions?${s}` : '/api/sessions';
75
98
  }
@@ -93,8 +116,10 @@ export async function loadAllSessions({ waitFresh = true, hubIndex = false } = {
93
116
  const fresh = await api('GET', path, undefined, undefined, { etag: true });
94
117
  if (fresh === NOT_MODIFIED) return sessionsData.peek();
95
118
  const list = normalizeSessions(fresh);
96
- sessionsData.value = list;
97
- setCachedSessions(list);
119
+ if (sessionsFingerprint(list) !== sessionsFingerprint(sessionsData.peek())) {
120
+ sessionsData.value = list;
121
+ setCachedSessions(list);
122
+ }
98
123
  return list;
99
124
  };
100
125
 
@@ -111,6 +111,7 @@ export function goHome() {
111
111
 
112
112
  const topbar = $('topbar-project');
113
113
  if (topbar) topbar.textContent = 'Select a session';
114
+ import('../notes.js').then(({ closeNotes }) => closeNotes({ restore: false })).catch(() => {});
114
115
  const welcome = $('welcome');
115
116
  if (welcome) {
116
117
  welcome.classList.remove('hidden');
@@ -152,6 +153,8 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
152
153
  const resolvedCwd = cwd || fromList?.cwd || fromLast?.cwd || null;
153
154
  const resolvedConfig = configDir || fromList?.configDir || fromLast?.configDir || null;
154
155
 
156
+ import('../notes.js').then(({ closeNotes }) => closeNotes({ restore: false })).catch(() => {});
157
+
155
158
  // Capture before changing ctx, then restore only after the new identity is set.
156
159
  stashComposerDraft();
157
160
  parkQueuedMessages();
@@ -199,10 +202,9 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
199
202
  // arrive immediately on socket-open and must belong to the new history.
200
203
  connectWS();
201
204
  switchTab(targetTab);
202
- {
203
- const cur = sessionsData.peek();
204
- sessionsData.value = Array.isArray(cur) ? [...cur] : [];
205
- }
205
+ import('./session-list.js').then(({ markActiveSessionRow }) => {
206
+ markActiveSessionRow(sid, resolvedAgent);
207
+ }).catch(() => {});
206
208
 
207
209
  setLastSessionContext({
208
210
  sessionId: sid,
@@ -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>
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());