claude-simple 0.2.6 → 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 (60) hide show
  1. package/README.md +2 -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.8.min.css +1598 -0
  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 +245 -1
  9. package/public/api.js +4 -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 +118 -17
  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 +2 -2
  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 +72 -23
  31. package/public/shell/hub.js +0 -0
  32. package/public/shell/layout.js +41 -12
  33. package/public/shell/notes.js +0 -0
  34. package/public/shell/session-context.js +0 -0
  35. package/public/shell/session-list.js +27 -28
  36. package/public/shell/session-nav.js +36 -1
  37. package/public/shell/templates.js +45 -7
  38. package/public/shell/util.js +0 -0
  39. package/public/shell.js +56 -7
  40. package/public/state.js +53 -13
  41. package/public/style.css +100 -6
  42. package/public/sw.js +2 -2
  43. package/public/terminal.js +77 -3
  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 +1 -1
  48. package/src/agents/codex.js +1 -0
  49. package/src/agents/grok.js +180 -298
  50. package/src/agents/meta-agent.js +332 -1
  51. package/src/agents/models.js +0 -0
  52. package/src/agents/sessions.js +106 -26
  53. package/src/agents/stream-batch.js +0 -0
  54. package/src/app.js +88 -11
  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.6.min.js +0 -953
  60. package/dist/style.0.2.6.min.css +0 -1504
@@ -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,19 +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_SESSIONS_KEY = 'recentSessionsV1';
229
230
 
230
- function sortRecentSessions(sessions, limit) {
231
- return [...asSessionArray(sessions)]
232
- .filter(s => s && s.sessionId)
233
- .sort((a, b) => (coerceTs(b.updatedAt) || 0) - (coerceTs(a.updatedAt) || 0))
231
+ function recentSessionKey(s) {
232
+ return `${s.machineId || ''}:${s.agent || 'claude'}:${s.sessionId}`;
233
+ }
234
+
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() {
241
+ try {
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();
263
+ }
264
+
265
+ /** Remove an item from this device's quick-jump list; the actual session remains intact. */
266
+ export function dismissRecentSession(session) {
267
+ if (!session?.sessionId) return;
268
+ const key = recentSessionKey(session);
269
+ saveRecentSessions(getRecentSessions().filter(s => recentSessionKey(s) !== key));
270
+ updateRecentSessionsList();
271
+ renderRecentSessionsMenu();
272
+ }
273
+
274
+ function sortRecentSessions(limit) {
275
+ return getRecentSessions()
276
+ .sort((a, b) => (coerceTs(b.lastOpenedAt) || 0) - (coerceTs(a.lastOpenedAt) || 0))
234
277
  .slice(0, limit);
235
278
  }
236
279
 
237
- export function updateRecentSessionsList(sessions) {
280
+ export function updateRecentSessionsList() {
238
281
  const container = $('recent-sessions-list');
239
282
  if (!container) return;
240
283
 
241
- const recent = sortRecentSessions(sessions, 6);
284
+ const recent = sortRecentSessions(6);
242
285
 
243
286
  if (!recent.length) {
244
287
  container.innerHTML = `<div class="col-span-full text-center text-xs text-base-content/40 py-4">No recent sessions</div>`;
@@ -315,31 +358,31 @@ export function positionRecentMenu() {
315
358
  menu.style.maxHeight = `${Math.min(maxH, Math.round(window.innerHeight * 0.7))}px`;
316
359
  }
317
360
 
318
- export function openRecentMenu(sessions) {
361
+ export function openRecentMenu() {
319
362
  const menu = $('recent-menu');
320
363
  if (!menu) return;
321
- renderRecentSessionsMenu(sessions ?? getDashboardSessions());
364
+ renderRecentSessionsMenu();
322
365
  menu.classList.remove('hidden');
323
366
  menu.classList.add('recent-menu-open');
324
367
  positionRecentMenu();
325
368
  $('recent-menu-backdrop')?.classList.remove('hidden');
326
369
  }
327
370
 
328
- export function toggleRecentMenu(sessions) {
371
+ export function toggleRecentMenu() {
329
372
  const menu = $('recent-menu');
330
373
  if (!menu) return;
331
- if (menu.classList.contains('hidden')) openRecentMenu(sessions);
374
+ if (menu.classList.contains('hidden')) openRecentMenu();
332
375
  else closeRecentMenu();
333
376
  }
334
377
 
335
- /** Topbar quick-jump: last N sessions by updatedAt. */
336
- export function renderRecentSessionsMenu(sessions) {
378
+ /** Topbar quick-jump: the last N sessions explicitly opened by this user. */
379
+ export function renderRecentSessionsMenu() {
337
380
  const listEl = $('recent-menu-list');
338
381
  const emptyEl = $('recent-menu-empty');
339
382
  const countEl = $('recent-menu-count');
340
383
  if (!listEl) return;
341
384
 
342
- const recent = sortRecentSessions(sessions ?? getDashboardSessions(), RECENT_MENU_LIMIT);
385
+ const recent = sortRecentSessions(RECENT_MENU_LIMIT);
343
386
  if (countEl) countEl.textContent = recent.length ? `${recent.length}` : '';
344
387
 
345
388
  if (!recent.length) {
@@ -357,14 +400,14 @@ export function renderRecentSessionsMenu(sessions) {
357
400
  ? `<span class="text-[9px] font-mono text-base-content/35 truncate max-w-[4.5rem]" title="${esc(s.machineId)}">@${esc(s.machineId)}</span>`
358
401
  : '';
359
402
  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 || '')}">
403
+ <div class="recent-menu-item w-full text-left px-2 py-1.5 rounded-md flex items-start gap-2
404
+ ${active ? 'bg-primary/15 text-primary' : 'hover:bg-base-200'}">
405
+ <button type="button" class="flex min-w-0 flex-1 items-start gap-2 text-left" aria-label="打开 ${esc(sessionDisplayTitle(s))}"
406
+ data-session-id="${esc(s.sessionId)}"
407
+ data-session-cwd="${esc(s.cwd || '')}"
408
+ data-session-config-dir="${esc(s.configDir || '')}"
409
+ data-session-agent="${esc(s.agent || 'claude')}"
410
+ data-session-machine="${esc(s.machineId || '')}">
368
411
  <span class="flex-shrink-0 mt-0.5">${agentBadge(s.agent)}</span>
369
412
  <span class="flex-1 min-w-0">
370
413
  <span class="block text-xs font-semibold truncate leading-snug">${esc(sessionDisplayTitle(s))}</span>
@@ -375,7 +418,13 @@ export function renderRecentSessionsMenu(sessions) {
375
418
  ${machine}
376
419
  ${active ? '<span class="text-[9px] opacity-70">当前</span>' : ''}
377
420
  </span>
378
- </button>`;
421
+ </button>
422
+ <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"
423
+ title="从最近会话移除" aria-label="从最近会话移除 ${esc(sessionDisplayTitle(s))}"
424
+ data-dismiss-recent-session="${esc(s.sessionId)}"
425
+ data-dismiss-recent-agent="${esc(s.agent || 'claude')}"
426
+ data-dismiss-recent-machine="${esc(s.machineId || '')}">×</button>
427
+ </div>`;
379
428
  }).join('');
380
429
  }
381
430
 
File without changes
@@ -2,13 +2,13 @@
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';
9
9
  // filesRoot/gitRoot used when starting a new session (default paths)
10
10
  import { api } from '../api.js';
11
- import { connectWS, clearMessages, appendSystemMsg, applyChatDensity } from '../chat.js';
11
+ import { connectWS, clearMessages, appendSystemMsg, applyChatDensity, stashComposerDraft, restoreComposerDraft, discardComposerAttachments, parkQueuedMessages, detachProcessingForSessionSwitch } from '../chat.js';
12
12
  import { refreshModelSelect } from './session-list.js';
13
13
  import { switchTab } from './session-nav.js';
14
14
  import { showMachinePicker } from './hub.js';
@@ -16,27 +16,37 @@ import { syncFocusScope } from '../mobile.js';
16
16
 
17
17
  // ── Sidebar ───────────────────────────────────────────────────────────────────
18
18
  export function openSidebar() {
19
- $('sidebar').classList.add('open');
19
+ const sidebar = $('sidebar');
20
+ if (!sidebar) return;
21
+ sidebar.classList.add('open');
20
22
  $('sidebar-overlay').classList.remove('hidden');
23
+ $('hamburger')?.setAttribute('aria-expanded', 'true');
21
24
  syncFocusScope();
25
+ // Put the next action where it belongs. Delaying lets the drawer transition
26
+ // start first, which is more reliable in mobile Safari.
27
+ setTimeout(() => $('session-search')?.focus({ preventScroll: true }), 180);
22
28
  }
23
29
  export function closeSidebar() {
24
- $('sidebar').classList.remove('open');
30
+ const sidebar = $('sidebar');
31
+ if (!sidebar) return;
32
+ sidebar.classList.remove('open');
25
33
  $('sidebar-overlay').classList.add('hidden');
34
+ $('hamburger')?.setAttribute('aria-expanded', 'false');
26
35
  syncFocusScope();
36
+ $('hamburger')?.focus({ preventScroll: true });
27
37
  }
28
38
  export function closeSidebarOnMobile() { if (window.innerWidth <= 640) closeSidebar(); }
29
39
 
30
40
  export function initSidebarToggle() {
31
41
  const btn = $('sidebar-toggle');
32
42
  const sidebar = $('sidebar');
33
- let collapsed = false;
34
- let savedWidth = sidebar.style.width || '260px';
43
+ if (!btn || !sidebar) return;
44
+ let collapsed = localStorage.getItem('sidebarCollapsed') === '1';
45
+ let savedWidth = localStorage.getItem('sidebarWidth') || sidebar.style.width || '280px';
35
46
 
36
- btn.addEventListener('click', () => {
37
- collapsed = !collapsed;
47
+ const applyCollapsed = () => {
48
+ sidebar.classList.toggle('desktop-collapsed', collapsed);
38
49
  if (collapsed) {
39
- savedWidth = sidebar.style.width || '260px';
40
50
  sidebar.style.width = '0';
41
51
  sidebar.style.minWidth = '0';
42
52
  sidebar.style.overflow = 'hidden';
@@ -46,6 +56,17 @@ export function initSidebarToggle() {
46
56
  sidebar.style.overflow = '';
47
57
  }
48
58
  btn.textContent = collapsed ? '▶' : '◀';
59
+ btn.title = collapsed ? '展开侧栏' : '收起侧栏';
60
+ btn.setAttribute('aria-label', btn.title);
61
+ btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
62
+ };
63
+ applyCollapsed();
64
+
65
+ btn.addEventListener('click', () => {
66
+ collapsed = !collapsed;
67
+ if (collapsed) savedWidth = sidebar.style.width || savedWidth;
68
+ localStorage.setItem('sidebarCollapsed', collapsed ? '1' : '0');
69
+ applyCollapsed();
49
70
  });
50
71
  }
51
72
 
@@ -71,6 +92,8 @@ export function initSidebarResize() {
71
92
  dragging = false;
72
93
  document.body.style.userSelect = '';
73
94
  document.body.style.cursor = '';
95
+ const width = Math.round(sidebar.getBoundingClientRect().width);
96
+ if (width >= 160) localStorage.setItem('sidebarWidth', `${width}px`);
74
97
  });
75
98
  }
76
99
 
@@ -79,7 +102,9 @@ export function applyTheme(theme) {
79
102
  document.documentElement.setAttribute('data-theme', theme);
80
103
  localStorage.setItem('theme', theme);
81
104
  const btn = $('btn-theme');
82
- if (btn) btn.textContent = theme === 'dark' || theme === 'night' || theme === 'black' ? '🌙' : '☀️';
105
+ const icon = theme === 'dark' || theme === 'night' || theme === 'black' ? '🌙' : '☀️';
106
+ if (btn) btn.textContent = icon;
107
+ document.querySelectorAll('[data-sidebar-action="theme"]').forEach(el => { el.textContent = icon; });
83
108
  // Swap highlight.js stylesheet for light/dark
84
109
  let link = document.getElementById('hljs-theme');
85
110
  if (!link) {
@@ -171,12 +196,15 @@ export async function startNewSession() {
171
196
  $('modal-new-session').close();
172
197
 
173
198
  const name = result.path.split('/').filter(Boolean).pop() || result.path;
199
+ stashComposerDraft();
200
+ parkQueuedMessages();
201
+ discardComposerAttachments();
202
+ detachProcessingForSessionSwitch();
174
203
  ctx.sessionId = null;
175
204
  ctx.configDir = configDir || null;
176
205
  ctx.agent = agent;
177
206
  setAgent(agent);
178
- currentModel.value = getDefaultModel(agent) || currentModel.peek();
179
- localStorage.setItem('model', currentModel.peek());
207
+ setCurrentModel(getDefaultModel(agent) || currentModel.peek());
180
208
  refreshModelSelect();
181
209
  batch(() => {
182
210
  currentProject.value = { id: name, name, path: result.path, machineId: selectedMachineId.peek() || ctx.machineId };
@@ -194,6 +222,7 @@ export async function startNewSession() {
194
222
  pv.classList.remove('hidden');
195
223
  pv.style.display = 'flex';
196
224
  clearMessages();
225
+ restoreComposerDraft({ sessionId: null });
197
226
  connectWS();
198
227
  switchTab('chat');
199
228
  appendSystemMsg(`New ${AGENT_LABELS[agent] || agent} session · ${result.path}`);
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
 
@@ -430,7 +421,7 @@ export function mdToHtml(text) {
430
421
  }
431
422
  }
432
423
 
433
- // Load the persistent memory for the currently open session into the Memory tab.
424
+ // Memory is shown as an agent-specific file list. Content is loaded on click.
434
425
  export async function loadMemoryTab() {
435
426
  const body = $('memory-area');
436
427
  if (!body) return;
@@ -440,22 +431,30 @@ export async function loadMemoryTab() {
440
431
  }
441
432
  body.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
442
433
  try {
443
- const q = new URLSearchParams({ agent: ctx.agent || currentAgent.peek() || 'claude' });
434
+ const agent = ctx.agent || currentAgent.peek() || 'claude';
435
+ const q = new URLSearchParams({ agent });
444
436
  const mem = await api('GET', `/api/sessions/${ctx.sessionId}/memory?${q}`);
445
- const parts = [];
446
- if (mem.index) {
447
- parts.push(`<div class="mb-4"><div class="text-[11px] uppercase tracking-wide text-base-content/40 mb-1">Index (MEMORY.md)</div>
448
- <div class="md">${mdToHtml(mem.index)}</div></div>`);
449
- }
450
- for (const f of (mem.files || [])) {
451
- parts.push(`<div class="mb-3 border border-base-300 rounded-lg overflow-hidden">
452
- <div class="px-3 py-1.5 bg-base-300/50 text-xs font-mono text-base-content/70">${esc(f.name)}</div>
453
- <div class="px-3 py-2 md">${mdToHtml(f.content)}</div>
454
- </div>`);
437
+ const files = mem.files || [];
438
+ if (!files.length) {
439
+ body.innerHTML = '<div class="p-4 text-base-content/40 text-xs">No memory files available for this agent/session.</div>';
440
+ return;
455
441
  }
456
- if (!parts.length) parts.push('<div class="text-base-content/40 text-xs">No memory saved for this project yet.</div>');
457
- body.innerHTML = `<div class="p-4">${parts.join('')}</div>`;
442
+ body.innerHTML = `<div class="p-3 text-[11px] text-base-content/45">${esc(agent)} · ${files.length} memory file${files.length === 1 ? '' : 's'} · click to open</div>
443
+ <div class="px-3 pb-4 flex flex-col gap-1">${files.map(f => `<button type="button" class="memory-file text-left px-3 py-2 rounded-lg border border-base-300 hover:border-primary hover:bg-primary/5" data-memory-file="${esc(f.id)}" data-memory-agent="${esc(agent)}"><span class="font-mono text-xs">${esc(f.name)}</span><span class="ml-2 text-[10px] text-base-content/40">${esc(f.scope || '')}</span></button>`).join('')}<div id="memory-file-content"></div></div>`;
458
444
  } catch (e) {
459
445
  body.innerHTML = `<div class="p-4 text-error text-xs">${esc(e?.message ?? String(e))}</div>`;
460
446
  }
461
447
  }
448
+
449
+ export async function openMemoryFile(fileId, agent) {
450
+ const out = $('memory-file-content');
451
+ if (!out || !ctx.sessionId || !fileId) return;
452
+ out.innerHTML = '<div class="p-3 text-xs text-base-content/40">Loading file…</div>';
453
+ try {
454
+ const q = new URLSearchParams({ agent: agent || ctx.agent || currentAgent.peek() || 'claude', file: fileId });
455
+ const row = await api('GET', `/api/sessions/${ctx.sessionId}/memory/file?${q}`);
456
+ out.innerHTML = `<div class="mt-2 border border-base-300 rounded-lg overflow-hidden"><div class="px-3 py-1.5 bg-base-300/50 text-xs font-mono">${esc(row.name)}</div><div class="px-3 py-2 md">${mdToHtml(row.content)}</div></div>`;
457
+ } catch (e) {
458
+ out.innerHTML = `<div class="mt-2 p-3 text-error text-xs">${esc(e?.message ?? String(e))}</div>`;
459
+ }
460
+ }