claude-simple 0.2.7 → 0.2.8

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 (59) hide show
  1. package/README.md +0 -0
  2. package/dist/app.0.2.8.min.js +1080 -0
  3. package/dist/index.html +2 -2
  4. package/dist/{style.0.2.7.min.css → style.0.2.8.min.css} +1 -1
  5. package/gateway.js +0 -0
  6. package/package.json +1 -1
  7. package/public/addon-fit.min.js +0 -0
  8. package/public/agent-panel.js +0 -0
  9. package/public/api.js +2 -0
  10. package/public/app.js +0 -0
  11. package/public/base.css +0 -0
  12. package/public/cache.js +0 -0
  13. package/public/chat.js +37 -6
  14. package/public/files.js +0 -0
  15. package/public/git.js +0 -0
  16. package/public/icons/icon-180.png +0 -0
  17. package/public/icons/icon-192.png +0 -0
  18. package/public/icons/icon-512.png +0 -0
  19. package/public/icons/icon-maskable-512.png +0 -0
  20. package/public/index.html +0 -0
  21. package/public/lib.js +0 -0
  22. package/public/manifest.webmanifest +0 -0
  23. package/public/mobile.js +0 -0
  24. package/public/notifications.js +215 -0
  25. package/public/offline.html +0 -0
  26. package/public/pwa.js +0 -0
  27. package/public/router.js +0 -0
  28. package/public/settings.js +0 -0
  29. package/public/shell/boot.js +0 -0
  30. package/public/shell/dashboard.js +46 -26
  31. package/public/shell/hub.js +0 -0
  32. package/public/shell/layout.js +2 -3
  33. package/public/shell/notes.js +0 -0
  34. package/public/shell/session-context.js +0 -0
  35. package/public/shell/session-list.js +5 -14
  36. package/public/shell/session-nav.js +18 -1
  37. package/public/shell/templates.js +18 -0
  38. package/public/shell/util.js +0 -0
  39. package/public/shell.js +24 -6
  40. package/public/state.js +49 -7
  41. package/public/style.css +0 -0
  42. package/public/sw.js +1 -1
  43. package/public/terminal.js +0 -0
  44. package/public/wake-lock.js +0 -0
  45. package/public/xterm.min.css +0 -0
  46. package/public/xterm.min.js +0 -0
  47. package/src/agents/claude.js +0 -0
  48. package/src/agents/codex.js +0 -0
  49. package/src/agents/grok.js +180 -299
  50. package/src/agents/meta-agent.js +0 -0
  51. package/src/agents/models.js +0 -0
  52. package/src/agents/sessions.js +0 -0
  53. package/src/agents/stream-batch.js +0 -0
  54. package/src/app.js +0 -0
  55. package/src/auto-update.js +0 -0
  56. package/src/runtime/approvals.js +0 -0
  57. package/src/runtime/outbox.js +0 -0
  58. package/src/runtime/protocol.js +0 -0
  59. package/dist/app.0.2.7.min.js +0 -1039
package/gateway.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
File without changes
File without changes
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/notifications'
26
+ || p === '/api/notifications/read'
25
27
  || p === '/api/ai/notes'
26
28
  || p.startsWith('/api/ai/notes/')
27
29
  || p.startsWith('/api/auth/');
package/public/app.js CHANGED
File without changes
package/public/base.css CHANGED
File without changes
package/public/cache.js CHANGED
File without changes
package/public/chat.js CHANGED
@@ -2,9 +2,10 @@
2
2
  import { watch, delegate, esc, $ } from './lib.js';
3
3
  import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
4
4
  currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
5
- addCustomModel, chatDensity, wsStatus } from './state.js';
5
+ addCustomModel, setCurrentModel, chatDensity, wsStatus } from './state.js';
6
6
  import { sendWs, api, authHeaders, flushWsQueue, clearWsQueue } from './api.js';
7
7
  import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
8
+ import { pushNotification } from './notifications.js';
8
9
 
9
10
  function assistantLabel() {
10
11
  return AGENT_LABELS[currentAgent.peek()] || 'Assistant';
@@ -327,12 +328,20 @@ function handleWsMessage(msg) {
327
328
  return;
328
329
  }
329
330
  if (msg.type === 'session-created') {
331
+ const selectedModel = currentModel.peek();
330
332
  ctx.sessionId = msg.sessionId;
331
- if (msg.agent) setAgent(msg.agent);
333
+ if (msg.agent && msg.agent !== currentAgent.peek()) setAgent(msg.agent);
334
+ // The first turn selected a model before a session id existed. Bind that
335
+ // choice as soon as the edge creates the session.
336
+ setCurrentModel(selectedModel, msg.agent || currentAgent.peek(), msg.sessionId);
332
337
  // Notify shell to refresh session list — use custom event to avoid circular import
333
338
  document.dispatchEvent(new CustomEvent('sessions-changed'));
334
339
  return;
335
340
  }
341
+ if (msg.type === 'notification' && msg.notification) {
342
+ pushNotification(msg.notification);
343
+ return;
344
+ }
336
345
  if (msg.type === 'result') {
337
346
  finalizeThoughtStream();
338
347
  finalizeAssistantStream();
@@ -579,8 +588,7 @@ function handleSlashCommand(text) {
579
588
  if (a !== currentAgent.peek()) {
580
589
  ctx.sessionId = null;
581
590
  setAgent(a);
582
- currentModel.value = getDefaultModel(a);
583
- localStorage.setItem('model', currentModel.peek());
591
+ setCurrentModel(getDefaultModel(a));
584
592
  document.dispatchEvent(new CustomEvent('agent-changed'));
585
593
  appendSystemMsg(`Agent → ${AGENT_LABELS[a]} · model ${currentModel.peek()} · new session`);
586
594
  } else {
@@ -600,8 +608,7 @@ function handleSlashCommand(text) {
600
608
  // Persist free-form ids so they stay in the model dropdown
601
609
  const known = getModelsForAgent(agent).some(m => m.value === resolved);
602
610
  if (!known) addCustomModel(agent, resolved);
603
- currentModel.value = resolved;
604
- localStorage.setItem('model', resolved);
611
+ setCurrentModel(resolved);
605
612
  appendSystemMsg(`Model set to ${resolved}${known ? '' : ' (saved as custom ★)'}`);
606
613
  document.dispatchEvent(new CustomEvent('agent-changed'));
607
614
  return true;
@@ -766,6 +773,27 @@ export function sendMessage() {
766
773
 
767
774
  // While a turn is running, queue the next message (common Grok complaint).
768
775
  if (isProcessing.peek()) {
776
+ // Grok owns its queue on the edge through a long-lived ACP process. Send
777
+ // immediately so a page reload cannot discard this follow-up in the tab.
778
+ if (currentAgent.peek() === 'grok' && ctx.sessionId) {
779
+ const agent = 'grok';
780
+ const ok = sendWs({
781
+ type: 'command', cmd: 'turn.start', agent, command: text, sessionId: ctx.sessionId,
782
+ options: {
783
+ agent, cwd: currentProject.peek().path, sessionId: ctx.sessionId,
784
+ configDir: ctx.configDir, model: currentModel.peek(),
785
+ ...(currentEffort.peek() && { effort: currentEffort.peek() }),
786
+ ...(currentPermission.peek() && { permissionMode: currentPermission.peek() }),
787
+ ...(currentPermission.peek() === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
788
+ },
789
+ });
790
+ input.value = '';
791
+ autoResize(input);
792
+ updateInputMode('');
793
+ clearPendingAttachments();
794
+ appendSystemMsg(ok ? '已加入 Grok 服务端队列,刷新页面不会丢失' : '连接未就绪,消息将在重连后发送');
795
+ return;
796
+ }
769
797
  pendingUserTexts.push({ key: draftKey(), text });
770
798
  input.value = '';
771
799
  autoResize(input);
@@ -806,6 +834,9 @@ export function sendMessage() {
806
834
  const effort = currentEffort.peek();
807
835
  const permission = currentPermission.peek();
808
836
  const agent = currentAgent.peek() || 'claude';
837
+ // Existing sessions retain their own picker value. New sessions are bound
838
+ // on the session-created event above.
839
+ if (ctx.sessionId) setCurrentModel(currentModel.peek(), agent, ctx.sessionId);
809
840
  const ok = sendWs({
810
841
  type: 'command',
811
842
  cmd: 'turn.start',
package/public/files.js CHANGED
File without changes
package/public/git.js CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
package/public/index.html CHANGED
File without changes
package/public/lib.js CHANGED
File without changes
File without changes
package/public/mobile.js CHANGED
File without changes
@@ -0,0 +1,215 @@
1
+ import { $, delegate, esc } from './lib.js';
2
+ import { api } from './api.js';
3
+ import { coerceTs } from './shell/util.js';
4
+
5
+ let notifications = [];
6
+ let unreadCount = 0;
7
+ let openSession = null;
8
+
9
+ function fmtTime(ts) {
10
+ const ms = coerceTs(ts);
11
+ if (ms == null) return '';
12
+ const d = new Date(ms);
13
+ if (Number.isNaN(d.getTime())) return '';
14
+ return d.toLocaleString();
15
+ }
16
+
17
+ function updateBadge() {
18
+ const badge = $('notifications-badge');
19
+ if (!badge) return;
20
+ if (!unreadCount) {
21
+ badge.classList.add('hidden');
22
+ badge.textContent = '0';
23
+ return;
24
+ }
25
+ badge.classList.remove('hidden');
26
+ badge.textContent = unreadCount > 99 ? '99+' : String(unreadCount);
27
+ }
28
+
29
+ function positionNotificationsMenu() {
30
+ const menu = $('notifications-menu');
31
+ const btn = $('btn-notifications');
32
+ const topbar = $('topbar');
33
+ if (!menu || !btn || !topbar) return;
34
+ const btnRect = btn.getBoundingClientRect();
35
+ const topbarRect = topbar.getBoundingClientRect();
36
+ const width = Math.min(416, window.innerWidth - 16);
37
+ const left = Math.max(8, Math.min(window.innerWidth - width - 8, btnRect.right - width));
38
+ menu.style.top = `${Math.round(topbarRect.bottom + 8)}px`;
39
+ menu.style.left = `${Math.round(left)}px`;
40
+ menu.style.width = `${Math.round(width)}px`;
41
+ }
42
+
43
+ function renderNotifications() {
44
+ const listEl = $('notifications-menu-list');
45
+ const emptyEl = $('notifications-menu-empty');
46
+ if (!listEl || !emptyEl) return;
47
+ if (!notifications.length) {
48
+ listEl.innerHTML = '';
49
+ emptyEl.classList.remove('hidden');
50
+ updateBadge();
51
+ return;
52
+ }
53
+ emptyEl.classList.add('hidden');
54
+ listEl.innerHTML = notifications.map((item) => `
55
+ <button type="button"
56
+ class="w-full text-left rounded-lg border px-3 py-2 transition-colors hover:bg-base-200/70 ${item.unread ? 'border-primary/35 bg-primary/5' : 'border-base-300 bg-base-100'}"
57
+ data-notification-id="${esc(item.id)}"
58
+ data-notification-session="${esc(item.sessionId || '')}"
59
+ data-notification-agent="${esc(item.agent || 'claude')}"
60
+ data-notification-cwd="${esc(item.cwd || '')}"
61
+ data-notification-machine="${esc(item.machineId || '')}">
62
+ <div class="flex items-start gap-2">
63
+ <span class="text-sm leading-5">🔔</span>
64
+ <div class="min-w-0 flex-1">
65
+ <div class="flex items-center gap-2">
66
+ <span class="text-xs font-semibold truncate">${esc(item.title || 'Session done')}</span>
67
+ ${item.unread ? '<span class="inline-block w-2 h-2 rounded-full bg-primary flex-shrink-0"></span>' : ''}
68
+ </div>
69
+ ${item.body ? `<div class="text-[11px] text-base-content/55 mt-0.5 line-clamp-2">${esc(item.body)}</div>` : ''}
70
+ <div class="flex items-center gap-2 mt-1 text-[10px] text-base-content/35">
71
+ ${item.projectName ? `<span class="font-mono truncate">${esc(item.projectName)}</span>` : ''}
72
+ ${item.machineId ? `<span class="px-1 py-0.5 rounded bg-base-300 text-base-content/55 font-mono">${esc(item.machineId)}</span>` : ''}
73
+ <span>${esc(fmtTime(item.createdAt))}</span>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </button>`).join('');
78
+ updateBadge();
79
+ }
80
+
81
+ export function closeNotificationsMenu() {
82
+ $('notifications-menu')?.classList.add('hidden');
83
+ $('notifications-backdrop')?.classList.add('hidden');
84
+ $('btn-notifications')?.setAttribute('aria-expanded', 'false');
85
+ }
86
+
87
+ export function openNotificationsMenu() {
88
+ positionNotificationsMenu();
89
+ $('notifications-menu')?.classList.remove('hidden');
90
+ $('notifications-backdrop')?.classList.remove('hidden');
91
+ $('btn-notifications')?.setAttribute('aria-expanded', 'true');
92
+ }
93
+
94
+ export function toggleNotificationsMenu() {
95
+ const menu = $('notifications-menu');
96
+ if (!menu) return;
97
+ if (menu.classList.contains('hidden')) openNotificationsMenu();
98
+ else closeNotificationsMenu();
99
+ }
100
+
101
+ function mergeNotifications(rows) {
102
+ const map = new Map();
103
+ for (const item of rows || []) {
104
+ if (!item?.id) continue;
105
+ map.set(String(item.id), item);
106
+ }
107
+ notifications = [...map.values()].sort((a, b) => (coerceTs(b.createdAt) || 0) - (coerceTs(a.createdAt) || 0));
108
+ unreadCount = notifications.reduce((sum, item) => sum + (item.unread ? 1 : 0), 0);
109
+ renderNotifications();
110
+ }
111
+
112
+ export async function refreshNotifications() {
113
+ try {
114
+ const data = await api('GET', '/api/notifications?limit=80');
115
+ notifications = Array.isArray(data?.notifications) ? data.notifications : [];
116
+ unreadCount = Number(data?.unreadCount) || notifications.reduce((sum, item) => sum + (item.unread ? 1 : 0), 0);
117
+ renderNotifications();
118
+ } catch {
119
+ notifications = [];
120
+ unreadCount = 0;
121
+ renderNotifications();
122
+ }
123
+ }
124
+
125
+ async function markNotificationsRead(ids = null) {
126
+ const data = await api('POST', '/api/notifications/read', ids?.length ? { ids } : {});
127
+ notifications = Array.isArray(data?.notifications) ? data.notifications : notifications.map(item => (
128
+ !ids || ids.includes(item.id) ? { ...item, unread: false } : item
129
+ ));
130
+ unreadCount = Number(data?.unreadCount) || notifications.reduce((sum, item) => sum + (item.unread ? 1 : 0), 0);
131
+ renderNotifications();
132
+ }
133
+
134
+ function maybeShowBrowserNotification(item) {
135
+ if (typeof Notification === 'undefined') return;
136
+ if (document.visibilityState === 'visible') return;
137
+ if (Notification.permission !== 'granted') return;
138
+ try {
139
+ const n = new Notification(item.title || 'Session done', {
140
+ body: item.body || item.projectName || item.sessionId || '',
141
+ tag: item.id,
142
+ });
143
+ n.onclick = () => {
144
+ window.focus();
145
+ n.close();
146
+ };
147
+ } catch {
148
+ // ignore
149
+ }
150
+ }
151
+
152
+ export function pushNotification(item) {
153
+ if (!item?.id) return;
154
+ const existing = notifications.find(row => row.id === item.id);
155
+ if (existing) {
156
+ mergeNotifications([item, ...notifications.filter(row => row.id !== item.id)]);
157
+ return;
158
+ }
159
+ mergeNotifications([item, ...notifications]);
160
+ maybeShowBrowserNotification(item);
161
+ }
162
+
163
+ export function initNotifications(opts = {}) {
164
+ openSession = typeof opts.openSession === 'function' ? opts.openSession : null;
165
+ renderNotifications();
166
+ refreshNotifications();
167
+
168
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
169
+ document.addEventListener('click', () => {
170
+ try { Notification.requestPermission().catch(() => {}); } catch {}
171
+ }, { once: true });
172
+ }
173
+
174
+ delegate.on('click', '#notifications-backdrop', (e) => {
175
+ e.preventDefault();
176
+ e.stopPropagation();
177
+ closeNotificationsMenu();
178
+ });
179
+
180
+ delegate.on('click', '#btn-notifications-read-all', async (e) => {
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+ await markNotificationsRead();
184
+ });
185
+
186
+ delegate.on('click', '[data-notification-id]', async (_, el) => {
187
+ const id = el.dataset.notificationId;
188
+ if (!id) return;
189
+ await markNotificationsRead([id]);
190
+ closeNotificationsMenu();
191
+ if (!openSession || !el.dataset.notificationSession) return;
192
+ openSession(
193
+ el.dataset.notificationSession,
194
+ el.dataset.notificationCwd || null,
195
+ null,
196
+ el.dataset.notificationAgent || 'claude',
197
+ el.dataset.notificationMachine || null,
198
+ );
199
+ });
200
+
201
+ document.addEventListener('click', (e) => {
202
+ const menu = $('notifications-menu');
203
+ const btn = $('btn-notifications');
204
+ const target = e.target;
205
+ if (!menu || menu.classList.contains('hidden')) return;
206
+ if (menu.contains(target) || btn?.contains(target)) return;
207
+ closeNotificationsMenu();
208
+ });
209
+
210
+ window.addEventListener('resize', () => {
211
+ if ($('notifications-menu') && !$('notifications-menu').classList.contains('hidden')) {
212
+ positionNotificationsMenu();
213
+ }
214
+ });
215
+ }
File without changes
package/public/pwa.js CHANGED
File without changes
package/public/router.js CHANGED
File without changes
File without changes
File without changes
@@ -176,7 +176,7 @@ export function updateDashboard(sessions) {
176
176
  }).join('');
177
177
 
178
178
  showSelectedDaySessions(sessions);
179
- updateRecentSessionsList(sessions);
179
+ updateRecentSessionsList();
180
180
  }
181
181
 
182
182
  export function showSelectedDaySessions(sessions) {
@@ -226,42 +226,62 @@ 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';
229
+ const RECENT_SESSIONS_KEY = 'recentSessionsV1';
230
230
 
231
- function recentDismissKey(s) {
231
+ function recentSessionKey(s) {
232
232
  return `${s.machineId || ''}:${s.agent || 'claude'}:${s.sessionId}`;
233
233
  }
234
234
 
235
- function dismissedRecentKeys() {
235
+ /**
236
+ * The quick-jump list is deliberately opt-in: it starts empty and is populated
237
+ * only after a session is opened. Unlike sessionsData, this survives machine
238
+ * switches and contains sessions from every edge machine.
239
+ */
240
+ export function getRecentSessions() {
236
241
  try {
237
- const saved = JSON.parse(localStorage.getItem(RECENT_DISMISSED_KEY) || '[]');
238
- return new Set(Array.isArray(saved) ? saved : []);
239
- } catch { return new Set(); }
242
+ const saved = JSON.parse(localStorage.getItem(RECENT_SESSIONS_KEY) || '[]');
243
+ return Array.isArray(saved)
244
+ ? saved.filter(s => s && s.sessionId)
245
+ : [];
246
+ } catch { return []; }
247
+ }
248
+
249
+ function saveRecentSessions(sessions) {
250
+ try { localStorage.setItem(RECENT_SESSIONS_KEY, JSON.stringify(sessions)); } catch { /* quota/private mode */ }
251
+ }
252
+
253
+ /** Add an explicitly opened session to the cross-machine quick-jump list. */
254
+ export function addRecentSession(session) {
255
+ if (!session?.sessionId) return;
256
+ const key = recentSessionKey(session);
257
+ const entry = { ...session, lastOpenedAt: Date.now() };
258
+ const recent = getRecentSessions().filter(s => recentSessionKey(s) !== key);
259
+ recent.unshift(entry);
260
+ saveRecentSessions(recent.slice(0, RECENT_MENU_LIMIT));
261
+ updateRecentSessionsList();
262
+ renderRecentSessionsMenu();
240
263
  }
241
264
 
242
265
  /** Remove an item from this device's quick-jump list; the actual session remains intact. */
243
266
  export function dismissRecentSession(session) {
244
267
  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());
268
+ const key = recentSessionKey(session);
269
+ saveRecentSessions(getRecentSessions().filter(s => recentSessionKey(s) !== key));
270
+ updateRecentSessionsList();
271
+ renderRecentSessionsMenu();
250
272
  }
251
273
 
252
- function sortRecentSessions(sessions, limit) {
253
- const dismissed = dismissedRecentKeys();
254
- return [...asSessionArray(sessions)]
255
- .filter(s => s && s.sessionId && !dismissed.has(recentDismissKey(s)))
256
- .sort((a, b) => (coerceTs(b.updatedAt) || 0) - (coerceTs(a.updatedAt) || 0))
274
+ function sortRecentSessions(limit) {
275
+ return getRecentSessions()
276
+ .sort((a, b) => (coerceTs(b.lastOpenedAt) || 0) - (coerceTs(a.lastOpenedAt) || 0))
257
277
  .slice(0, limit);
258
278
  }
259
279
 
260
- export function updateRecentSessionsList(sessions) {
280
+ export function updateRecentSessionsList() {
261
281
  const container = $('recent-sessions-list');
262
282
  if (!container) return;
263
283
 
264
- const recent = sortRecentSessions(sessions, 6);
284
+ const recent = sortRecentSessions(6);
265
285
 
266
286
  if (!recent.length) {
267
287
  container.innerHTML = `<div class="col-span-full text-center text-xs text-base-content/40 py-4">No recent sessions</div>`;
@@ -338,31 +358,31 @@ export function positionRecentMenu() {
338
358
  menu.style.maxHeight = `${Math.min(maxH, Math.round(window.innerHeight * 0.7))}px`;
339
359
  }
340
360
 
341
- export function openRecentMenu(sessions) {
361
+ export function openRecentMenu() {
342
362
  const menu = $('recent-menu');
343
363
  if (!menu) return;
344
- renderRecentSessionsMenu(sessions ?? getDashboardSessions());
364
+ renderRecentSessionsMenu();
345
365
  menu.classList.remove('hidden');
346
366
  menu.classList.add('recent-menu-open');
347
367
  positionRecentMenu();
348
368
  $('recent-menu-backdrop')?.classList.remove('hidden');
349
369
  }
350
370
 
351
- export function toggleRecentMenu(sessions) {
371
+ export function toggleRecentMenu() {
352
372
  const menu = $('recent-menu');
353
373
  if (!menu) return;
354
- if (menu.classList.contains('hidden')) openRecentMenu(sessions);
374
+ if (menu.classList.contains('hidden')) openRecentMenu();
355
375
  else closeRecentMenu();
356
376
  }
357
377
 
358
- /** Topbar quick-jump: last N sessions by updatedAt. */
359
- export function renderRecentSessionsMenu(sessions) {
378
+ /** Topbar quick-jump: the last N sessions explicitly opened by this user. */
379
+ export function renderRecentSessionsMenu() {
360
380
  const listEl = $('recent-menu-list');
361
381
  const emptyEl = $('recent-menu-empty');
362
382
  const countEl = $('recent-menu-count');
363
383
  if (!listEl) return;
364
384
 
365
- const recent = sortRecentSessions(sessions ?? getDashboardSessions(), RECENT_MENU_LIMIT);
385
+ const recent = sortRecentSessions(RECENT_MENU_LIMIT);
366
386
  if (countEl) countEl.textContent = recent.length ? `${recent.length}` : '';
367
387
 
368
388
  if (!recent.length) {
File without changes
@@ -2,7 +2,7 @@
2
2
  import { batch, esc, $ } from '../lib.js';
3
3
  import {
4
4
  workspacesData, sessionFilter, filesPath, viewingFile, currentProject,
5
- filesRoot, gitRoot, currentModel, currentAgent, setAgent,
5
+ filesRoot, gitRoot, currentModel, currentAgent, setAgent, setCurrentModel,
6
6
  hubMode, machinesList, selectedMachineId, setSelectedMachine,
7
7
  getDefaultModel, AGENT_LABELS, ctx,
8
8
  } from '../state.js';
@@ -204,8 +204,7 @@ export async function startNewSession() {
204
204
  ctx.configDir = configDir || null;
205
205
  ctx.agent = agent;
206
206
  setAgent(agent);
207
- currentModel.value = getDefaultModel(agent) || currentModel.peek();
208
- localStorage.setItem('model', currentModel.peek());
207
+ setCurrentModel(getDefaultModel(agent) || currentModel.peek());
209
208
  refreshModelSelect();
210
209
  batch(() => {
211
210
  currentProject.value = { id: name, name, path: result.path, machineId: selectedMachineId.peek() || ctx.machineId };
File without changes
File without changes
@@ -8,7 +8,7 @@ import {
8
8
  sessionMetaMap, LOW_TURN_THRESHOLD, metaKey, sessionDisplayTitle,
9
9
  hubMode, selectedMachineId, agentsMeta, currentEffort,
10
10
  getModelsForAgent, getDefaultModel, getEffortsForModel,
11
- addCustomModel, removeCustomModel, getCustomModels, ctx,
11
+ addCustomModel, removeCustomModel, getCustomModels, setCurrentModel, ctx,
12
12
  } from '../state.js';
13
13
  import { api, NOT_MODIFIED } from '../api.js';
14
14
  import { getCachedSessions, setCachedSessions, getCachedWorkspaces, setCachedWorkspaces } from '../cache.js';
@@ -158,15 +158,8 @@ export async function loadAgentsMeta() {
158
158
  try {
159
159
  const data = await api('GET', '/api/agents');
160
160
  agentsMeta.value = data;
161
- // Align current model with discovered list for active agent
162
- const agent = currentAgent.peek() || 'claude';
163
- const models = getModelsForAgent(agent);
164
- const cur = currentModel.peek();
165
- if (cur && !models.some(m => m.value === cur)) {
166
- const def = getDefaultModel(agent);
167
- currentModel.value = def;
168
- localStorage.setItem('model', def);
169
- }
161
+ // Do not replace a user's saved model merely because discovery has not
162
+ // listed it yet. refreshModelSelect renders it as a custom entry instead.
170
163
  refreshModelSelect();
171
164
  return data;
172
165
  } catch (e) {
@@ -210,8 +203,7 @@ export function refreshModelSelect() {
210
203
  ).join('');
211
204
  if (!models.some(m => m.value === cur)) {
212
205
  const def = getDefaultModel(agent) || models[0]?.value;
213
- currentModel.value = def;
214
- localStorage.setItem('model', def);
206
+ setCurrentModel(def);
215
207
  sel.value = def;
216
208
  cur = def;
217
209
  } else {
@@ -241,8 +233,7 @@ export function promptCustomModel({ editCurrent = false } = {}) {
241
233
  removeCustomModel(agent, initial);
242
234
  }
243
235
  addCustomModel(agent, id);
244
- currentModel.value = id;
245
- localStorage.setItem('model', id);
236
+ setCurrentModel(id);
246
237
  refreshModelSelect();
247
238
  }
248
239
 
@@ -15,7 +15,7 @@ import {
15
15
  import { setHash, syncHash } from '../router.js';
16
16
  import { refreshModelSelect } from './session-list.js';
17
17
  import { renderSessionNotesBar } from './notes.js';
18
- import { updateDashboard, getDashboardSessions } from './dashboard.js';
18
+ import { addRecentSession, updateDashboard, getDashboardSessions } from './dashboard.js';
19
19
 
20
20
  /** Show which edge machine Files/Git/Shell paths belong to. */
21
21
  function syncMachinePathHints(machineId) {
@@ -137,6 +137,10 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
137
137
  setSelectedMachine(resolvedMachine);
138
138
  try { ctx.ws?.close(); } catch {}
139
139
  ctx.ws = null;
140
+ // A recent entry can belong to another edge. Route the session request
141
+ // immediately, then refresh the surrounding machine UI in the background.
142
+ import('./hub.js').then(({ syncTopbarMachine }) => syncTopbarMachine()).catch(() => {});
143
+ import('./session-list.js').then(({ loadAllSessions }) => loadAllSessions()).catch(() => {});
140
144
  }
141
145
 
142
146
  ctx.sessionId = sid;
@@ -179,6 +183,19 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
179
183
  machineId: resolvedMachine,
180
184
  tab: targetTab,
181
185
  });
186
+ // Recent is a deliberate browsing history, not an automatic list of every
187
+ // session on the currently selected machine. Keep the target machine in
188
+ // the entry so a later click can route back to the right edge.
189
+ addRecentSession({
190
+ sessionId: sid,
191
+ cwd: edgeCwd || resolvedCwd,
192
+ configDir: resolvedConfig,
193
+ agent: resolvedAgent,
194
+ machineId: resolvedMachine,
195
+ display: fromList?.display || fromLast?.display || '',
196
+ projectName: fromList?.projectName || fromLast?.projectName || '',
197
+ updatedAt: fromList?.updatedAt || fromLast?.updatedAt || null,
198
+ });
182
199
 
183
200
  const label = AGENT_LABELS[resolvedAgent] || resolvedAgent;
184
201
  const cacheKey = `${resolvedMachine || ''}:${resolvedAgent}:${sid}`;
@@ -69,6 +69,13 @@ export const AppShell = (opts = {}) => {
69
69
  <span class="text-[10px] opacity-50">▾</span>
70
70
  </button>
71
71
  </div>
72
+ <div id="notifications-wrap" class="relative flex-shrink-0">
73
+ <button type="button" id="btn-notifications" class="btn btn-ghost btn-xs border border-base-300 gap-1 min-h-8 px-2"
74
+ title="通知" aria-haspopup="dialog" aria-expanded="false">
75
+ <span>🔔</span>
76
+ <span id="notifications-badge" class="hidden inline-flex min-w-5 h-5 px-1 items-center justify-center rounded-full bg-error text-error-content text-[10px] font-bold">0</span>
77
+ </button>
78
+ </div>
72
79
  <span id="topbar-project" class="text-sm text-base-content/50 flex-1 truncate min-w-0">Select a session</span>
73
80
  <span id="conn-dot" class="w-2 h-2 rounded-full bg-base-content/30 flex-shrink-0" title="连接状态"></span>
74
81
  <div class="flex gap-2 topbar-actions">
@@ -93,6 +100,17 @@ export const AppShell = (opts = {}) => {
93
100
  <div id="recent-menu-list" class="flex flex-col gap-0.5"></div>
94
101
  <p id="recent-menu-empty" class="text-xs text-base-content/40 px-1 py-2 hidden">暂无最近会话</p>
95
102
  </div>
103
+ <div id="notifications-backdrop" class="hidden fixed inset-0 z-[55] bg-black/40" aria-hidden="true"></div>
104
+ <div id="notifications-menu" class="hidden fixed z-[60] w-[min(26rem,calc(100vw-1rem))] max-h-[min(30rem,75vh)] overflow-y-auto
105
+ rounded-lg border border-base-300 bg-base-100 shadow-xl p-2"
106
+ role="dialog" aria-label="通知中心">
107
+ <div class="flex items-center justify-between px-1 pb-1.5 mb-1 border-b border-base-300 sticky top-0 bg-base-100 z-[1]">
108
+ <span class="text-[10px] uppercase tracking-wide text-base-content/45 font-semibold">通知</span>
109
+ <button type="button" id="btn-notifications-read-all" class="btn btn-ghost btn-xs px-1 text-[10px]">全部已读</button>
110
+ </div>
111
+ <div id="notifications-menu-list" class="flex flex-col gap-1"></div>
112
+ <p id="notifications-menu-empty" class="text-xs text-base-content/40 px-1 py-2 hidden">暂无通知</p>
113
+ </div>
96
114
  <!-- Hub: only when no remembered machine (or last one offline) -->
97
115
  <div id="machine-picker" class="hidden fixed inset-0 z-[60] bg-base-100 flex items-center justify-center p-4">
98
116
  <div class="w-full max-w-md">
File without changes