claude-simple 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/gateway.js CHANGED
@@ -229,11 +229,16 @@ app.all('/machine/:machineId/*', async (req, res) => {
229
229
  }, body);
230
230
 
231
231
  // Don't forward hop-by-hop headers
232
- const skip = new Set(['transfer-encoding', 'connection', 'keep-alive']);
232
+ const skip = new Set(['transfer-encoding', 'connection', 'keep-alive', 'content-length']);
233
233
  Object.entries(result.headers || {}).forEach(([k, v]) => {
234
234
  if (!skip.has(k.toLowerCase())) res.setHeader(k, v);
235
235
  });
236
- res.status(result.status).send(result.body);
236
+ // Edge sends binary downloads as base64 (see client.js encoding:'base64').
237
+ let body = result.body ?? '';
238
+ if (String(result.encoding || '').toLowerCase() === 'base64' && typeof body === 'string') {
239
+ body = Buffer.from(body, 'base64');
240
+ }
241
+ res.status(result.status).send(body);
237
242
  } catch (e) {
238
243
  res.status(502).json({ error: e.message });
239
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
@@ -118,7 +118,16 @@ function panelHTML() {
118
118
  </label>
119
119
  <textarea id="ma-input" rows="1" placeholder="继续提问… (⌘↵ 发送) · 历史会话会带着上下文"
120
120
  class="textarea textarea-bordered flex-1 text-sm resize-none leading-relaxed"
121
- style="min-height:40px;max-height:120px"></textarea>
121
+ style="min-height:40px;max-height:120px"
122
+ name="meta-agent-message"
123
+ autocomplete="off"
124
+ autocorrect="off"
125
+ autocapitalize="off"
126
+ spellcheck="false"
127
+ data-1p-ignore="true"
128
+ data-lpignore="true"
129
+ data-form-type="other"
130
+ data-bwignore="true"></textarea>
122
131
  <button type="button" id="ma-send" class="btn btn-primary btn-sm" style="height:40px">发送</button>
123
132
  </div>
124
133
  </div>
package/public/chat.js CHANGED
@@ -788,9 +788,17 @@ function updateInputMode(value) {
788
788
  input.placeholder = isShell ? 'Shell command (without !)…' : `Ask ${label}… or !cmd for shell`;
789
789
  }
790
790
 
791
+ function chatInputMaxHeight() {
792
+ // Keep in sync with #chat-input max-height in style.css (mobile vs desktop).
793
+ if (typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches) {
794
+ return 96;
795
+ }
796
+ return 140;
797
+ }
798
+
791
799
  function autoResize(el) {
792
800
  el.style.height = 'auto';
793
- el.style.height = Math.min(el.scrollHeight, 140) + 'px';
801
+ el.style.height = Math.min(el.scrollHeight, chatInputMaxHeight()) + 'px';
794
802
  }
795
803
 
796
804
  // ── Message rendering ─────────────────────────────────────────────────────────
package/public/files.js CHANGED
@@ -111,7 +111,7 @@ function fileChrome(path, size, kindLabel) {
111
111
  return `
112
112
  <div class="flex items-center gap-2 px-4 py-1.5 border-b border-base-300 flex-shrink-0 bg-base-200">
113
113
  <span class="text-xs text-base-content/40 flex-1 truncate">${esc(path)} · ${esc(sizeStr)}${kindLabel ? ` · <span class="text-primary/70">${esc(kindLabel)}</span>` : ''}</span>
114
- <button class="btn btn-ghost btn-xs" data-download-path="${esc(path)}">↓ Download</button>
114
+ <button type="button" class="btn btn-ghost btn-xs" data-download-path="${esc(path)}">↓ Download</button>
115
115
  </div>`;
116
116
  }
117
117
 
@@ -127,10 +127,10 @@ function renderDir(files) {
127
127
  <span class="cursor-pointer flex-shrink-0">${f.isDir ? '📁' : fileIcon(f.name)}</span>
128
128
  <span class="text-sm flex-1 truncate cursor-pointer ${f.isDir ? 'text-primary' : ''}">${esc(f.name)}</span>
129
129
  ${!f.isDir ? `
130
- <button class="opacity-0 group-hover:opacity-100 btn btn-ghost btn-xs px-1 text-base-content/40 hover:text-base-content"
131
- data-download-path="${esc(f.path)}" title="Download">↓</button>
132
- <button class="opacity-0 group-hover:opacity-100 btn btn-ghost btn-xs px-1 text-error/60 hover:text-error"
133
- data-delete-path="${esc(f.path)}" title="Delete">✕</button>` : ''}
130
+ <button type="button" class="file-row-action btn btn-ghost btn-xs px-1.5 text-base-content/50 hover:text-base-content"
131
+ data-download-path="${esc(f.path)}" title="Download" aria-label="Download">↓</button>
132
+ <button type="button" class="file-row-action btn btn-ghost btn-xs px-1.5 text-error/60 hover:text-error"
133
+ data-delete-path="${esc(f.path)}" title="Delete" aria-label="Delete">✕</button>` : ''}
134
134
  </div>`).join('');
135
135
  }
136
136
 
@@ -343,19 +343,52 @@ function triggerUpload() {
343
343
  input.click();
344
344
  }
345
345
 
346
- function downloadFile(proj, filePath) {
346
+ async function downloadFile(proj, filePath) {
347
347
  const root = filesRoot.peek() || proj?.path || '';
348
+ if (!root) {
349
+ alert('No path selected — set root first');
350
+ return;
351
+ }
348
352
  const id = proj?.id || '_';
349
- const qs = authQuery({
350
- root,
351
- path: filePath,
352
- download: 'true',
353
- });
353
+ const name = (filePath || '').split('/').pop() || 'download';
354
+ // Prefer Authorization header (no token in URL / logs). authQuery kept as
355
+ // fallback for environments that only support plain navigations.
356
+ const qs = new URLSearchParams({ root, path: filePath, download: 'true' });
354
357
  const url = `/api/projects/${id}/file?${qs}`;
355
- const a = document.createElement('a');
356
- a.href = url;
357
- a.download = filePath.split('/').pop();
358
- a.click();
358
+ try {
359
+ const res = await fetch(url, { headers: authHeaders(), credentials: 'same-origin' });
360
+ if (!res.ok) {
361
+ const d = await res.json().catch(() => ({}));
362
+ throw new Error(d.error || `HTTP ${res.status}`);
363
+ }
364
+ const blob = await res.blob();
365
+ const objectUrl = URL.createObjectURL(blob);
366
+ const a = document.createElement('a');
367
+ a.href = objectUrl;
368
+ a.download = name;
369
+ a.rel = 'noopener';
370
+ a.style.display = 'none';
371
+ document.body.appendChild(a);
372
+ a.click();
373
+ a.remove();
374
+ // iOS may need the URL briefly; revoke after a beat.
375
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
376
+ } catch (e) {
377
+ // Last resort: open with token query (works when fetch is blocked, e.g. some WebViews).
378
+ try {
379
+ const fallback = `/api/projects/${id}/file?${authQuery({ root, path: filePath, download: 'true' })}`;
380
+ const a = document.createElement('a');
381
+ a.href = fallback;
382
+ a.download = name;
383
+ a.rel = 'noopener';
384
+ a.target = '_blank';
385
+ document.body.appendChild(a);
386
+ a.click();
387
+ a.remove();
388
+ } catch {
389
+ alert(`Download failed: ${e.message}`);
390
+ }
391
+ }
359
392
  }
360
393
 
361
394
  async function deleteFile(proj, filePath) {
@@ -447,13 +480,19 @@ export function initFilesTab() {
447
480
  delegate.on('click', '[data-dir-path]', (_, el) => { viewingFile.value = null; filesPath.value = el.dataset.dirPath; });
448
481
  delegate.on('click', '[data-file-path]', (_, el) => { viewingFile.value = el.dataset.filePath; });
449
482
  delegate.on('click', '#btn-upload', () => triggerUpload());
450
- delegate.on('click', '[data-download-path]', (_, el) => {
451
- const proj = currentProject.peek();
452
- if (proj) downloadFile(proj, el.dataset.downloadPath);
483
+ delegate.on('click', '[data-download-path]', (e, el) => {
484
+ e.preventDefault();
485
+ e.stopPropagation();
486
+ const path = el.dataset.downloadPath;
487
+ if (!path) return;
488
+ downloadFile(currentProject.peek(), path);
453
489
  });
454
- delegate.on('click', '[data-delete-path]', (_, el) => {
455
- const proj = currentProject.peek();
456
- if (proj) deleteFile(proj, el.dataset.deletePath);
490
+ delegate.on('click', '[data-delete-path]', (e, el) => {
491
+ e.preventDefault();
492
+ e.stopPropagation();
493
+ const path = el.dataset.deletePath;
494
+ if (!path) return;
495
+ deleteFile(currentProject.peek(), path);
457
496
  });
458
497
  // Excel sheet tabs
459
498
  delegate.on('click', '.office-sheet-tab', (_, el) => {
package/public/index.html CHANGED
@@ -65,10 +65,10 @@
65
65
  <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
66
66
  <link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css">
67
67
  <script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
68
- <link rel="stylesheet" href="style.css?v=safe-3">
68
+ <link rel="stylesheet" href="style.css?v=ui-8">
69
69
  </head>
70
70
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
71
71
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
72
- <script type="module" src="app.js"></script>
72
+ <script type="module" src="app.js?v=ui-8"></script>
73
73
  </body>
74
74
  </html>
@@ -224,14 +224,21 @@ export function showSelectedDaySessions(sessions) {
224
224
  }).join('');
225
225
  }
226
226
 
227
+ /** How many sessions in the topbar “最近” quick-jump menu. */
228
+ export const RECENT_MENU_LIMIT = 15;
229
+
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))
234
+ .slice(0, limit);
235
+ }
236
+
227
237
  export function updateRecentSessionsList(sessions) {
228
238
  const container = $('recent-sessions-list');
229
239
  if (!container) return;
230
240
 
231
- sessions = asSessionArray(sessions);
232
- const recent = [...sessions]
233
- .sort((a, b) => (coerceTs(b.updatedAt) || 0) - (coerceTs(a.updatedAt) || 0))
234
- .slice(0, 6);
241
+ const recent = sortRecentSessions(sessions, 6);
235
242
 
236
243
  if (!recent.length) {
237
244
  container.innerHTML = `<div class="col-span-full text-center text-xs text-base-content/40 py-4">No recent sessions</div>`;
@@ -258,6 +265,57 @@ export function updateRecentSessionsList(sessions) {
258
265
  }).join('');
259
266
  }
260
267
 
268
+ export function closeRecentMenu() {
269
+ $('recent-menu')?.classList.add('hidden');
270
+ }
271
+
272
+ /** Topbar quick-jump: last N sessions by updatedAt. */
273
+ export function renderRecentSessionsMenu(sessions) {
274
+ const listEl = $('recent-menu-list');
275
+ const emptyEl = $('recent-menu-empty');
276
+ const countEl = $('recent-menu-count');
277
+ if (!listEl) return;
278
+
279
+ const recent = sortRecentSessions(sessions ?? getDashboardSessions(), RECENT_MENU_LIMIT);
280
+ if (countEl) countEl.textContent = recent.length ? `${recent.length}` : '';
281
+
282
+ if (!recent.length) {
283
+ listEl.innerHTML = '';
284
+ emptyEl?.classList.remove('hidden');
285
+ return;
286
+ }
287
+ emptyEl?.classList.add('hidden');
288
+
289
+ listEl.innerHTML = recent.map(s => {
290
+ const timeStr = formatTime(s.updatedAt);
291
+ const active = ctx.sessionId === s.sessionId
292
+ && (!s.machineId || !ctx.machineId || s.machineId === ctx.machineId);
293
+ const machine = s.machineId
294
+ ? `<span class="text-[9px] font-mono text-base-content/35 truncate max-w-[4.5rem]" title="${esc(s.machineId)}">@${esc(s.machineId)}</span>`
295
+ : '';
296
+ return `
297
+ <button type="button"
298
+ class="recent-menu-item w-full text-left px-2 py-1.5 rounded-md flex items-start gap-2
299
+ ${active ? 'bg-primary/15 text-primary' : 'hover:bg-base-200'}"
300
+ data-session-id="${esc(s.sessionId)}"
301
+ data-session-cwd="${esc(s.cwd || '')}"
302
+ data-session-config-dir="${esc(s.configDir || '')}"
303
+ data-session-agent="${esc(s.agent || 'claude')}"
304
+ data-session-machine="${esc(s.machineId || '')}">
305
+ <span class="flex-shrink-0 mt-0.5">${agentBadge(s.agent)}</span>
306
+ <span class="flex-1 min-w-0">
307
+ <span class="block text-xs font-semibold truncate leading-snug">${esc(sessionDisplayTitle(s))}</span>
308
+ <span class="block text-[10px] text-base-content/40 truncate mt-0.5">${esc(s.projectName || shortPath(s.cwd) || '—')}</span>
309
+ </span>
310
+ <span class="flex flex-col items-end gap-0.5 flex-shrink-0 self-start">
311
+ <span class="text-[9px] text-base-content/40 whitespace-nowrap">${esc(timeStr)}</span>
312
+ ${machine}
313
+ ${active ? '<span class="text-[9px] opacity-70">当前</span>' : ''}
314
+ </span>
315
+ </button>`;
316
+ }).join('');
317
+ }
318
+
261
319
  export function setCalMonthYear(m, y) { calMonth = m; calYear = y; }
262
320
  export function setSelectedCalDate(d) { selectedCalDate = d; }
263
321
 
@@ -165,7 +165,8 @@ function isMetaEmpty(m) {
165
165
  }
166
166
 
167
167
  export async function toggleSessionFavorite(sessionId, agent, { machineId } = {}) {
168
- const key = metaKey(agent, sessionId, machineId);
168
+ const mid = machineId || ctx.machineId || null;
169
+ const key = metaKey(agent, sessionId, mid);
169
170
  const prev = sessionMetaMap.peek()?.[key] || emptyMeta();
170
171
  const nextFav = !prev.favorite;
171
172
  sessionMetaMap.value = {
@@ -173,7 +174,9 @@ export async function toggleSessionFavorite(sessionId, agent, { machineId } = {}
173
174
  [key]: { ...prev, favorite: nextFav, updatedAt: Date.now() },
174
175
  };
175
176
  try {
176
- const res = await api('PUT', '/api/sessions/meta', { sessionId, agent, favorite: nextFav });
177
+ const res = await api('PUT', '/api/sessions/meta', {
178
+ sessionId, agent, favorite: nextFav, machineId: mid || undefined,
179
+ }, undefined, { machineId: mid || undefined });
177
180
  sessionMetaMap.value = {
178
181
  ...sessionMetaMap.peek(),
179
182
  [key]: mergeMeta(prev, {
@@ -193,7 +196,8 @@ export async function toggleSessionFavorite(sessionId, agent, { machineId } = {}
193
196
  /** Hide / unhide a session (soft archive in the sidebar). */
194
197
  export async function toggleSessionHidden(sessionId, agent, { machineId } = {}) {
195
198
  if (!sessionId) return false;
196
- const key = metaKey(agent, sessionId, machineId);
199
+ const mid = machineId || ctx.machineId || null;
200
+ const key = metaKey(agent, sessionId, mid);
197
201
  const prev = sessionMetaMap.peek()?.[key] || emptyMeta();
198
202
  const nextHidden = !prev.hidden;
199
203
  sessionMetaMap.value = {
@@ -201,7 +205,9 @@ export async function toggleSessionHidden(sessionId, agent, { machineId } = {})
201
205
  [key]: { ...prev, hidden: nextHidden, updatedAt: Date.now() },
202
206
  };
203
207
  try {
204
- const res = await api('PUT', '/api/sessions/meta', { sessionId, agent, hidden: nextHidden });
208
+ const res = await api('PUT', '/api/sessions/meta', {
209
+ sessionId, agent, hidden: nextHidden, machineId: mid || undefined,
210
+ }, undefined, { machineId: mid || undefined });
205
211
  const next = mergeMeta(prev, {
206
212
  favorite: res.favorite,
207
213
  notes: res.notes,
@@ -224,11 +230,17 @@ export async function toggleSessionHidden(sessionId, agent, { machineId } = {})
224
230
  return true;
225
231
  }
226
232
 
227
- export async function saveSessionNotes(sessionId, agent, notes) {
228
- const key = metaKey(agent, sessionId);
233
+ export async function saveSessionNotes(sessionId, agent, notes, { machineId } = {}) {
234
+ const mid = machineId || ctx.machineId || null;
235
+ const key = metaKey(agent, sessionId, mid);
229
236
  const prev = sessionMetaMap.peek()?.[key] || emptyMeta();
230
237
  try {
231
- const res = await api('PUT', '/api/sessions/meta', { sessionId, agent, notes });
238
+ const res = await api('PUT', '/api/sessions/meta', {
239
+ sessionId,
240
+ agent,
241
+ notes,
242
+ machineId: mid || undefined,
243
+ }, undefined, { machineId: mid || undefined });
232
244
  sessionMetaMap.value = {
233
245
  ...sessionMetaMap.peek(),
234
246
  [key]: mergeMeta(prev, {
@@ -251,7 +263,8 @@ export async function saveSessionNotes(sessionId, agent, notes) {
251
263
  */
252
264
  export async function renameSession(sessionId, agent, title, { machineId } = {}) {
253
265
  if (!sessionId) return false;
254
- const key = metaKey(agent, sessionId, machineId);
266
+ const mid = machineId || ctx.machineId || null;
267
+ const key = metaKey(agent, sessionId, mid);
255
268
  const prev = sessionMetaMap.peek()?.[key] || emptyMeta();
256
269
  const nextTitle = String(title || '').trim().slice(0, 200);
257
270
  sessionMetaMap.value = {
@@ -259,7 +272,9 @@ export async function renameSession(sessionId, agent, title, { machineId } = {})
259
272
  [key]: { ...prev, title: nextTitle, updatedAt: Date.now() },
260
273
  };
261
274
  try {
262
- const res = await api('PUT', '/api/sessions/meta', { sessionId, agent, title: nextTitle });
275
+ const res = await api('PUT', '/api/sessions/meta', {
276
+ sessionId, agent, title: nextTitle, machineId: mid || undefined,
277
+ }, undefined, { machineId: mid || undefined });
263
278
  const next = mergeMeta(prev, {
264
279
  favorite: res.favorite,
265
280
  notes: res.notes,
@@ -3,7 +3,7 @@ import { batch, esc, $ } from '../lib.js';
3
3
  import {
4
4
  sessionsData, sessionFilter, filesPath, viewingFile, filteredSessions,
5
5
  currentProject, currentTab, filesRoot, gitRoot, currentAgent, setAgent,
6
- selectedMachineId, setSelectedMachine, AGENT_LABELS, ctx,
6
+ selectedMachineId, setSelectedMachine, AGENT_LABELS, ctx, hubMode,
7
7
  } from '../state.js';
8
8
  import { api } from '../api.js';
9
9
  import { getLastSessionContext, setLastSessionContext } from './session-context.js';
@@ -182,14 +182,23 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
182
182
  for (; i < end; i++) {
183
183
  const m = msgs[i];
184
184
  const ts = coerceTs(m.ts ?? m.timestamp);
185
- if (m.type === 'text') {
185
+ // Accept both normalized history rows and a few legacy shapes.
186
+ const typ = m.type || (m.role ? 'text' : null);
187
+ if (typ === 'text' || (m.role && m.content != null && !m.name)) {
186
188
  if (m.role === 'assistant' || m.role === 'user') flushToolBatch();
187
- appendMsg(m.role === 'user' ? 'user' : 'assistant', m.role === 'user' ? 'You' : label, m.content, { ts });
188
- } else if (m.type === 'tool_use') {
189
- renderToolUse({ name: m.name, input: m.input ?? {} }, { ts });
190
- } else if (m.type === 'token_usage') {
189
+ const content = typeof m.content === 'string'
190
+ ? m.content
191
+ : (Array.isArray(m.content)
192
+ ? m.content.map(p => p?.text || '').filter(Boolean).join('\n')
193
+ : String(m.content ?? ''));
194
+ if (content) {
195
+ appendMsg(m.role === 'user' ? 'user' : 'assistant', m.role === 'user' ? 'You' : label, content, { ts });
196
+ }
197
+ } else if (typ === 'tool_use' || m.name) {
198
+ renderToolUse({ name: m.name || 'tool', input: m.input ?? m.arguments ?? {} }, { ts });
199
+ } else if (typ === 'token_usage' || m.usage) {
191
200
  flushToolBatch();
192
- appendHistoryTokenBar(m.usage);
201
+ appendHistoryTokenBar(m.usage || m);
193
202
  }
194
203
  }
195
204
  if (i < msgs.length) {
@@ -209,29 +218,52 @@ export async function resumeSession(sid, cwd, configDir, agent, machineId, opts
209
218
  };
210
219
 
211
220
  const loadHistory = async () => {
212
- // Instant re-open from in-memory cache
221
+ // Instant re-open from in-memory cache (never trust empty cache — race/stale)
213
222
  const cached = historyCache.get(cacheKey);
214
- if (cached && Date.now() - cached.at < HISTORY_CACHE_TTL) {
223
+ if (cached && cached.msgs?.length && Date.now() - cached.at < HISTORY_CACHE_TTL) {
215
224
  paintMessages(cached.msgs, cached.context, cached.meta);
216
225
  return;
217
226
  }
218
227
  appendSystemMsg(`Loading ${label} history…`);
219
228
  try {
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' });
227
- const data = await api('GET', `/api/sessions/${sid}/messages?${q}`);
228
- const msgs = Array.isArray(data) ? data : (data.messages || []);
229
- const context = Array.isArray(data) ? null : (data.context || null);
230
- const meta = Array.isArray(data) ? {} : { truncated: data.truncated, total: data.total };
231
- historyCache.set(cacheKey, { msgs, context, meta, at: Date.now() });
229
+ // Hub mode must route to the edge that owns the session.
230
+ const mid = resolvedMachine || selectedMachineId.peek() || ctx.machineId || null;
231
+ if (hubMode.peek() && !mid) {
232
+ throw new Error('未选择机器,无法加载历史');
233
+ }
234
+ if (mid && ctx.machineId !== mid) setSelectedMachine(mid);
235
+
236
+ // Full history (tail=0); tool inputs compacted server-side (~100 chars each).
237
+ const fetchMsgs = async (agent) => {
238
+ const q = new URLSearchParams({
239
+ tail: '0',
240
+ compact: '1',
241
+ });
242
+ if (agent) q.set('agent', agent);
243
+ // Also put machine in query for proxies that drop custom headers.
244
+ if (mid) q.set('machine', mid);
245
+ return api('GET', `/api/sessions/${sid}/messages?${q}`, undefined, undefined, {
246
+ machineId: mid || undefined,
247
+ dedupe: false,
248
+ });
249
+ };
250
+
251
+ let data = await fetchMsgs(resolvedAgent);
252
+ let msgs = Array.isArray(data) ? data : (data?.messages || []);
253
+ // Prefer stated agent; if empty, retry scanning all agents on the edge.
254
+ if (!msgs.length && resolvedAgent) {
255
+ data = await fetchMsgs(null);
256
+ msgs = Array.isArray(data) ? data : (data?.messages || []);
257
+ }
258
+
259
+ const context = Array.isArray(data) ? null : (data?.context || null);
260
+ const meta = Array.isArray(data) ? {} : { truncated: data?.truncated, total: data?.total };
261
+ if (msgs.length) historyCache.set(cacheKey, { msgs, context, meta, at: Date.now() });
262
+ else historyCache.delete(cacheKey);
232
263
  paintMessages(msgs, context, meta);
233
264
  } catch (e) {
234
265
  if (ctx.sessionId !== sid) return;
266
+ historyCache.delete(cacheKey);
235
267
  $('messages').innerHTML = '';
236
268
  appendSystemMsg(`${label} · ${sid.slice(0, 8)}… (history unavailable: ${e.message})`);
237
269
  }
@@ -60,7 +60,23 @@ export const AppShell = (opts = {}) => {
60
60
  <p id="machine-menu-empty" class="text-xs text-base-content/40 px-1 py-2 hidden">暂无机器在线</p>
61
61
  </div>
62
62
  </div>
63
- <span id="topbar-project" class="text-sm text-base-content/50 flex-1 truncate">Select a session</span>
63
+ <div id="recent-menu-wrap" class="relative flex-shrink-0">
64
+ <button type="button" id="btn-recent-sessions" class="btn btn-ghost btn-xs border border-base-300 gap-1"
65
+ title="最近会话 / 快速跳转">
66
+ <span>最近</span>
67
+ <span class="text-[10px] opacity-50">▾</span>
68
+ </button>
69
+ <div id="recent-menu" class="hidden absolute left-0 top-full mt-1 z-50 w-80 max-h-[min(24rem,70vh)] overflow-y-auto
70
+ rounded-lg border border-base-300 bg-base-100 shadow-xl p-2">
71
+ <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]">
72
+ <span class="text-[10px] uppercase tracking-wide text-base-content/45 font-semibold">Recent sessions</span>
73
+ <span id="recent-menu-count" class="text-[10px] text-base-content/35 font-mono"></span>
74
+ </div>
75
+ <div id="recent-menu-list" class="flex flex-col gap-0.5"></div>
76
+ <p id="recent-menu-empty" class="text-xs text-base-content/40 px-1 py-2 hidden">暂无最近会话</p>
77
+ </div>
78
+ </div>
79
+ <span id="topbar-project" class="text-sm text-base-content/50 flex-1 truncate min-w-0">Select a session</span>
64
80
  <span id="conn-dot" class="w-2 h-2 rounded-full bg-base-content/30 flex-shrink-0" title="连接状态"></span>
65
81
  <div class="flex gap-2">
66
82
  <button id="btn-refresh-conn" class="btn btn-ghost btn-xs border border-base-300" title="重新连接并同步">↻</button>
@@ -263,13 +279,21 @@ export const AppShell = (opts = {}) => {
263
279
  <option value="full">density: full</option>
264
280
  </select>
265
281
  </div>
266
- <div class="flex gap-2 items-end px-3 pt-2 pb-2">
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>
282
+ <div id="composer-row" class="flex gap-2 items-end px-3 pt-2 pb-2">
283
+ <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">⚙</button>
268
284
  <textarea id="chat-input" rows="1" placeholder="Ask… (!cmd shell · ⌘↵ send)"
269
285
  class="chat-input flex-1 text-sm resize-none leading-relaxed"
270
- style="min-height:40px;max-height:140px"></textarea>
271
- <button id="send-btn" class="btn btn-primary btn-sm flex-shrink-0" style="height:40px">Send</button>
272
- <button id="stop-btn" class="btn btn-error btn-sm hidden flex-shrink-0" style="height:40px">■</button>
286
+ name="agent-chat-message"
287
+ autocomplete="off"
288
+ autocorrect="off"
289
+ autocapitalize="off"
290
+ spellcheck="false"
291
+ data-1p-ignore="true"
292
+ data-lpignore="true"
293
+ data-form-type="other"
294
+ data-bwignore="true"></textarea>
295
+ <button id="send-btn" class="btn btn-primary btn-sm flex-shrink-0">Send</button>
296
+ <button id="stop-btn" class="btn btn-error btn-sm hidden flex-shrink-0">■</button>
273
297
  </div>
274
298
  </div>
275
299
  </div>
package/public/shell.js CHANGED
@@ -24,7 +24,10 @@ import { initSettings, openSettings } from './settings.js';
24
24
  import { openMetaAgent, toggleMetaAgent } from './agent-panel.js';
25
25
 
26
26
  import { AuthScreen, AppShell } from './shell/templates.js';
27
- import { updateDashboard, shiftCalMonth, setSelectedCalDate, getDashboardSessions } from './shell/dashboard.js';
27
+ import {
28
+ updateDashboard, shiftCalMonth, setSelectedCalDate, getDashboardSessions,
29
+ renderRecentSessionsMenu, closeRecentMenu,
30
+ } from './shell/dashboard.js';
28
31
  import {
29
32
  loadAllSessions, loadWorkspaces, renderSessionList, syncSidebarChrome,
30
33
  refreshModelSelect, refreshEffortSelect, promptCustomModel,
@@ -555,6 +558,7 @@ export function initShell(opts = {}) {
555
558
  });
556
559
  delegate.on('click', '#btn-machine-menu', async e => {
557
560
  e.stopPropagation();
561
+ closeRecentMenu();
558
562
  const menu = $('machine-menu');
559
563
  if (!menu) return;
560
564
  if (menu.classList.contains('hidden')) {
@@ -565,9 +569,27 @@ export function initShell(opts = {}) {
565
569
  menu.classList.add('hidden');
566
570
  }
567
571
  });
572
+ delegate.on('click', '#btn-recent-sessions', e => {
573
+ e.stopPropagation();
574
+ closeMachineMenu();
575
+ const menu = $('recent-menu');
576
+ if (!menu) return;
577
+ if (menu.classList.contains('hidden')) {
578
+ renderRecentSessionsMenu(getDashboardSessions());
579
+ menu.classList.remove('hidden');
580
+ } else {
581
+ menu.classList.add('hidden');
582
+ }
583
+ });
568
584
  document.addEventListener('click', e => {
569
- const wrap = $('machine-menu-wrap');
570
- if (wrap && !wrap.contains(e.target)) closeMachineMenu();
585
+ const machineWrap = $('machine-menu-wrap');
586
+ if (machineWrap && !machineWrap.contains(e.target)) closeMachineMenu();
587
+ const recentWrap = $('recent-menu-wrap');
588
+ if (recentWrap && !recentWrap.contains(e.target)) closeRecentMenu();
589
+ });
590
+ // After picking a session from the topbar menu, close it (row click also resumes).
591
+ delegate.on('click', '#recent-menu [data-session-id]', () => {
592
+ closeRecentMenu();
571
593
  });
572
594
 
573
595
  delegate.on('click', '#btn-edit-project-notes, #project-goal-text, #project-notes-text', () => {