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
@@ -10,7 +10,7 @@ import {
10
10
  getModelsForAgent, getDefaultModel, getEffortsForModel,
11
11
  addCustomModel, removeCustomModel, getCustomModels, ctx,
12
12
  } from '../state.js';
13
- import { api } from '../api.js';
13
+ import { api, NOT_MODIFIED } from '../api.js';
14
14
  import { getCachedSessions, setCachedSessions, getCachedWorkspaces, setCachedWorkspaces } from '../cache.js';
15
15
  import { formatTime, agentBadge, agentCountPills, shortPath } from './util.js';
16
16
 
@@ -22,6 +22,16 @@ function normalizeSessions(v) {
22
22
  return [];
23
23
  }
24
24
 
25
+ function sessionsQuery() {
26
+ const days = parseInt(timeRange.peek() || '14', 10);
27
+ const q = new URLSearchParams();
28
+ // Match sidebar time filter so we don't pull years of metadata on mobile.
29
+ if (days > 0) q.set('days', String(days));
30
+ q.set('limit', '400');
31
+ const s = q.toString();
32
+ return s ? `/api/sessions?${s}` : '/api/sessions';
33
+ }
34
+
25
35
  /** @param {{ waitFresh?: boolean }} opts waitFresh=false → paint cache first, refresh in bg */
26
36
  export async function loadAllSessions({ waitFresh = true } = {}) {
27
37
  const cached = normalizeSessions(await getCachedSessions());
@@ -37,10 +47,13 @@ export async function loadAllSessions({ waitFresh = true } = {}) {
37
47
  }
38
48
 
39
49
  const fetchFresh = async () => {
40
- const fresh = normalizeSessions(await api('GET', '/api/sessions'));
41
- sessionsData.value = fresh;
42
- setCachedSessions(fresh);
43
- return fresh;
50
+ const path = sessionsQuery();
51
+ const fresh = await api('GET', path, undefined, undefined, { etag: true });
52
+ if (fresh === NOT_MODIFIED) return sessionsData.peek();
53
+ const list = normalizeSessions(fresh);
54
+ sessionsData.value = list;
55
+ setCachedSessions(list);
56
+ return list;
44
57
  };
45
58
 
46
59
  if (waitFresh || !cached.length) {
@@ -79,6 +92,7 @@ export function sessionItemHtml(s, { showProject = false } = {}) {
79
92
  ? s.snippet
80
93
  : sessionDisplayTitle(s);
81
94
  const fav = !!meta.favorite;
95
+ const hid = !!meta.hidden;
82
96
  const hasNote = !!(meta.notes && String(meta.notes).trim());
83
97
  const turns = Number(s.turnCount) || 0;
84
98
  const thin = turns > 0 && turns <= LOW_TURN_THRESHOLD;
@@ -87,7 +101,8 @@ export function sessionItemHtml(s, { showProject = false } = {}) {
87
101
  : '';
88
102
  const badges = [
89
103
  thin ? `<span class="session-thin-badge" title="Only ${turns} turn${turns === 1 ? '' : 's'}">thin·${turns}</span>` : '',
90
- renamed ? `<span class="session-renamed-badge" title="Custom name (original: ${esc(s.display || '')})">✎</span>` : '',
104
+ renamed ? `<span class="session-renamed-badge" title="Custom name (original: ${esc(s.display || '')})">renamed</span>` : '',
105
+ hid ? `<span class="session-hidden-badge" title="Hidden">hidden</span>` : '',
91
106
  hasNote ? `<span class="session-note-dot" title="${esc(meta.notes)}">📝</span>` : '',
92
107
  ].filter(Boolean).join('');
93
108
  const machineBadge = s.machineId
@@ -97,7 +112,7 @@ export function sessionItemHtml(s, { showProject = false } = {}) {
97
112
  ? `${title}\n(original: ${s.display || s.sessionId})`
98
113
  : (s.display || '');
99
114
  return `<div class="session-row px-2.5 py-1.5 cursor-pointer hover:bg-base-300 border-l-2
100
- ${active ? 'border-primary bg-primary/5' : 'border-transparent'} ${fav ? 'session-fav' : ''} ${thin ? 'session-thin' : ''}"
115
+ ${active ? 'border-primary bg-primary/5' : 'border-transparent'} ${fav ? 'session-fav' : ''} ${hid ? 'session-hidden-row' : ''} ${thin ? 'session-thin' : ''}"
101
116
  data-session-id="${esc(s.sessionId)}"
102
117
  data-session-cwd="${esc(s.cwd || '')}"
103
118
  data-session-config-dir="${esc(s.configDir || '')}"
@@ -114,17 +129,21 @@ export function sessionItemHtml(s, { showProject = false } = {}) {
114
129
  ${agentBadge(s.agent)}
115
130
  <div class="min-w-0 flex-1">
116
131
  <div class="session-title-wrap flex items-center gap-1 min-w-0"
117
- data-session-id="${esc(s.sessionId)}"
118
132
  data-rename-session-id="${esc(s.sessionId)}"
119
133
  data-rename-session-agent="${esc(s.agent || 'claude')}"
120
134
  data-rename-session-machine="${esc(s.machineId || '')}"
121
135
  data-rename-session-display="${esc(s.display || '')}">
122
136
  <div class="session-title-text text-[11px] leading-snug truncate flex-1 min-w-0 ${active ? 'text-primary font-medium' : 'text-base-content/85'} ${renamed ? 'session-title-renamed' : ''}"
123
- data-rename-session="1"
124
- title="${esc(tip)} · click to rename">${esc(title)}</div>
137
+ title="${esc(tip)}">${esc(title)}</div>
125
138
  <button type="button" class="session-rename-btn flex-shrink-0"
126
139
  data-rename-session="1"
127
140
  title="Rename session">✎</button>
141
+ <button type="button" class="session-hide-btn flex-shrink-0 ${hid ? 'is-hidden' : ''}"
142
+ data-hide-toggle="1"
143
+ data-hide-session-id="${esc(s.sessionId)}"
144
+ data-hide-session-agent="${esc(s.agent || 'claude')}"
145
+ data-hide-session-machine="${esc(s.machineId || '')}"
146
+ title="${hid ? 'Unhide session' : 'Hide session'}">${hid ? '👁' : '🙈'}</button>
128
147
  </div>
129
148
  ${projectLine}
130
149
  ${badges || machineBadge ? `<div class="flex items-center gap-1 mt-0.5 flex-wrap">${machineBadge}${badges}</div>` : ''}
@@ -217,8 +217,13 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
217
217
  }
218
218
  appendSystemMsg(`Loading ${label} history…`);
219
219
  try {
220
- // tail=120 → server returns only recent messages (much faster on long sessions)
221
- const q = new URLSearchParams({ agent: resolvedAgent, tail: '120' });
220
+ // Smaller tail + compact tool payloads on mobile / Save-Data.
221
+ const saveData = typeof navigator !== 'undefined' && (
222
+ navigator.connection?.saveData
223
+ || /Mobi|Android|iPhone/i.test(navigator.userAgent || '')
224
+ );
225
+ const tail = saveData ? '50' : '80';
226
+ const q = new URLSearchParams({ agent: resolvedAgent, tail, compact: '1' });
222
227
  const data = await api('GET', `/api/sessions/${sid}/messages?${q}`);
223
228
  const msgs = Array.isArray(data) ? data : (data.messages || []);
224
229
  const context = Array.isArray(data) ? null : (data.context || null);
@@ -39,7 +39,7 @@ export const AppShell = (opts = {}) => {
39
39
  return `
40
40
  <div id="sidebar-overlay" class="hidden fixed inset-0 bg-black/50 z-[9]"></div>
41
41
  <div id="app" class="${appHidden} flex-col flex-1 overflow-hidden" style="${appStyle}">
42
- <div class="flex items-center gap-2 px-3 bg-base-200 border-b border-base-300 flex-shrink-0" style="height:44px">
42
+ <div id="topbar" class="flex items-center gap-2 px-3 bg-base-200 border-b border-base-300 flex-shrink-0" style="height:44px">
43
43
  <button id="hamburger" class="btn btn-ghost btn-sm px-2 text-lg">☰</button>
44
44
  <span id="btn-home" class="font-semibold text-sm hidden sm:inline cursor-pointer hover:text-primary transition-colors select-none">🤖 Agent UI</span>
45
45
  <span class="text-base-300 hidden sm:inline">/</span>
@@ -61,7 +61,10 @@ export const AppShell = (opts = {}) => {
61
61
  </div>
62
62
  </div>
63
63
  <span id="topbar-project" class="text-sm text-base-content/50 flex-1 truncate">Select a session</span>
64
+ <span id="conn-dot" class="w-2 h-2 rounded-full bg-base-content/30 flex-shrink-0" title="连接状态"></span>
64
65
  <div class="flex gap-2">
66
+ <button id="btn-refresh-conn" class="btn btn-ghost btn-xs border border-base-300" title="重新连接并同步">↻</button>
67
+ <button id="btn-pwa-install" class="btn btn-ghost btn-xs border border-base-300 hidden" title="安装到主屏幕">安装</button>
65
68
  <button id="btn-meta-agent" class="btn btn-ghost btn-xs border border-primary/40 text-primary" title="Meta Agent — 报告 / 问答 / VLM">✦ Agent</button>
66
69
  <button id="btn-new-session" class="btn btn-ghost btn-xs border border-base-300">+ New</button>
67
70
  <button id="btn-theme" class="btn btn-ghost btn-xs border border-base-300">🌙</button>
@@ -114,6 +117,7 @@ export const AppShell = (opts = {}) => {
114
117
  <button data-agent-filter="codex" class="agent-pill">Codex</button>
115
118
  <button data-agent-filter="grok" class="agent-pill">Grok</button>
116
119
  <button id="btn-fav-filter" class="agent-pill" title="Show only favorites">★ Fav</button>
120
+ <button id="btn-hidden-filter" class="agent-pill" title="Show only hidden sessions">🙈 Hidden</button>
117
121
  </div>
118
122
  <div id="search-status" class="hidden text-[10px] text-base-content/40 px-0.5"></div>
119
123
  </div>
@@ -205,13 +209,17 @@ export const AppShell = (opts = {}) => {
205
209
  <div id="tab-chat" class="flex flex-col flex-1 overflow-hidden relative">
206
210
  <div id="project-notes-bar" class="bg-base-200 border-b border-base-300 px-3 py-1.5 text-xs flex flex-col gap-1" style="display:none"></div>
207
211
  <div id="session-notes-bar" class="bg-base-200/80 border-b border-base-300 px-3 py-1 text-xs flex justify-between items-center gap-2" style="display:none"></div>
212
+ <div id="ws-banner" class="hidden px-3 py-1.5 text-[11px] text-center bg-warning/15 text-warning border-b border-warning/20 flex items-center justify-center gap-2">
213
+ <span id="ws-banner-text"></span>
214
+ <button type="button" id="ws-banner-retry" class="btn btn-ghost btn-xs h-6 min-h-0 px-2 text-warning border border-warning/30">重连</button>
215
+ </div>
208
216
  <div id="messages" class="flex-1 overflow-y-auto p-3 flex flex-col gap-2"></div>
209
217
  <div id="scroll-nav" class="absolute right-3 bottom-24 flex flex-col gap-1.5 z-10">
210
218
  <button id="scroll-top-btn" class="scroll-fab hidden" title="Jump to top">↑</button>
211
219
  <button id="scroll-bottom-btn" class="scroll-fab hidden" title="Jump to bottom">↓</button>
212
220
  </div>
213
221
  <div id="permission-requests"></div>
214
- <div class="flex flex-col border-t border-base-300 bg-base-200 flex-shrink-0">
222
+ <div id="chat-composer" class="flex flex-col border-t border-base-300 bg-base-200 flex-shrink-0">
215
223
  <div id="chat-opts" class="hidden flex items-center gap-1.5 px-3 pt-2 flex-wrap">
216
224
  <select id="sel-agent" class="select select-xs select-bordered text-xs font-semibold" title="Agent">
217
225
  <option value="claude">Claude</option>
@@ -258,7 +266,7 @@ export const AppShell = (opts = {}) => {
258
266
  <div class="flex gap-2 items-end px-3 pt-2 pb-2">
259
267
  <button id="btn-opts" class="btn btn-ghost btn-sm px-2 text-base-content/30 hover:text-base-content flex-shrink-0" title="Options" style="height:40px">⚙</button>
260
268
  <textarea id="chat-input" rows="1" placeholder="Ask… (!cmd shell · ⌘↵ send)"
261
- class="textarea textarea-bordered flex-1 text-sm resize-none leading-relaxed"
269
+ class="chat-input flex-1 text-sm resize-none leading-relaxed"
262
270
  style="min-height:40px;max-height:140px"></textarea>
263
271
  <button id="send-btn" class="btn btn-primary btn-sm flex-shrink-0" style="height:40px">Send</button>
264
272
  <button id="stop-btn" class="btn btn-error btn-sm hidden flex-shrink-0" style="height:40px">■</button>
package/public/shell.js CHANGED
@@ -9,10 +9,11 @@ import {
9
9
  currentModel, currentEffort, currentPermission, currentAgent, setAgent,
10
10
  sidebarView, agentFilter, timeRange, activityHits, activityLoading,
11
11
  chatDensity, setChatDensity,
12
- favoritesOnly, hubMode, hubMachineReady, selectedMachineId, setSelectedMachine,
12
+ favoritesOnly, showHiddenOnly, hubMode, hubMachineReady, selectedMachineId, setSelectedMachine,
13
13
  machinesList, AGENT_LABELS, getDefaultModel, sessionMetaMap, ctx,
14
14
  } from './state.js';
15
15
  import { api } from './api.js';
16
+ import { promptPwaInstall } from './pwa.js';
16
17
  import { getLastSessionContext } from './shell/session-context.js';
17
18
  import {
18
19
  connectWS, clearMessages, appendMsg, appendSystemMsg, sendMessage, stopProcessing,
@@ -34,7 +35,7 @@ import { showApp } from './shell/boot.js';
34
35
  import {
35
36
  fetchProjectNotes, renderProjectNotesDisplayMode, renderProjectNotesEditMode,
36
37
  renderSessionNotesBar, renderSessionNotesEditMode,
37
- toggleSessionFavorite, saveSessionNotes, startInlineRename, setProjectNoteFields,
38
+ toggleSessionFavorite, toggleSessionHidden, saveSessionNotes, startInlineRename, setProjectNoteFields,
38
39
  } from './shell/notes.js';
39
40
  import {
40
41
  refreshMachinesList, renderMachinePickerList, renderMachineMenuList,
@@ -50,6 +51,48 @@ import {
50
51
  // Re-export public API (app.js and others)
51
52
  export { showApp, resumeSession, switchTab, openProject, goHome, loadAllSessions };
52
53
 
54
+ /** Visible install guide when browser has no beforeinstallprompt (iOS Safari etc.). */
55
+ function showPwaInstallHelp() {
56
+ const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent)
57
+ || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
58
+ const isAndroid = /Android/i.test(navigator.userAgent);
59
+ let body;
60
+ if (isIOS) {
61
+ body = `<ol class="list-decimal pl-4 space-y-2 text-sm text-base-content/80">
62
+ <li>点底部分享按钮 <strong>分享</strong>(方框+向上箭头)</li>
63
+ <li>下滑找到并点 <strong>添加到主屏幕</strong></li>
64
+ <li>确认名称后点 <strong>添加</strong></li>
65
+ </ol>
66
+ <p class="text-xs text-base-content/50 mt-3">需用 <strong>Safari</strong> 打开本站;微信/Chrome 内置浏览器通常没有「添加到主屏幕」。</p>`;
67
+ } else if (isAndroid) {
68
+ body = `<ol class="list-decimal pl-4 space-y-2 text-sm text-base-content/80">
69
+ <li>点右上角菜单 <strong>⋮</strong></li>
70
+ <li>选择 <strong>安装应用</strong> 或 <strong>添加到主屏幕</strong></li>
71
+ </ol>
72
+ <p class="text-xs text-base-content/50 mt-3">若菜单里没有该项:请用 <strong>Chrome</strong> 打开,并先接受本站证书提示。自签 HTTPS 下部分机型不弹出一键安装。</p>`;
73
+ } else {
74
+ body = `<p class="text-sm text-base-content/80">请在浏览器菜单中选择 <strong>安装应用 / 安装网页应用 / 添加到主屏幕</strong>。</p>
75
+ <p class="text-xs text-base-content/50 mt-3">Chrome / Edge 地址栏右侧有时也会出现安装图标。</p>`;
76
+ }
77
+ let dlg = document.getElementById('modal-pwa-install');
78
+ if (!dlg) {
79
+ dlg = document.createElement('dialog');
80
+ dlg.id = 'modal-pwa-install';
81
+ dlg.className = 'modal';
82
+ document.body.appendChild(dlg);
83
+ }
84
+ dlg.innerHTML = `
85
+ <div class="modal-box max-w-sm">
86
+ <h3 class="font-bold text-base mb-3">安装到主屏幕</h3>
87
+ ${body}
88
+ <div class="modal-action">
89
+ <form method="dialog"><button class="btn btn-sm">知道了</button></form>
90
+ </div>
91
+ </div>
92
+ <form method="dialog" class="modal-backdrop"><button>close</button></form>`;
93
+ dlg.showModal();
94
+ }
95
+
53
96
  export async function initAuth() {
54
97
  const authScreen = $('auth-screen');
55
98
  if (authScreen) {
@@ -235,7 +278,15 @@ export function initShell(opts = {}) {
235
278
  initSidebarResize();
236
279
  initSidebarToggle();
237
280
 
238
- document.addEventListener('sessions-changed', () => loadAllSessions());
281
+ let sessionsChangedTimer = null;
282
+ document.addEventListener('sessions-changed', () => {
283
+ // Debounce: rapid agent turns would otherwise re-download the full list.
284
+ if (sessionsChangedTimer) clearTimeout(sessionsChangedTimer);
285
+ sessionsChangedTimer = setTimeout(() => {
286
+ sessionsChangedTimer = null;
287
+ loadAllSessions({ waitFresh: false });
288
+ }, 1200);
289
+ });
239
290
  document.addEventListener('router:home', goHome);
240
291
 
241
292
  document.addEventListener('router:session', async ({ detail: { id, tab } }) => {
@@ -279,6 +330,7 @@ export function initShell(opts = {}) {
279
330
  delegate.on('change', '#time-range', (_, el) => {
280
331
  timeRange.value = el.value;
281
332
  localStorage.setItem('timeRange', el.value);
333
+ loadAllSessions({ waitFresh: true });
282
334
  if (sessionSearch.peek().trim()) scheduleActivitySearch(sessionSearch.peek());
283
335
  });
284
336
  delegate.on('dblclick', '[data-project-cwd]', (_, el) => {
@@ -288,6 +340,16 @@ export function initShell(opts = {}) {
288
340
  openProject({ id: name, name, path: cwd });
289
341
  appendSystemMsg(`Project · ${cwd}`);
290
342
  });
343
+ delegate.on('click', '#btn-pwa-install', async () => {
344
+ const ok = await promptPwaInstall();
345
+ if (ok) return;
346
+ // iOS / browsers without beforeinstallprompt: chat system messages are easy
347
+ // to miss (no open session / wrong tab). Always show a modal.
348
+ showPwaInstallHelp();
349
+ });
350
+ document.addEventListener('pwa-installable', () => {
351
+ document.getElementById('btn-pwa-install')?.classList.remove('hidden');
352
+ });
291
353
  delegate.on('click', '#btn-settings', openSettings);
292
354
  delegate.on('click', '#btn-meta-agent', () => toggleMetaAgent());
293
355
  delegate.on('click', '#btn-meta-agent-welcome', () => openMetaAgent('chat'));
@@ -308,9 +370,11 @@ export function initShell(opts = {}) {
308
370
 
309
371
  delegate.on('click', '[data-session-id]', (e, el) => {
310
372
  if (e.target.closest('[data-fav-toggle]')) return;
373
+ if (e.target.closest('[data-hide-toggle]')) return;
311
374
  if (e.target.closest('[data-rename-session]')) return;
312
375
  if (e.target.closest('.session-rename-input')) return;
313
376
  if (e.target.closest('.session-title-wrap.session-rename-active')) return;
377
+ // Only open when clicking the row / title — not action buttons
314
378
  resumeSession(
315
379
  el.dataset.sessionId,
316
380
  el.dataset.sessionCwd || null,
@@ -320,7 +384,7 @@ export function initShell(opts = {}) {
320
384
  );
321
385
  closeSidebarOnMobile();
322
386
  });
323
- // Inline rename (sidebar): click title or ✎ — no dialog
387
+ // Inline rename (sidebar): only the ✎ button — title click opens the session
324
388
  delegate.on('click', '[data-rename-session]', (e, el) => {
325
389
  e.preventDefault();
326
390
  e.stopPropagation();
@@ -335,12 +399,12 @@ export function initShell(opts = {}) {
335
399
  mountEl: wrap,
336
400
  });
337
401
  });
338
- // Inline rename (session bar title)
339
- delegate.on('click', '[data-rename-bar], #session-title-display', (e, el) => {
402
+ // Inline rename (session bar): only the ✎ button next to the title
403
+ delegate.on('click', '[data-rename-bar]', (e, el) => {
340
404
  e.preventDefault();
341
405
  e.stopPropagation();
342
406
  if (!ctx.sessionId) return;
343
- const mountEl = el.id === 'session-title-display' ? el : (el.closest('#session-title-display') || el);
407
+ const mountEl = document.getElementById('session-title-display') || el;
344
408
  startInlineRename({
345
409
  sessionId: ctx.sessionId,
346
410
  agent: ctx.agent || currentAgent.peek() || 'claude',
@@ -540,7 +604,18 @@ export function initShell(opts = {}) {
540
604
  e.preventDefault();
541
605
  const mid = el.dataset.favSessionMachine || ctx.machineId;
542
606
  if (mid) { ctx.machineId = mid; localStorage.setItem('machineId', mid); }
543
- toggleSessionFavorite(el.dataset.favSessionId, el.dataset.favSessionAgent || 'claude');
607
+ toggleSessionFavorite(el.dataset.favSessionId, el.dataset.favSessionAgent || 'claude', {
608
+ machineId: mid || null,
609
+ });
610
+ });
611
+ delegate.on('click', '[data-hide-toggle]', (e, el) => {
612
+ e.stopPropagation();
613
+ e.preventDefault();
614
+ const mid = el.dataset.hideSessionMachine || ctx.machineId;
615
+ if (mid) { ctx.machineId = mid; localStorage.setItem('machineId', mid); }
616
+ toggleSessionHidden(el.dataset.hideSessionId, el.dataset.hideSessionAgent || 'claude', {
617
+ machineId: mid || null,
618
+ });
544
619
  });
545
620
  delegate.on('click', '#btn-edit-session-notes, #session-notes-text', () => {
546
621
  renderSessionNotesEditMode();
@@ -566,6 +641,23 @@ export function initShell(opts = {}) {
566
641
  favoritesOnly.value = next;
567
642
  localStorage.setItem('favoritesOnly', next ? '1' : '0');
568
643
  $('btn-fav-filter')?.classList.toggle('active', next);
644
+ // Fav and Hidden filters are exclusive for clarity
645
+ if (next && showHiddenOnly.peek()) {
646
+ showHiddenOnly.value = false;
647
+ localStorage.setItem('showHiddenOnly', '0');
648
+ $('btn-hidden-filter')?.classList.remove('active');
649
+ }
650
+ });
651
+ delegate.on('click', '#btn-hidden-filter', () => {
652
+ const next = !showHiddenOnly.peek();
653
+ showHiddenOnly.value = next;
654
+ localStorage.setItem('showHiddenOnly', next ? '1' : '0');
655
+ $('btn-hidden-filter')?.classList.toggle('active', next);
656
+ if (next && favoritesOnly.peek()) {
657
+ favoritesOnly.value = false;
658
+ localStorage.setItem('favoritesOnly', '0');
659
+ $('btn-fav-filter')?.classList.remove('active');
660
+ }
569
661
  });
570
662
 
571
663
  refreshModelSelect();
@@ -578,6 +670,7 @@ export function initShell(opts = {}) {
578
670
  btn.classList.toggle('active', btn.dataset.agentFilter === agentFilter.peek());
579
671
  });
580
672
  $('btn-fav-filter')?.classList.toggle('active', favoritesOnly.peek());
673
+ $('btn-hidden-filter')?.classList.toggle('active', showHiddenOnly.peek());
581
674
  document.querySelectorAll('[data-view]').forEach(btn => {
582
675
  const on = btn.dataset.view === sidebarView.peek();
583
676
  btn.classList.toggle('bg-primary/15', on);
package/public/state.js CHANGED
@@ -13,6 +13,8 @@ export const viewingFile = signal(null); // currently viewing file in F
13
13
  export const currentProject = signal(null); // { id, name, path } | null
14
14
  export const currentTab = signal('chat');
15
15
  export const isProcessing = signal(false);
16
+ /** Chat socket: idle | connecting | open | reconnecting | offline */
17
+ export const wsStatus = signal('idle');
16
18
  export const currentAgent = signal(localStorage.getItem('agent') || 'claude'); // 'claude'|'codex'|'grok'
17
19
  export const currentModel = signal(localStorage.getItem('model') || 'claude-sonnet-4-5');
18
20
  export const currentEffort = signal('');
@@ -27,10 +29,12 @@ export const agentFilter = signal(localStorage.getItem('agentFilter') || 'all
27
29
  export const timeRange = signal(localStorage.getItem('timeRange') || '14'); // '0'|'1'|'7'|'14'|'30'
28
30
  export const activityHits = signal(null); // null | { q, results, loading, error }
29
31
  export const activityLoading = signal(false);
30
- /** agent:sessionId → { favorite, notes, title, updatedAt } (title = rename map) */
32
+ /** agent:sessionId → { favorite, notes, title, hidden, updatedAt } (title = rename map) */
31
33
  export const sessionMetaMap = signal({});
32
34
  /** Sidebar: show only favorited sessions */
33
35
  export const favoritesOnly = signal(localStorage.getItem('favoritesOnly') === '1');
36
+ /** Sidebar: show only hidden sessions (default list excludes hidden) */
37
+ export const showHiddenOnly = signal(localStorage.getItem('showHiddenOnly') === '1');
34
38
  /** Sessions with this many user turns or fewer get a “thin” badge */
35
39
  export const LOW_TURN_THRESHOLD = 2;
36
40
 
@@ -77,7 +81,7 @@ export function metaKey(agent, sessionId, machineId) {
77
81
 
78
82
  export function getSessionMeta(agent, sessionId, machineId) {
79
83
  const m = sessionMetaMap.peek()?.[metaKey(agent, sessionId, machineId)];
80
- return m || { favorite: false, notes: '', title: '' };
84
+ return m || { favorite: false, notes: '', title: '', hidden: false };
81
85
  }
82
86
 
83
87
  /** Display title: user rename map → original display → short id. */
@@ -295,6 +299,7 @@ export const filteredSessions = computed(() => {
295
299
  const aFilter = agentFilter.value;
296
300
  const days = timeRange.value;
297
301
  const favOnly = favoritesOnly.value;
302
+ const hiddenOnly = showHiddenOnly.value;
298
303
  const metaMap = sessionMetaMap.value;
299
304
  // When deep activity results are present and user is searching, prefer those
300
305
  const hits = activityHits.value;
@@ -309,6 +314,12 @@ export const filteredSessions = computed(() => {
309
314
  list = list.filter(s => s && (!s.machineId || s.machineId === mid));
310
315
  }
311
316
  list = list.filter(s => s && matchesAgentFilter(s, aFilter) && matchesTimeRange(s, days));
317
+ // Hidden sessions: excluded by default; "Hidden" filter shows only them
318
+ if (hiddenOnly) {
319
+ list = list.filter(s => s && metaMap?.[metaKey(s.agent, s.sessionId, s.machineId)]?.hidden);
320
+ } else {
321
+ list = list.filter(s => s && !metaMap?.[metaKey(s.agent, s.sessionId, s.machineId)]?.hidden);
322
+ }
312
323
  if (favOnly) {
313
324
  list = list.filter(s => s && metaMap?.[metaKey(s.agent, s.sessionId, s.machineId)]?.favorite);
314
325
  }
package/public/style.css CHANGED
@@ -2,13 +2,62 @@
2
2
  @media (min-width: 641px) { #hamburger { display: none !important; } }
3
3
  @media (max-width: 640px) {
4
4
  #sidebar {
5
- position: fixed; top: 44px; left: 0; bottom: 0;
5
+ position: fixed;
6
+ top: calc(44px + env(safe-area-inset-top, 0px));
7
+ left: 0;
8
+ bottom: 0;
6
9
  transform: translateX(-100%); z-index: 10;
7
10
  transition: transform .2s ease;
11
+ padding-bottom: env(safe-area-inset-bottom, 0px);
8
12
  }
9
13
  #sidebar.open { transform: translateX(0); box-shadow: 4px 0 20px rgba(0,0,0,.4); }
10
14
  }
11
15
 
16
+ /*
17
+ * PWA standalone safe areas:
18
+ * Do NOT pad the whole body (that leaves a dead empty strip under the chat bar).
19
+ * Paint chrome into the insets; only inset interactive content.
20
+ */
21
+ html, body {
22
+ height: 100%;
23
+ height: 100dvh;
24
+ }
25
+ html.pwa-standalone,
26
+ html.pwa-standalone body {
27
+ background: oklch(var(--b1));
28
+ }
29
+ /* Top bar: extend under status bar, push content below notch */
30
+ html.pwa-standalone #topbar {
31
+ padding-top: env(safe-area-inset-top, 0px);
32
+ min-height: calc(44px + env(safe-area-inset-top, 0px));
33
+ height: auto !important;
34
+ padding-left: max(0.75rem, env(safe-area-inset-left, 0px));
35
+ padding-right: max(0.75rem, env(safe-area-inset-right, 0px));
36
+ }
37
+ /* Chat composer: sit above home indicator (background fills the inset) */
38
+ html.pwa-standalone #chat-composer {
39
+ padding-bottom: env(safe-area-inset-bottom, 0px);
40
+ padding-left: env(safe-area-inset-left, 0px);
41
+ padding-right: env(safe-area-inset-right, 0px);
42
+ }
43
+ /* Welcome / machine picker full-screen panels */
44
+ html.pwa-standalone #welcome,
45
+ html.pwa-standalone #machine-picker {
46
+ padding-top: max(1rem, env(safe-area-inset-top, 0px));
47
+ padding-bottom: max(1rem, env(safe-area-inset-bottom, 0px));
48
+ padding-left: max(1rem, env(safe-area-inset-left, 0px));
49
+ padding-right: max(1rem, env(safe-area-inset-right, 0px));
50
+ }
51
+ /* Meta agent drawer bottom */
52
+ html.pwa-standalone #meta-agent-panel,
53
+ html.pwa-standalone .ma-panel {
54
+ padding-bottom: env(safe-area-inset-bottom, 0px);
55
+ }
56
+
57
+ #conn-dot {
58
+ box-shadow: 0 0 0 2px color-mix(in oklch, var(--color-base-200) 80%, transparent);
59
+ }
60
+
12
61
  /* ── Theme-aware code / tool surfaces ────────────────────────────────────── */
13
62
  /* Prefer DaisyUI tokens so light/dark both look intentional — no #0d1117 hardcode. */
14
63
  .code-surface {
@@ -542,23 +591,54 @@
542
591
  opacity: 0.9;
543
592
  }
544
593
  .session-title-renamed { font-style: normal; }
545
- .session-rename-btn {
594
+ .session-rename-btn,
595
+ .session-hide-btn {
596
+ appearance: none;
597
+ border: none;
598
+ background: transparent;
546
599
  font-size: 10px;
547
600
  line-height: 1;
548
601
  padding: 1px 3px;
549
602
  border-radius: 4px;
550
603
  color: color-mix(in oklch, var(--color-base-content) 35%, transparent);
551
604
  opacity: 0;
605
+ cursor: pointer;
552
606
  transition: opacity .12s, color .12s, background .12s;
553
607
  }
554
608
  .session-row:hover .session-rename-btn,
555
- .session-row:focus-within .session-rename-btn {
609
+ .session-row:focus-within .session-rename-btn,
610
+ .session-row:hover .session-hide-btn,
611
+ .session-row:focus-within .session-hide-btn,
612
+ #session-notes-bar .session-rename-btn,
613
+ #session-notes-bar .session-hide-btn {
556
614
  opacity: 1;
557
615
  }
558
616
  .session-rename-btn:hover {
559
617
  color: var(--color-primary);
560
618
  background: color-mix(in oklch, var(--color-primary) 12%, transparent);
561
619
  }
620
+ .session-hide-btn:hover {
621
+ color: var(--color-warning);
622
+ background: color-mix(in oklch, var(--color-warning) 12%, transparent);
623
+ }
624
+ .session-hide-btn.is-hidden {
625
+ opacity: 1;
626
+ color: var(--color-warning);
627
+ }
628
+ .session-row.session-hidden-row {
629
+ opacity: 0.78;
630
+ }
631
+ .session-hidden-badge {
632
+ font-size: 9px;
633
+ font-family: ui-monospace, Menlo, monospace;
634
+ font-weight: 700;
635
+ letter-spacing: 0.02em;
636
+ padding: 0 4px;
637
+ border-radius: 3px;
638
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
639
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
640
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 14%, transparent);
641
+ }
562
642
  .session-title-wrap {
563
643
  width: 100%;
564
644
  }
@@ -747,6 +827,38 @@
747
827
  .diff-hunk { color: oklch(var(--in)); }
748
828
  .diff-head { color: oklch(var(--bc)/.5); }
749
829
 
830
+ /* ── Chat composer ───────────────────────────────────────────────────────── */
831
+ /* Single outer border only — no DaisyUI nested focus ring / inner outline. */
832
+ #chat-input.chat-input,
833
+ #chat-input {
834
+ width: 100%;
835
+ min-height: 40px;
836
+ max-height: 140px;
837
+ padding: 0.55rem 0.85rem;
838
+ border-radius: 0.75rem;
839
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 16%, transparent);
840
+ background: var(--color-base-100);
841
+ color: var(--color-base-content);
842
+ outline: none !important;
843
+ box-shadow: none !important;
844
+ --tw-ring-shadow: 0 0 #0000 !important;
845
+ --tw-ring-offset-shadow: 0 0 #0000 !important;
846
+ transition: border-color .15s ease;
847
+ }
848
+ #chat-input:focus,
849
+ #chat-input:focus-visible {
850
+ outline: none !important;
851
+ box-shadow: none !important;
852
+ border-color: color-mix(in oklch, var(--color-primary) 55%, transparent);
853
+ }
854
+ #chat-input.textarea-warning {
855
+ border-color: color-mix(in oklch, var(--color-warning) 65%, transparent);
856
+ background: color-mix(in oklch, var(--color-warning) 6%, var(--color-base-100));
857
+ }
858
+ #chat-input.textarea-warning:focus {
859
+ border-color: color-mix(in oklch, var(--color-warning) 80%, transparent);
860
+ }
861
+
750
862
  /* ── Typing dots ─────────────────────────────────────────────────────────── */
751
863
  .dot { width: 5px; height: 5px; background: currentColor; border-radius: 50%; display: inline-block; animation: blink 1.2s infinite; }
752
864
  .dot:nth-child(2) { animation-delay: .4s; }
@@ -999,6 +1111,26 @@
999
1111
  font-size: 10px;
1000
1112
  opacity: 0.75;
1001
1113
  }
1114
+ .ma-approval {
1115
+ font-size: 12px;
1116
+ background: color-mix(in oklch, var(--color-warning) 10%, var(--color-base-200));
1117
+ border: 1px solid color-mix(in oklch, var(--color-warning) 45%, var(--color-base-300));
1118
+ border-radius: 8px;
1119
+ padding: 8px 10px;
1120
+ margin-right: 8%;
1121
+ }
1122
+ .ma-approval-label { font-weight: 600; margin-bottom: 4px; }
1123
+ .ma-approval-cmd {
1124
+ font-family: ui-monospace, monospace;
1125
+ font-size: 11px;
1126
+ white-space: pre-wrap;
1127
+ word-break: break-all;
1128
+ background: var(--color-base-100);
1129
+ border-radius: 6px;
1130
+ padding: 6px 8px;
1131
+ margin: 4px 0;
1132
+ }
1133
+ .ma-approval-actions { display: flex; gap: 6px; justify-content: flex-end; }
1002
1134
  .ma-thumb {
1003
1135
  width: 56px;
1004
1136
  height: 56px;