claude-simple 0.1.0 → 0.2.1

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 (46) hide show
  1. package/client.js +31 -2
  2. package/dist/app.0.2.1.min.js +868 -0
  3. package/dist/highlight.min.js +448 -430
  4. package/dist/index.html +2 -2
  5. package/dist/style.0.2.1.min.css +1182 -0
  6. package/package.json +1 -1
  7. package/public/agent-panel.js +44 -0
  8. package/public/api.js +61 -10
  9. package/public/app.js +4 -0
  10. package/public/chat.js +338 -31
  11. package/public/files.js +22 -30
  12. package/public/git.js +21 -17
  13. package/public/icons/icon-180.png +0 -0
  14. package/public/icons/icon-192.png +0 -0
  15. package/public/icons/icon-512.png +0 -0
  16. package/public/icons/icon-maskable-512.png +0 -0
  17. package/public/index.html +16 -3
  18. package/public/lib.js +7 -2
  19. package/public/manifest.webmanifest +36 -0
  20. package/public/offline.html +33 -0
  21. package/public/pwa.js +78 -0
  22. package/public/shell/boot.js +3 -0
  23. package/public/shell/hub.js +4 -2
  24. package/public/shell/notes.js +93 -31
  25. package/public/shell/session-list.js +29 -10
  26. package/public/shell/session-nav.js +7 -2
  27. package/public/shell/templates.js +11 -3
  28. package/public/shell.js +101 -8
  29. package/public/state.js +13 -2
  30. package/public/style.css +135 -3
  31. package/public/sw.js +104 -0
  32. package/public/terminal.js +22 -0
  33. package/public/wake-lock.js +45 -0
  34. package/server.js +5 -1
  35. package/src/agents/claude.js +108 -10
  36. package/src/agents/codex.js +1 -1
  37. package/src/agents/grok.js +40 -32
  38. package/src/agents/meta-agent.js +40 -1
  39. package/src/agents/stream-batch.js +62 -0
  40. package/src/app.js +312 -41
  41. package/src/auto-update.js +215 -0
  42. package/src/runtime/approvals.js +67 -0
  43. package/src/runtime/outbox.js +143 -0
  44. package/src/runtime/protocol.js +224 -0
  45. package/dist/app.0.1.0.min.js +0 -825
  46. package/dist/style.0.1.0.min.css +0 -1050
package/public/files.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // files.js — Files tab with upload / download / delete + text/office preview
2
- import { effect, delegate, esc, $ } from './lib.js';
2
+ import { effect, asyncEffect, delegate, esc, $ } from './lib.js';
3
3
  import { currentProject, currentTab, filesRoot, filesPath, viewingFile, ctx } from './state.js';
4
4
  import { api, authHeaders, authQuery } from './api.js';
5
5
 
@@ -407,7 +407,7 @@ export function initFilesTab() {
407
407
  ${!isFile ? `<button id="btn-upload" class="btn btn-ghost btn-xs text-base-content/50 hover:text-base-content">↑ Upload</button>` : ''}`;
408
408
  });
409
409
 
410
- effect(() => {
410
+ asyncEffect(async signal => {
411
411
  const tab = currentTab.value;
412
412
  const proj = currentProject.value;
413
413
  // Prefer explicit filesRoot; else session cwd on the selected machine
@@ -416,40 +416,32 @@ export function initFilesTab() {
416
416
  const dir = filesPath.value;
417
417
  const area = $('files-area');
418
418
  if (!area) return;
419
- const ctrl = new AbortController();
420
- if (tab !== 'files') { return () => ctrl.abort(); }
419
+ if (tab !== 'files') return;
421
420
  if (!root) {
422
421
  area.innerHTML = '<div class="p-4 text-xs text-base-content/40">No path yet — open a session (uses that machine&apos;s cwd) or type an absolute path and press Go.</div>';
423
- return () => ctrl.abort();
422
+ return;
424
423
  }
425
424
  const id = proj?.id || '_';
426
425
  area.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
427
- if (file !== null) {
428
- api('GET', `/api/projects/${id}/file?root=${encodeURIComponent(root)}&path=${encodeURIComponent(file)}`, undefined, ctrl.signal)
429
- .then(d => { if (!ctrl.signal.aborted) return showFile(area, file, d, ctrl.signal); })
430
- .catch(e => {
431
- if (!ctrl.signal.aborted && e?.name !== 'AbortError') {
432
- area.innerHTML = `<div class="p-4 text-xs text-error">${esc(e.message)}</div>`;
433
- }
434
- });
435
- } else {
436
- api('GET', `/api/projects/${id}/files?root=${encodeURIComponent(root)}&path=${encodeURIComponent(dir)}`, undefined, ctrl.signal)
437
- .then(d => {
438
- if (ctrl.signal.aborted) return;
439
- const list = Array.isArray(d) ? d : (Array.isArray(d?.files) ? d.files : null);
440
- if (!list) {
441
- area.innerHTML = `<div class="p-4 text-xs text-error">${esc(d?.error || 'Invalid directory listing')}</div>`;
442
- return;
443
- }
444
- area.innerHTML = renderDir(list);
445
- })
446
- .catch(e => {
447
- if (!ctrl.signal.aborted && e?.name !== 'AbortError') {
448
- area.innerHTML = `<div class="p-4 text-xs text-error">${esc(e.message)}</div>`;
449
- }
450
- });
426
+ try {
427
+ if (file !== null) {
428
+ const data = await api('GET', `/api/projects/${id}/file?root=${encodeURIComponent(root)}&path=${encodeURIComponent(file)}`, undefined, signal);
429
+ if (!signal.aborted) await showFile(area, file, data, signal);
430
+ return;
431
+ }
432
+ const data = await api('GET', `/api/projects/${id}/files?root=${encodeURIComponent(root)}&path=${encodeURIComponent(dir)}`, undefined, signal);
433
+ if (signal.aborted) return;
434
+ const list = Array.isArray(data) ? data : (Array.isArray(data?.files) ? data.files : null);
435
+ if (!list) {
436
+ area.innerHTML = `<div class="p-4 text-xs text-error">${esc(data?.error || 'Invalid directory listing')}</div>`;
437
+ return;
438
+ }
439
+ area.innerHTML = renderDir(list);
440
+ } catch (e) {
441
+ if (!signal.aborted && e?.name !== 'AbortError') {
442
+ area.innerHTML = `<div class="p-4 text-xs text-error">${esc(e.message)}</div>`;
443
+ }
451
444
  }
452
- return () => ctrl.abort();
453
445
  });
454
446
 
455
447
  delegate.on('click', '[data-dir-path]', (_, el) => { viewingFile.value = null; filesPath.value = el.dataset.dirPath; });
package/public/git.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // git.js — Git tab
2
- import { signal, effect, delegate, esc, $ } from './lib.js';
2
+ import { signal, asyncEffect, delegate, esc, $ } from './lib.js';
3
3
  import { currentProject, currentTab, gitRoot } from './state.js';
4
4
  import { api } from './api.js';
5
5
 
@@ -63,41 +63,45 @@ function renderDiff(diff) {
63
63
 
64
64
  export function initGitTab() {
65
65
  // Git status panel
66
- effect(() => {
66
+ asyncEffect(async signal => {
67
67
  const tab = currentTab.value;
68
68
  const proj = currentProject.value;
69
69
  const root = gitRoot.value || proj?.path;
70
70
  const area = $('git-area');
71
71
  if (!area) return;
72
- const ctrl = new AbortController();
73
- if (tab !== 'git') return () => ctrl.abort();
72
+ if (tab !== 'git') return;
74
73
  if (!root) {
75
74
  area.innerHTML = '<div class="p-4 text-sm text-base-content/50">Enter a project root above, or open a session with a working directory.</div>';
76
- return () => ctrl.abort();
75
+ return;
77
76
  }
78
77
  const id = proj?.id || '_';
79
78
  area.innerHTML = '<div class="p-4 text-xs text-base-content/40">Loading…</div>';
80
- api('GET', `/api/projects/${id}/git/status?root=${encodeURIComponent(root)}`, undefined, ctrl.signal)
81
- .then(d => { if (!ctrl.signal.aborted) area.innerHTML = gitRenderer(d); })
82
- .catch(e => { if (!ctrl.signal.aborted && e?.name !== 'AbortError')
83
- area.innerHTML = `<div class="p-4 text-xs text-error">${esc(e?.message ?? String(e))}</div>`; });
84
- return () => ctrl.abort();
79
+ try {
80
+ const data = await api('GET', `/api/projects/${id}/git/status?root=${encodeURIComponent(root)}`, undefined, signal);
81
+ if (!signal.aborted) area.innerHTML = gitRenderer(data);
82
+ } catch (e) {
83
+ if (!signal.aborted && e?.name !== 'AbortError') {
84
+ area.innerHTML = `<div class="p-4 text-xs text-error">${esc(e?.message ?? String(e))}</div>`;
85
+ }
86
+ }
85
87
  });
86
88
 
87
89
  // Diff panel
88
- effect(() => {
90
+ asyncEffect(async signal => {
89
91
  const proj = currentProject.value;
90
92
  const root = gitRoot.value || proj?.path;
91
93
  const staged = diffStaged.value;
92
94
  const target = $('diff-content');
93
95
  if (!target || !root) return;
94
96
  const id = proj?.id || '_';
95
- const ctrl = new AbortController();
96
- api('GET', `/api/projects/${id}/git/diff?root=${encodeURIComponent(root)}${staged ? '&staged=true' : ''}`, undefined, ctrl.signal)
97
- .then(d => { if (!ctrl.signal.aborted) target.innerHTML = d?.diff ? renderDiff(d.diff) : '<span class="text-base-content/40">No changes</span>'; })
98
- .catch(e => { if (!ctrl.signal.aborted && e?.name !== 'AbortError')
99
- target.innerHTML = `<span class="text-error">${esc(e?.message ?? String(e))}</span>`; });
100
- return () => ctrl.abort();
97
+ try {
98
+ const data = await api('GET', `/api/projects/${id}/git/diff?root=${encodeURIComponent(root)}${staged ? '&staged=true' : ''}`, undefined, signal);
99
+ if (!signal.aborted) target.innerHTML = data?.diff ? renderDiff(data.diff) : '<span class="text-base-content/40">No changes</span>';
100
+ } catch (e) {
101
+ if (!signal.aborted && e?.name !== 'AbortError') {
102
+ target.innerHTML = `<span class="text-error">${esc(e?.message ?? String(e))}</span>`;
103
+ }
104
+ }
101
105
  });
102
106
 
103
107
  delegate.on('click', '#diff-unstaged, #diff-staged', (_, el) => {
Binary file
Binary file
Binary file
package/public/index.html CHANGED
@@ -45,14 +45,27 @@
45
45
  display: none !important;
46
46
  }
47
47
  </style>
48
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
49
- <title>Claude Code</title>
48
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
49
+ <meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
50
+ <meta name="theme-color" content="#0f172a">
51
+ <meta name="color-scheme" content="dark light">
52
+ <meta name="mobile-web-app-capable" content="yes">
53
+ <meta name="apple-mobile-web-app-capable" content="yes">
54
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
55
+ <meta name="apple-mobile-web-app-title" content="Agent UI">
56
+ <meta name="application-name" content="Agent UI">
57
+ <meta name="description" content="Claude · Codex · Grok multi-machine agent console">
58
+ <link rel="manifest" href="/manifest.webmanifest">
59
+ <link rel="apple-touch-icon" href="/icons/icon-180.png">
60
+ <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
61
+ <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
62
+ <title>Agent UI</title>
50
63
  <link href="https://cdn.jsdelivr.net/npm/daisyui@5/daisyui.css" rel="stylesheet">
51
64
  <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
52
65
  <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
53
66
  <link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css">
54
67
  <script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
55
- <link rel="stylesheet" href="style.css?v=boot-2">
68
+ <link rel="stylesheet" href="style.css?v=safe-3">
56
69
  </head>
57
70
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
58
71
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
package/public/lib.js CHANGED
@@ -1,2 +1,7 @@
1
- export * from 'https://cdn.jsdelivr.net/gh/forechoandlook/mini-react@latest/dist/mini-react.min.js';
2
- // doc from https://cdn.jsdelivr.net/gh/forechoandlook/mini-react@latest/README.mini.md
1
+ // Pinned, not @latest: this file is served straight to production browsers by
2
+ // the live hub (see docs/deployment.md) with no build step in between, so an
3
+ // unpinned @latest here means every push to the mini-react repo — tested or
4
+ // not — goes live immediately. Bump deliberately after verifying against
5
+ // this app, not as a side effect of publishing a mini-react release.
6
+ export * from 'https://cdn.jsdelivr.net/gh/forechoandlook/mini-react@v0.1.13/dist/mini-react.min.js';
7
+ // doc from https://cdn.jsdelivr.net/gh/forechoandlook/mini-react@v0.1.13/README.mini.md
@@ -0,0 +1,36 @@
1
+ {
2
+ "id": "/",
3
+ "name": "Agent UI",
4
+ "short_name": "Agent UI",
5
+ "description": "Claude · Codex · Grok multi-machine agent console",
6
+ "start_url": "/?source=pwa",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "display_override": ["standalone", "minimal-ui", "browser"],
10
+ "orientation": "any",
11
+ "background_color": "#0f172a",
12
+ "theme_color": "#0f172a",
13
+ "lang": "zh-CN",
14
+ "dir": "ltr",
15
+ "categories": ["developer", "productivity", "utilities"],
16
+ "icons": [
17
+ {
18
+ "src": "/icons/icon-192.png",
19
+ "sizes": "192x192",
20
+ "type": "image/png",
21
+ "purpose": "any"
22
+ },
23
+ {
24
+ "src": "/icons/icon-512.png",
25
+ "sizes": "512x512",
26
+ "type": "image/png",
27
+ "purpose": "any"
28
+ },
29
+ {
30
+ "src": "/icons/icon-maskable-512.png",
31
+ "sizes": "512x512",
32
+ "type": "image/png",
33
+ "purpose": "maskable"
34
+ }
35
+ ]
36
+ }
@@ -0,0 +1,33 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="theme-color" content="#0f172a">
7
+ <title>离线 · Agent UI</title>
8
+ <style>
9
+ body {
10
+ margin: 0; min-height: 100dvh; display: flex; align-items: center; justify-content: center;
11
+ font-family: system-ui, -apple-system, sans-serif;
12
+ background: #0f172a; color: #e2e8f0; padding: 1.5rem; text-align: center;
13
+ }
14
+ .card {
15
+ max-width: 22rem; padding: 1.5rem; border-radius: 1rem;
16
+ background: #1e293b; border: 1px solid #334155;
17
+ }
18
+ h1 { font-size: 1.1rem; margin: 0 0 .5rem; }
19
+ p { font-size: .9rem; color: #94a3b8; line-height: 1.5; margin: 0 0 1rem; }
20
+ button {
21
+ appearance: none; border: 0; border-radius: .5rem; padding: .55rem 1rem;
22
+ background: #6366f1; color: #fff; font-weight: 600; font-size: .875rem; cursor: pointer;
23
+ }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div class="card">
28
+ <h1>当前无网络</h1>
29
+ <p>Agent UI 需要连接 Hub 才能聊天。恢复网络后点下方按钮重试。</p>
30
+ <button type="button" onclick="location.href='/'">重新加载</button>
31
+ </div>
32
+ </body>
33
+ </html>
package/public/pwa.js ADDED
@@ -0,0 +1,78 @@
1
+ // pwa.js — register service worker + optional install affordance
2
+ // Does not touch WebSocket; SW only caches static shell assets.
3
+
4
+ let deferredInstall = null;
5
+
6
+ function isStandalone() {
7
+ return (
8
+ window.matchMedia('(display-mode: standalone)').matches
9
+ || window.navigator.standalone === true
10
+ || document.referrer.includes('android-app://')
11
+ );
12
+ }
13
+
14
+ function showInstallButton() {
15
+ document.getElementById('btn-pwa-install')?.classList.remove('hidden');
16
+ }
17
+
18
+ export function initPwa() {
19
+ if (isStandalone()) {
20
+ document.documentElement.classList.add('pwa-standalone');
21
+ }
22
+
23
+ window.addEventListener('beforeinstallprompt', (e) => {
24
+ e.preventDefault();
25
+ deferredInstall = e;
26
+ document.dispatchEvent(new CustomEvent('pwa-installable'));
27
+ showInstallButton();
28
+ });
29
+
30
+ window.addEventListener('appinstalled', () => {
31
+ deferredInstall = null;
32
+ document.getElementById('btn-pwa-install')?.classList.add('hidden');
33
+ });
34
+
35
+ // iOS Safari has no beforeinstallprompt — still offer a tip button on mobile.
36
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
37
+ || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
38
+ if (!isStandalone() && (isIOS || /Android/i.test(navigator.userAgent))) {
39
+ // Defer until shell HTML exists
40
+ requestAnimationFrame(() => showInstallButton());
41
+ }
42
+
43
+ if (!('serviceWorker' in navigator)) return;
44
+
45
+ // Register after first paint so install doesn't compete with boot.
46
+ const register = () => {
47
+ navigator.serviceWorker.register('/sw.js', { scope: '/' })
48
+ .then((reg) => {
49
+ reg.addEventListener('updatefound', () => {
50
+ const sw = reg.installing;
51
+ if (!sw) return;
52
+ sw.addEventListener('statechange', () => {
53
+ if (sw.state === 'installed' && navigator.serviceWorker.controller) {
54
+ console.info('[pwa] New version ready — reload to apply');
55
+ }
56
+ });
57
+ });
58
+ })
59
+ .catch((err) => console.warn('[pwa] SW register failed', err));
60
+ };
61
+
62
+ if (document.readyState === 'complete') setTimeout(register, 0);
63
+ else window.addEventListener('load', () => setTimeout(register, 0));
64
+ }
65
+
66
+ /** Trigger native install sheet when available (Chrome/Edge Android, some desktop). */
67
+ export async function promptPwaInstall() {
68
+ if (!deferredInstall) return false;
69
+ deferredInstall.prompt();
70
+ const choice = await deferredInstall.userChoice.catch(() => null);
71
+ deferredInstall = null;
72
+ document.getElementById('btn-pwa-install')?.classList.add('hidden');
73
+ return choice?.outcome === 'accepted';
74
+ }
75
+
76
+ export function canInstallPwa() {
77
+ return !!deferredInstall;
78
+ }
@@ -8,6 +8,7 @@ import { getCachedSessions } from '../cache.js';
8
8
  import { getLastSessionContext } from './session-context.js';
9
9
  import { loadAllSessions, loadWorkspaces, loadAgentsMeta } from './session-list.js';
10
10
  import { loadSessionMetaMap } from './notes.js';
11
+ import { loadMetaAgentBoot } from '../agent-panel.js';
11
12
  import { resumeSession } from './session-nav.js';
12
13
  import {
13
14
  refreshMachinesList, hideMachinePicker, showMachinePicker,
@@ -59,6 +60,7 @@ export async function showApp(opts = {}) {
59
60
  setSelectedMachine(remembered);
60
61
  hideMachinePicker();
61
62
  hubMachineReady.value = true;
63
+ loadMetaAgentBoot();
62
64
  }
63
65
 
64
66
  // Machines list: refresh in background; only block picker if no remembered id
@@ -128,6 +130,7 @@ export async function showApp(opts = {}) {
128
130
  }
129
131
 
130
132
  // Standalone: cache-first sessions + live model lists, then deep link
133
+ loadMetaAgentBoot();
131
134
  const dataP = Promise.all([
132
135
  loadAllSessions({ waitFresh: false }),
133
136
  loadWorkspaces({ waitFresh: false }),
@@ -5,8 +5,9 @@ import {
5
5
  currentProject, sessionFilter, viewingFile, ctx,
6
6
  } from '../state.js';
7
7
  import { api, probeHub } from '../api.js';
8
- import { loadAllSessions, loadWorkspaces } from './session-list.js';
8
+ import { loadAllSessions, loadWorkspaces, loadAgentsMeta } from './session-list.js';
9
9
  import { loadSessionMetaMap } from './notes.js';
10
+ import { loadMetaAgentBoot } from '../agent-panel.js';
10
11
  // goHome imported dynamically in enterMachine to avoid circular deps
11
12
 
12
13
  export async function refreshMachinesList() {
@@ -186,7 +187,8 @@ export async function enterMachine(machineId, { forceReload = false } = {}) {
186
187
  bar.textContent = `Machine · ${machineId}`;
187
188
  }
188
189
 
189
- await Promise.all([loadAllSessions(), loadWorkspaces(), loadSessionMetaMap()]);
190
+ loadMetaAgentBoot();
191
+ await Promise.all([loadAllSessions(), loadWorkspaces(), loadSessionMetaMap(), loadAgentsMeta().catch(() => {})]);
190
192
  hubMachineReady.value = true;
191
193
  }
192
194
 
@@ -103,6 +103,7 @@ export function renderSessionNotesBar() {
103
103
  const meta = getSessionMeta(agent, sid);
104
104
  const note = (meta.notes || '').trim();
105
105
  const fav = !!meta.favorite;
106
+ const hid = !!meta.hidden;
106
107
  const title = (meta.title || '').trim();
107
108
  const showTitle = title || sid.slice(0, 8);
108
109
  bar.style.display = 'flex';
@@ -112,9 +113,15 @@ export function renderSessionNotesBar() {
112
113
  data-fav-session-id="${esc(sid)}" data-fav-session-agent="${esc(agent)}"
113
114
  title="${fav ? 'Unfavorite' : 'Favorite'}">${fav ? '★' : '☆'}</button>
114
115
  <span id="session-title-display"
115
- class="session-title-inline max-w-[40%] truncate font-semibold text-base-content/85 cursor-text hover:text-primary"
116
- title="Click to rename"
117
- data-rename-bar="1">${esc(showTitle)}</span>
116
+ class="session-title-inline max-w-[40%] truncate font-semibold text-base-content/85"
117
+ title="${esc(showTitle)}">${esc(showTitle)}</span>
118
+ <button type="button" class="session-rename-btn flex-shrink-0 opacity-70 hover:opacity-100"
119
+ data-rename-bar="1" title="Rename session">✎</button>
120
+ <button type="button" class="session-hide-btn flex-shrink-0 ${hid ? 'is-hidden' : ''}"
121
+ data-hide-toggle="1"
122
+ data-hide-session-id="${esc(sid)}"
123
+ data-hide-session-agent="${esc(agent)}"
124
+ title="${hid ? 'Unhide session' : 'Hide session'}">${hid ? '👁' : '🙈'}</button>
118
125
  <span class="font-bold text-base-content/40 uppercase flex-shrink-0">Note:</span>
119
126
  <span class="truncate text-base-content/70 ${note ? '' : 'italic text-base-content/40'}"
120
127
  id="session-notes-text" title="${esc(note || 'Add a note for this session')}">${esc(note || 'No session note')}</span>
@@ -139,17 +146,43 @@ export function renderSessionNotesEditMode() {
139
146
  input?.select();
140
147
  }
141
148
 
142
- export async function toggleSessionFavorite(sessionId, agent) {
143
- const key = metaKey(agent, sessionId);
144
- const prev = sessionMetaMap.peek()?.[key] || { favorite: false, notes: '', title: '' };
149
+ function emptyMeta() {
150
+ return { favorite: false, notes: '', title: '', hidden: false };
151
+ }
152
+
153
+ function mergeMeta(prev, patch) {
154
+ return {
155
+ favorite: patch.favorite !== undefined ? Boolean(patch.favorite) : Boolean(prev.favorite),
156
+ notes: patch.notes !== undefined ? String(patch.notes || '') : String(prev.notes || ''),
157
+ title: patch.title !== undefined ? String(patch.title || '').trim() : String(prev.title || '').trim(),
158
+ hidden: patch.hidden !== undefined ? Boolean(patch.hidden) : Boolean(prev.hidden),
159
+ updatedAt: Date.now(),
160
+ };
161
+ }
162
+
163
+ function isMetaEmpty(m) {
164
+ return !m?.favorite && !m?.hidden && !(m?.notes || '').trim() && !(m?.title || '').trim();
165
+ }
166
+
167
+ export async function toggleSessionFavorite(sessionId, agent, { machineId } = {}) {
168
+ const key = metaKey(agent, sessionId, machineId);
169
+ const prev = sessionMetaMap.peek()?.[key] || emptyMeta();
145
170
  const nextFav = !prev.favorite;
146
- // optimistic
147
171
  sessionMetaMap.value = {
148
172
  ...sessionMetaMap.peek(),
149
173
  [key]: { ...prev, favorite: nextFav, updatedAt: Date.now() },
150
174
  };
151
175
  try {
152
- await api('PUT', '/api/sessions/meta', { sessionId, agent, favorite: nextFav });
176
+ const res = await api('PUT', '/api/sessions/meta', { sessionId, agent, favorite: nextFav });
177
+ sessionMetaMap.value = {
178
+ ...sessionMetaMap.peek(),
179
+ [key]: mergeMeta(prev, {
180
+ favorite: res.favorite ?? nextFav,
181
+ notes: res.notes,
182
+ title: res.title,
183
+ hidden: res.hidden,
184
+ }),
185
+ };
153
186
  } catch (e) {
154
187
  sessionMetaMap.value = { ...sessionMetaMap.peek(), [key]: prev };
155
188
  alert(`Favorite failed: ${e.message}`);
@@ -157,19 +190,53 @@ export async function toggleSessionFavorite(sessionId, agent) {
157
190
  if (ctx.sessionId === sessionId) renderSessionNotesBar();
158
191
  }
159
192
 
193
+ /** Hide / unhide a session (soft archive in the sidebar). */
194
+ export async function toggleSessionHidden(sessionId, agent, { machineId } = {}) {
195
+ if (!sessionId) return false;
196
+ const key = metaKey(agent, sessionId, machineId);
197
+ const prev = sessionMetaMap.peek()?.[key] || emptyMeta();
198
+ const nextHidden = !prev.hidden;
199
+ sessionMetaMap.value = {
200
+ ...sessionMetaMap.peek(),
201
+ [key]: { ...prev, hidden: nextHidden, updatedAt: Date.now() },
202
+ };
203
+ try {
204
+ const res = await api('PUT', '/api/sessions/meta', { sessionId, agent, hidden: nextHidden });
205
+ const next = mergeMeta(prev, {
206
+ favorite: res.favorite,
207
+ notes: res.notes,
208
+ title: res.title,
209
+ hidden: res.hidden ?? nextHidden,
210
+ });
211
+ if (isMetaEmpty(next)) {
212
+ const map = { ...sessionMetaMap.peek() };
213
+ delete map[key];
214
+ sessionMetaMap.value = map;
215
+ } else {
216
+ sessionMetaMap.value = { ...sessionMetaMap.peek(), [key]: next };
217
+ }
218
+ } catch (e) {
219
+ sessionMetaMap.value = { ...sessionMetaMap.peek(), [key]: prev };
220
+ alert(`Hide failed: ${e.message}`);
221
+ return false;
222
+ }
223
+ if (ctx.sessionId === sessionId) renderSessionNotesBar();
224
+ return true;
225
+ }
226
+
160
227
  export async function saveSessionNotes(sessionId, agent, notes) {
161
228
  const key = metaKey(agent, sessionId);
162
- const prev = sessionMetaMap.peek()?.[key] || { favorite: false, notes: '', title: '' };
229
+ const prev = sessionMetaMap.peek()?.[key] || emptyMeta();
163
230
  try {
164
231
  const res = await api('PUT', '/api/sessions/meta', { sessionId, agent, notes });
165
232
  sessionMetaMap.value = {
166
233
  ...sessionMetaMap.peek(),
167
- [key]: {
168
- favorite: res.favorite ?? prev.favorite,
234
+ [key]: mergeMeta(prev, {
235
+ favorite: res.favorite,
169
236
  notes: res.notes ?? notes,
170
- title: res.title ?? prev.title ?? '',
171
- updatedAt: Date.now(),
172
- },
237
+ title: res.title,
238
+ hidden: res.hidden,
239
+ }),
173
240
  };
174
241
  } catch (e) {
175
242
  alert(`Save note failed: ${e.message}`);
@@ -185,31 +252,26 @@ export async function saveSessionNotes(sessionId, agent, notes) {
185
252
  export async function renameSession(sessionId, agent, title, { machineId } = {}) {
186
253
  if (!sessionId) return false;
187
254
  const key = metaKey(agent, sessionId, machineId);
188
- const prev = sessionMetaMap.peek()?.[key] || { favorite: false, notes: '', title: '' };
255
+ const prev = sessionMetaMap.peek()?.[key] || emptyMeta();
189
256
  const nextTitle = String(title || '').trim().slice(0, 200);
190
- // optimistic
191
257
  sessionMetaMap.value = {
192
258
  ...sessionMetaMap.peek(),
193
259
  [key]: { ...prev, title: nextTitle, updatedAt: Date.now() },
194
260
  };
195
261
  try {
196
262
  const res = await api('PUT', '/api/sessions/meta', { sessionId, agent, title: nextTitle });
197
- sessionMetaMap.value = {
198
- ...sessionMetaMap.peek(),
199
- [key]: {
200
- favorite: res.favorite ?? prev.favorite,
201
- notes: res.notes ?? prev.notes ?? '',
202
- title: res.title !== undefined ? res.title : nextTitle,
203
- updatedAt: Date.now(),
204
- },
205
- };
206
- // If server dropped empty entry, keep local clear
207
- if (!res.title && !res.favorite && !res.notes) {
263
+ const next = mergeMeta(prev, {
264
+ favorite: res.favorite,
265
+ notes: res.notes,
266
+ title: res.title !== undefined ? res.title : nextTitle,
267
+ hidden: res.hidden,
268
+ });
269
+ if (isMetaEmpty(next)) {
208
270
  const map = { ...sessionMetaMap.peek() };
209
- if (!nextTitle && !prev.favorite && !(prev.notes || '').trim()) {
210
- delete map[key];
211
- sessionMetaMap.value = map;
212
- }
271
+ delete map[key];
272
+ sessionMetaMap.value = map;
273
+ } else {
274
+ sessionMetaMap.value = { ...sessionMetaMap.peek(), [key]: next };
213
275
  }
214
276
  } catch (e) {
215
277
  sessionMetaMap.value = { ...sessionMetaMap.peek(), [key]: prev };