claude-simple 0.2.6 → 0.2.7

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.6",
3
+ "version": "0.2.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { esc, $ } from './lib.js';
6
6
  import { api, authHeaders, currentMachineId } from './api.js';
7
- import { hubMode, ctx } from './state.js';
7
+ import { hubMode, ctx, currentAgent, currentProject } from './state.js';
8
8
 
9
9
  let panelOpen = false;
10
10
  let mode = 'chat'; // chat | report | history | config
@@ -19,6 +19,10 @@ let autoTimer = null;
19
19
  let lastAutoDay = localStorage.getItem('ai_last_auto_report') || '';
20
20
  let historyCache = [];
21
21
  let historySearch = '';
22
+ let notesCache = [];
23
+ let notesSearch = '';
24
+ let editingNoteId = null;
25
+ let currentNoteSnapshot = null;
22
26
  const LAST_SESSION_KEY = 'ma_last_session_id';
23
27
 
24
28
  function mdHtml(text) {
@@ -90,6 +94,7 @@ function panelHTML() {
90
94
  <div class="flex items-center gap-1">
91
95
  <button type="button" id="ma-btn-chat" class="btn btn-xs btn-ghost border border-primary/40 text-primary">对话</button>
92
96
  <button type="button" id="ma-btn-history" class="btn btn-xs btn-ghost border border-base-300" title="历史对话">历史</button>
97
+ <button type="button" id="ma-btn-notes" class="btn btn-xs btn-ghost border border-base-300" title="工作记事">记事</button>
93
98
  <button type="button" id="ma-btn-report" class="btn btn-xs btn-ghost border border-base-300">报告</button>
94
99
  <button type="button" id="ma-btn-config" class="btn btn-xs btn-ghost border border-base-300" title="AI 配置">⚙</button>
95
100
  <button type="button" id="ma-btn-close" class="btn btn-xs btn-ghost" data-ma-close="1">✕</button>
@@ -145,6 +150,56 @@ function panelHTML() {
145
150
  </p>
146
151
  </div>
147
152
 
153
+ <div id="ma-mode-notes" class="meta-agent-body hidden flex min-h-0 flex-1">
154
+ <div class="w-[44%] min-w-[180px] max-w-[240px] border-r border-base-300 flex flex-col">
155
+ <div class="flex items-center gap-2 px-3 py-2 border-b border-base-300 flex-shrink-0">
156
+ <input id="ma-notes-search" type="search" placeholder="搜索记事…"
157
+ class="input input-xs input-bordered flex-1 text-xs" autocomplete="off">
158
+ <button type="button" id="ma-notes-refresh" class="btn btn-ghost btn-xs">↻</button>
159
+ <button type="button" id="ma-notes-new" class="btn btn-primary btn-xs">+</button>
160
+ </div>
161
+ <div id="ma-notes-list" class="flex-1 overflow-y-auto p-2 flex flex-col gap-1"></div>
162
+ </div>
163
+ <div class="flex-1 min-w-0 flex flex-col">
164
+ <div class="px-3 py-2 border-b border-base-300 text-[11px] text-base-content/45 flex items-center gap-2 flex-wrap">
165
+ <span>记事保存在 Hub,可被 Meta Agent 检索。</span>
166
+ <span id="ma-note-links" class="truncate"></span>
167
+ </div>
168
+ <div class="p-3 flex flex-col gap-3 flex-1 overflow-y-auto">
169
+ <label class="form-control">
170
+ <span class="label-text text-xs">标题</span>
171
+ <input id="ma-note-title" class="input input-sm input-bordered text-sm" placeholder="例如:发布前遗留问题">
172
+ </label>
173
+ <label class="form-control flex-1">
174
+ <span class="label-text text-xs">内容</span>
175
+ <textarea id="ma-note-content" class="textarea textarea-bordered text-sm min-h-[220px]" placeholder="记录决策、风险、待办、排查线索…"></textarea>
176
+ </label>
177
+ <div class="flex flex-wrap items-center gap-3 text-xs">
178
+ <label class="flex items-center gap-1 cursor-pointer">
179
+ <input type="checkbox" id="ma-note-pin" class="checkbox checkbox-xs">
180
+ 置顶
181
+ </label>
182
+ <label class="flex items-center gap-1 cursor-pointer">
183
+ <input type="checkbox" id="ma-note-link-project" class="checkbox checkbox-xs">
184
+ 关联当前项目
185
+ </label>
186
+ <label class="flex items-center gap-1 cursor-pointer">
187
+ <input type="checkbox" id="ma-note-link-session" class="checkbox checkbox-xs">
188
+ 关联当前 session
189
+ </label>
190
+ </div>
191
+ <div class="flex items-center justify-between gap-2">
192
+ <span id="ma-note-status" class="text-xs text-base-content/45"></span>
193
+ <div class="flex items-center gap-2">
194
+ <button type="button" id="ma-note-ask" class="btn btn-ghost btn-sm">快捷提问</button>
195
+ <button type="button" id="ma-note-delete" class="btn btn-ghost btn-sm text-error">删除</button>
196
+ <button type="button" id="ma-note-save" class="btn btn-primary btn-sm">保存记事</button>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </div>
202
+
148
203
  <div id="ma-mode-report" class="meta-agent-body hidden flex-col min-h-0 flex-1">
149
204
  <div class="flex items-center gap-2 px-3 py-2 border-b border-base-300 flex-shrink-0 flex-wrap">
150
205
  <select id="ma-report-days" class="select select-xs select-bordered">
@@ -214,11 +269,13 @@ function setMode(m) {
214
269
  mode = m;
215
270
  const chat = $('ma-mode-chat');
216
271
  const history = $('ma-mode-history');
272
+ const notes = $('ma-mode-notes');
217
273
  const report = $('ma-mode-report');
218
274
  const config = $('ma-mode-config');
219
275
  for (const [el, key] of [
220
276
  [chat, 'chat'],
221
277
  [history, 'history'],
278
+ [notes, 'notes'],
222
279
  [report, 'report'],
223
280
  [config, 'config'],
224
281
  ]) {
@@ -235,8 +292,10 @@ function setMode(m) {
235
292
  };
236
293
  tab('ma-btn-chat', m === 'chat');
237
294
  tab('ma-btn-history', m === 'history');
295
+ tab('ma-btn-notes', m === 'notes');
238
296
  tab('ma-btn-report', m === 'report');
239
297
  if (m === 'history') renderHistoryList();
298
+ if (m === 'notes') renderNotesList();
240
299
  }
241
300
 
242
301
  function updateSessionChrome() {
@@ -484,6 +543,155 @@ async function refreshHistoryCache() {
484
543
  return historyCache;
485
544
  }
486
545
 
546
+ function currentNoteLinks() {
547
+ const proj = currentProject.peek();
548
+ const sessionLinked = ctx.sessionId ? `${ctx.agent || currentAgent.peek() || 'claude'}:${ctx.sessionId.slice(0, 8)}` : '';
549
+ return {
550
+ project: proj?.path || '',
551
+ sessionId: ctx.sessionId || '',
552
+ agent: ctx.agent || currentAgent.peek() || 'claude',
553
+ summary: [
554
+ proj?.name ? `项目 ${proj.name}` : '',
555
+ sessionLinked ? `会话 ${sessionLinked}` : '',
556
+ ].filter(Boolean).join(' · '),
557
+ };
558
+ }
559
+
560
+ function resetNoteEditor() {
561
+ editingNoteId = null;
562
+ currentNoteSnapshot = null;
563
+ $('ma-note-title').value = '';
564
+ $('ma-note-content').value = '';
565
+ $('ma-note-pin').checked = false;
566
+ $('ma-note-link-project').checked = !!currentProject.peek()?.path;
567
+ $('ma-note-link-session').checked = !!ctx.sessionId;
568
+ $('ma-note-status').textContent = '新记事';
569
+ $('ma-note-delete').disabled = true;
570
+ $('ma-note-links').textContent = currentNoteLinks().summary || '未关联上下文';
571
+ }
572
+
573
+ function fillNoteEditor(note) {
574
+ editingNoteId = note?.id || null;
575
+ currentNoteSnapshot = note || null;
576
+ $('ma-note-title').value = note?.title || '';
577
+ $('ma-note-content').value = note?.content || '';
578
+ $('ma-note-pin').checked = !!note?.pinned;
579
+ $('ma-note-link-project').checked = !!note?.cwd;
580
+ $('ma-note-link-session').checked = !!note?.sessionId;
581
+ $('ma-note-status').textContent = note?.updatedAt ? `上次更新 ${fmtSessionWhen(note.updatedAt)}` : '';
582
+ $('ma-note-delete').disabled = !note?.id;
583
+ const links = [];
584
+ if (note?.cwd) links.push(`项目 ${note.cwd.split('/').pop()}`);
585
+ if (note?.sessionId) links.push(`会话 ${(note.agent || 'claude')}:${note.sessionId.slice(0, 8)}`);
586
+ $('ma-note-links').textContent = links.join(' · ') || '未关联上下文';
587
+ }
588
+
589
+ function askFromCurrentNote() {
590
+ const title = $('ma-note-title')?.value?.trim() || '';
591
+ const content = $('ma-note-content')?.value?.trim() || '';
592
+ if (!title && !content) {
593
+ $('ma-note-status').textContent = '先选一条记事或输入内容';
594
+ return;
595
+ }
596
+ const links = [];
597
+ if ($('ma-note-link-project')?.checked && currentProject.peek()?.path) {
598
+ links.push(`项目路径:${currentProject.peek().path}`);
599
+ }
600
+ if ($('ma-note-link-session')?.checked && ctx.sessionId) {
601
+ links.push(`关联会话:${ctx.agent || currentAgent.peek() || 'claude'} / ${ctx.sessionId}`);
602
+ }
603
+ const prompt = [
604
+ '基于下面这条工作记事回答,并结合相关会话与项目上下文:',
605
+ `标题:${title || '未命名'}`,
606
+ content ? `内容:\n${content}` : '',
607
+ links.length ? links.join('\n') : '',
608
+ '',
609
+ '请先总结这条记事的核心,再给出下一步建议和可能风险。',
610
+ ].filter(Boolean).join('\n');
611
+ setMode('chat');
612
+ $('ma-input').value = prompt;
613
+ $('ma-input').dispatchEvent(new Event('input'));
614
+ $('ma-input').focus();
615
+ }
616
+
617
+ async function refreshNotesCache() {
618
+ const q = (notesSearch || '').trim();
619
+ const data = await api('GET', `/api/ai/notes?limit=80${q ? `&q=${encodeURIComponent(q)}` : ''}`);
620
+ notesCache = data.notes || [];
621
+ return notesCache;
622
+ }
623
+
624
+ function renderNotesList() {
625
+ const list = $('ma-notes-list');
626
+ if (!list) return;
627
+ if (!notesCache.length) {
628
+ list.innerHTML = '<div class="text-center text-xs text-base-content/40 py-10">还没有记事。</div>';
629
+ return;
630
+ }
631
+ list.innerHTML = notesCache.map((note) => {
632
+ const active = note.id === editingNoteId;
633
+ const tags = [
634
+ note.scope === 'project' ? '项目' : note.scope === 'session' ? '会话' : '通用',
635
+ note.pinned ? '置顶' : '',
636
+ ].filter(Boolean).join(' · ');
637
+ return `
638
+ <button type="button" class="ma-note-item ${active ? 'active' : ''}" data-note-id="${esc(note.id)}">
639
+ <div class="text-left">
640
+ <div class="font-medium text-xs truncate">${esc(note.title || '未命名')}</div>
641
+ <div class="text-[10px] text-base-content/45 mt-0.5 line-clamp-2">${esc(note.content || '')}</div>
642
+ <div class="text-[10px] text-base-content/35 mt-1">${esc(tags)}${tags ? ' · ' : ''}${esc(fmtSessionWhen(note.updatedAt) || '')}</div>
643
+ </div>
644
+ </button>`;
645
+ }).join('');
646
+ list.querySelectorAll('[data-note-id]').forEach((btn) => {
647
+ btn.addEventListener('click', async () => {
648
+ try {
649
+ const note = await api('GET', `/api/ai/notes/${btn.dataset.noteId}`);
650
+ fillNoteEditor(note);
651
+ renderNotesList();
652
+ } catch (e) {
653
+ $('ma-note-status').textContent = e.message;
654
+ }
655
+ });
656
+ });
657
+ }
658
+
659
+ async function saveCurrentNote() {
660
+ const title = $('ma-note-title')?.value?.trim() || '';
661
+ const content = $('ma-note-content')?.value?.trim() || '';
662
+ if (!title || !content) {
663
+ $('ma-note-status').textContent = '标题和内容都不能为空';
664
+ return;
665
+ }
666
+ const links = currentNoteLinks();
667
+ const body = {
668
+ id: editingNoteId || undefined,
669
+ title,
670
+ content,
671
+ pinned: !!$('ma-note-pin')?.checked,
672
+ cwd: $('ma-note-link-project')?.checked ? links.project : '',
673
+ sessionId: $('ma-note-link-session')?.checked ? links.sessionId : '',
674
+ agent: $('ma-note-link-session')?.checked ? links.agent : '',
675
+ };
676
+ $('ma-note-status').textContent = '保存中…';
677
+ const note = await api('POST', '/api/ai/notes', body);
678
+ fillNoteEditor(note);
679
+ await refreshNotesCache();
680
+ renderNotesList();
681
+ }
682
+
683
+ async function deleteCurrentNoteEditor() {
684
+ if (!editingNoteId) {
685
+ resetNoteEditor();
686
+ return;
687
+ }
688
+ if (!confirm('删除这条记事?不可恢复。')) return;
689
+ await api('DELETE', `/api/ai/notes/${editingNoteId}`);
690
+ resetNoteEditor();
691
+ await refreshNotesCache();
692
+ renderNotesList();
693
+ }
694
+
487
695
  function renderHistoryList() {
488
696
  const list = $('ma-history-list');
489
697
  if (!list) return;
@@ -986,6 +1194,11 @@ export function initMetaAgent() {
986
1194
  await refreshHistoryCache();
987
1195
  setMode('history');
988
1196
  });
1197
+ $('ma-btn-notes')?.addEventListener('click', async () => {
1198
+ await refreshNotesCache();
1199
+ if (!editingNoteId) resetNoteEditor();
1200
+ setMode('notes');
1201
+ });
989
1202
  $('ma-open-history')?.addEventListener('click', async () => {
990
1203
  await refreshHistoryCache();
991
1204
  setMode('history');
@@ -1042,6 +1255,35 @@ export function initMetaAgent() {
1042
1255
  });
1043
1256
  $('ma-gen-report')?.addEventListener('click', generateReport);
1044
1257
  $('ma-load-reports')?.addEventListener('click', loadReportList);
1258
+ $('ma-notes-refresh')?.addEventListener('click', async () => {
1259
+ await refreshNotesCache();
1260
+ renderNotesList();
1261
+ });
1262
+ $('ma-notes-new')?.addEventListener('click', () => {
1263
+ resetNoteEditor();
1264
+ renderNotesList();
1265
+ $('ma-note-title')?.focus();
1266
+ });
1267
+ $('ma-notes-search')?.addEventListener('input', async (e) => {
1268
+ notesSearch = e.target.value || '';
1269
+ await refreshNotesCache();
1270
+ renderNotesList();
1271
+ });
1272
+ $('ma-note-save')?.addEventListener('click', async () => {
1273
+ try {
1274
+ await saveCurrentNote();
1275
+ } catch (e) {
1276
+ $('ma-note-status').textContent = e.message;
1277
+ }
1278
+ });
1279
+ $('ma-note-delete')?.addEventListener('click', async () => {
1280
+ try {
1281
+ await deleteCurrentNoteEditor();
1282
+ } catch (e) {
1283
+ $('ma-note-status').textContent = e.message;
1284
+ }
1285
+ });
1286
+ $('ma-note-ask')?.addEventListener('click', askFromCurrentNote);
1045
1287
  $('ma-cfg-save')?.addEventListener('click', saveConfig);
1046
1288
  $('ma-auto-report')?.addEventListener('change', async () => {
1047
1289
  try {
@@ -1114,6 +1356,7 @@ export function initMetaAgent() {
1114
1356
  // status + selected machine are known (boot.js / hub.js), not here: at this
1115
1357
  // point probeHub() hasn't resolved yet, so hubMode always reads as false.
1116
1358
  loadSession(null, { silent: true });
1359
+ resetNoteEditor();
1117
1360
  }
1118
1361
 
1119
1362
  /**
@@ -1124,6 +1367,7 @@ export function loadMetaAgentBoot() {
1124
1367
  if (!ctx.token) return;
1125
1368
  if (hubMode.peek() && !currentMachineId()) return;
1126
1369
  refreshHistoryCache();
1370
+ refreshNotesCache().catch(() => {});
1127
1371
  api('GET', '/api/ai/config').then(scheduleAutoReport).catch(() => {});
1128
1372
  }
1129
1373
 
package/public/api.js CHANGED
@@ -22,6 +22,8 @@ function isHubOnlyPath(path) {
22
22
  const p = path.split('?')[0];
23
23
  return p === '/api/hub'
24
24
  || p === '/api/machines'
25
+ || p === '/api/ai/notes'
26
+ || p.startsWith('/api/ai/notes/')
25
27
  || p.startsWith('/api/auth/');
26
28
  }
27
29
 
package/public/chat.js CHANGED
@@ -32,8 +32,57 @@ let lifecycleBound = false;
32
32
  let sawDisconnect = false;
33
33
  let lastWsMachine = null;
34
34
  let reconnectQuiet = false;
35
- /** Queued user text to send after current turn finishes (Grok used to block forever). */
36
- let pendingUserText = null;
35
+ /** FIFO messages deliberately queued while a turn is running. */
36
+ let pendingUserTexts = [];
37
+ // Composer state belongs to a conversation, never to the shared textarea.
38
+ // Keeping this in memory deliberately avoids persisting potentially sensitive
39
+ // prompts to disk while still making session switches lossless.
40
+ const composerDrafts = new Map();
41
+
42
+ function draftKey({ sessionId = ctx.sessionId, agent = ctx.agent, machineId = ctx.machineId } = {}) {
43
+ if (!sessionId) return null;
44
+ return `${machineId || ''}:${agent || 'claude'}:${sessionId}`;
45
+ }
46
+
47
+ /** Save the visible, unsent prompt under its current session identity. */
48
+ export function stashComposerDraft(identity) {
49
+ const key = draftKey(identity);
50
+ const input = $('chat-input');
51
+ if (!key || !input) return;
52
+ if (input.value) composerDrafts.set(key, input.value);
53
+ else composerDrafts.delete(key);
54
+ }
55
+
56
+ /** Restore only the target session's draft; a missing one must mean blank. */
57
+ export function restoreComposerDraft(identity) {
58
+ const input = $('chat-input');
59
+ if (!input) return;
60
+ input.value = composerDrafts.get(draftKey(identity)) || '';
61
+ autoResize(input);
62
+ updateInputMode(input.value);
63
+ }
64
+
65
+ /**
66
+ * Leaving a session turns its queued sends back into a visible draft. This is
67
+ * intentionally conservative: a background completion must never send work
68
+ * into whichever session the user is viewing now.
69
+ */
70
+ export function parkQueuedMessages(identity) {
71
+ const key = draftKey(identity);
72
+ if (!key) return;
73
+ const queued = pendingUserTexts.filter(item => item.key === key);
74
+ if (!queued.length) return;
75
+ pendingUserTexts = pendingUserTexts.filter(item => item.key !== key);
76
+ const existing = composerDrafts.get(key) || '';
77
+ composerDrafts.set(key, [existing, ...queued.map(item => item.text)].filter(Boolean).join('\n\n'));
78
+ }
79
+
80
+ /** A run may continue server-side after navigation, but it is no longer active UI state. */
81
+ export function detachProcessingForSessionSwitch() {
82
+ if (!isProcessing.peek()) return;
83
+ isProcessing.value = false;
84
+ syncWakeLock();
85
+ }
37
86
 
38
87
  function bindSocketLifecycle() {
39
88
  if (lifecycleBound) return;
@@ -238,16 +287,27 @@ export function connectWS(opts = {}) {
238
287
  }
239
288
 
240
289
  function flushPendingUserText() {
241
- if (!pendingUserText || isProcessing.peek()) return;
242
- const text = pendingUserText;
243
- pendingUserText = null;
290
+ if (!pendingUserTexts.length || isProcessing.peek()) return;
291
+ const currentKey = draftKey();
292
+ const index = pendingUserTexts.findIndex(item => item.key === currentKey);
293
+ // Queues belonging to another session stay parked until that session is
294
+ // opened (or are converted to a draft during navigation).
295
+ if (index < 0) return;
296
+ const [pending] = pendingUserTexts.splice(index, 1);
244
297
  const input = $('chat-input');
245
298
  if (input) {
246
- input.value = text;
299
+ input.value = pending.text;
247
300
  autoResize(input);
248
301
  }
249
- // Defer so isProcessing watch can re-enable send button first
250
- setTimeout(() => sendMessage(), 0);
302
+ // Defer so isProcessing watch can re-enable send button first. Re-check the
303
+ // identity inside the callback: a click can switch sessions in this tiny gap.
304
+ setTimeout(() => {
305
+ if (pending.key !== draftKey() || isProcessing.peek()) {
306
+ composerDrafts.set(pending.key, pending.text);
307
+ return;
308
+ }
309
+ sendMessage();
310
+ }, 0);
251
311
  }
252
312
 
253
313
  function markTurnDone() {
@@ -257,6 +317,10 @@ function markTurnDone() {
257
317
  }
258
318
 
259
319
  function handleWsMessage(msg) {
320
+ // The socket can still receive a final frame from the previously viewed
321
+ // session while its subscription is changing. Do not let that frame alter
322
+ // the new session's transcript, processing state, or queue.
323
+ if (msg?.sessionId && ctx.sessionId && msg.sessionId !== ctx.sessionId) return;
260
324
  // Server outbox may pack multiple events into one frame
261
325
  if (msg.type === 'batch' && Array.isArray(msg.items)) {
262
326
  for (const item of msg.items) handleWsMessage(item);
@@ -702,7 +766,7 @@ export function sendMessage() {
702
766
 
703
767
  // While a turn is running, queue the next message (common Grok complaint).
704
768
  if (isProcessing.peek()) {
705
- pendingUserText = text;
769
+ pendingUserTexts.push({ key: draftKey(), text });
706
770
  input.value = '';
707
771
  autoResize(input);
708
772
  updateInputMode('');
@@ -712,6 +776,7 @@ export function sendMessage() {
712
776
  }
713
777
 
714
778
  input.value = '';
779
+ composerDrafts.delete(draftKey());
715
780
  autoResize(input);
716
781
  updateInputMode('');
717
782
  clearPendingAttachments();
@@ -754,7 +819,7 @@ export function sendMessage() {
754
819
  configDir: ctx.configDir,
755
820
  model: currentModel.peek(),
756
821
  ...(effort && { effort }),
757
- ...(permission && permission !== 'default' && { permissionMode: permission }),
822
+ ...(permission && { permissionMode: permission }),
758
823
  ...(permission === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
759
824
  },
760
825
  });
@@ -766,7 +831,7 @@ export function stopProcessing() {
766
831
  if (ctx.sessionId) {
767
832
  sendWs({ type: 'command', cmd: 'turn.interrupt', sessionId: ctx.sessionId });
768
833
  }
769
- pendingUserText = null;
834
+ pendingUserTexts = pendingUserTexts.filter(item => item.key !== draftKey());
770
835
  markTurnDone();
771
836
  }
772
837
 
@@ -1019,6 +1084,11 @@ function clearPendingAttachments() {
1019
1084
  renderAttachBar();
1020
1085
  }
1021
1086
 
1087
+ /** Attachments are bound to the visible composer; discard them before session navigation. */
1088
+ export function discardComposerAttachments() {
1089
+ clearPendingAttachments();
1090
+ }
1091
+
1022
1092
  function renderAttachBar() {
1023
1093
  const bar = $('chat-attach-bar');
1024
1094
  if (!bar) return;
package/public/index.html CHANGED
@@ -83,7 +83,7 @@
83
83
  <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
84
84
  <link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css">
85
85
  <script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
86
- <link rel="stylesheet" href="style.css?v=ui-10">
86
+ <link rel="stylesheet" href="style.css?v=ui-11">
87
87
  </head>
88
88
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
89
89
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
@@ -99,6 +99,6 @@
99
99
  }
100
100
  }, 10000);
101
101
  </script>
102
- <script type="module" src="app.js?v=ui-10"></script>
102
+ <script type="module" src="app.js?v=ui-11"></script>
103
103
  </body>
104
104
  </html>
@@ -226,10 +226,33 @@ export function showSelectedDaySessions(sessions) {
226
226
 
227
227
  /** How many sessions in the topbar “最近” quick-jump menu. */
228
228
  export const RECENT_MENU_LIMIT = 15;
229
+ const RECENT_DISMISSED_KEY = 'recentSessionsDismissed';
230
+
231
+ function recentDismissKey(s) {
232
+ return `${s.machineId || ''}:${s.agent || 'claude'}:${s.sessionId}`;
233
+ }
234
+
235
+ function dismissedRecentKeys() {
236
+ try {
237
+ const saved = JSON.parse(localStorage.getItem(RECENT_DISMISSED_KEY) || '[]');
238
+ return new Set(Array.isArray(saved) ? saved : []);
239
+ } catch { return new Set(); }
240
+ }
241
+
242
+ /** Remove an item from this device's quick-jump list; the actual session remains intact. */
243
+ export function dismissRecentSession(session) {
244
+ if (!session?.sessionId) return;
245
+ const keys = dismissedRecentKeys();
246
+ keys.add(recentDismissKey(session));
247
+ // Keep storage bounded even after many one-off sessions.
248
+ localStorage.setItem(RECENT_DISMISSED_KEY, JSON.stringify([...keys].slice(-200)));
249
+ renderRecentSessionsMenu(getDashboardSessions());
250
+ }
229
251
 
230
252
  function sortRecentSessions(sessions, limit) {
253
+ const dismissed = dismissedRecentKeys();
231
254
  return [...asSessionArray(sessions)]
232
- .filter(s => s && s.sessionId)
255
+ .filter(s => s && s.sessionId && !dismissed.has(recentDismissKey(s)))
233
256
  .sort((a, b) => (coerceTs(b.updatedAt) || 0) - (coerceTs(a.updatedAt) || 0))
234
257
  .slice(0, limit);
235
258
  }
@@ -357,14 +380,14 @@ export function renderRecentSessionsMenu(sessions) {
357
380
  ? `<span class="text-[9px] font-mono text-base-content/35 truncate max-w-[4.5rem]" title="${esc(s.machineId)}">@${esc(s.machineId)}</span>`
358
381
  : '';
359
382
  return `
360
- <button type="button"
361
- class="recent-menu-item w-full text-left px-2 py-1.5 rounded-md flex items-start gap-2
362
- ${active ? 'bg-primary/15 text-primary' : 'hover:bg-base-200'}"
363
- data-session-id="${esc(s.sessionId)}"
364
- data-session-cwd="${esc(s.cwd || '')}"
365
- data-session-config-dir="${esc(s.configDir || '')}"
366
- data-session-agent="${esc(s.agent || 'claude')}"
367
- data-session-machine="${esc(s.machineId || '')}">
383
+ <div class="recent-menu-item w-full text-left px-2 py-1.5 rounded-md flex items-start gap-2
384
+ ${active ? 'bg-primary/15 text-primary' : 'hover:bg-base-200'}">
385
+ <button type="button" class="flex min-w-0 flex-1 items-start gap-2 text-left" aria-label="打开 ${esc(sessionDisplayTitle(s))}"
386
+ data-session-id="${esc(s.sessionId)}"
387
+ data-session-cwd="${esc(s.cwd || '')}"
388
+ data-session-config-dir="${esc(s.configDir || '')}"
389
+ data-session-agent="${esc(s.agent || 'claude')}"
390
+ data-session-machine="${esc(s.machineId || '')}">
368
391
  <span class="flex-shrink-0 mt-0.5">${agentBadge(s.agent)}</span>
369
392
  <span class="flex-1 min-w-0">
370
393
  <span class="block text-xs font-semibold truncate leading-snug">${esc(sessionDisplayTitle(s))}</span>
@@ -375,7 +398,13 @@ export function renderRecentSessionsMenu(sessions) {
375
398
  ${machine}
376
399
  ${active ? '<span class="text-[9px] opacity-70">当前</span>' : ''}
377
400
  </span>
378
- </button>`;
401
+ </button>
402
+ <button type="button" class="recent-remove btn btn-ghost btn-xs min-h-7 h-7 w-7 px-0 flex-shrink-0 text-base-content/45 hover:text-error"
403
+ title="从最近会话移除" aria-label="从最近会话移除 ${esc(sessionDisplayTitle(s))}"
404
+ data-dismiss-recent-session="${esc(s.sessionId)}"
405
+ data-dismiss-recent-agent="${esc(s.agent || 'claude')}"
406
+ data-dismiss-recent-machine="${esc(s.machineId || '')}">×</button>
407
+ </div>`;
379
408
  }).join('');
380
409
  }
381
410