claude-simple 0.2.9 → 0.2.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-simple",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
@@ -19,11 +19,13 @@
19
19
  "public"
20
20
  ],
21
21
  "scripts": {
22
- "build": "node build.mjs",
22
+ "assets": "node scripts/sync-vendor.mjs && npx @tailwindcss/cli -i public/base.css -o public/ui.min.css --minify",
23
+ "build": "npm run assets && node build.mjs",
23
24
  "prepublishOnly": "node build.mjs",
24
25
  "start": "NODE_ENV=production node server.js",
25
26
  "dev": "NODE_ENV=development node --watch server.js",
26
27
  "serve": "node server.js",
28
+ "test": "node --test",
27
29
  "gateway": "node gateway.js",
28
30
  "gateway:go": "cd gateway && go run .",
29
31
  "gateway:build": "cd gateway && make build",
@@ -31,10 +33,16 @@
31
33
  },
32
34
  "dependencies": {
33
35
  "@anthropic-ai/claude-agent-sdk": "*",
36
+ "@highlightjs/cdn-assets": "^11.11.1",
34
37
  "bcrypt": "^5.1.1",
35
38
  "express": "^4.18.2",
36
39
  "jsonwebtoken": "^9.0.2",
37
- "ws": "^8.16.0"
40
+ "jszip": "^3.10.1",
41
+ "mammoth": "^1.8.0",
42
+ "marked": "^9.1.6",
43
+ "pdfjs-dist": "^3.11.174",
44
+ "ws": "^8.16.0",
45
+ "xlsx": "^0.18.5"
38
46
  },
39
47
  "devDependencies": {
40
48
  "@tailwindcss/cli": "^4.2.4",
package/public/chat.js CHANGED
@@ -370,7 +370,7 @@ function handleWsMessage(msg) {
370
370
  finalizeThoughtStream();
371
371
  finalizeAssistantStream();
372
372
  flushToolBatch();
373
- // result already printed "Done" for codex/grok; avoid double for those
373
+ // result already printed "Done" for CLI agents; avoid double for those
374
374
  if (!msg.aborted && msg.agent === 'claude') appendSystemMsg('✓ Done');
375
375
  else if (!msg.aborted && msg.agent && msg.agent !== 'claude' && !msg.error) { /* result handled it */ }
376
376
  else if (!msg.aborted && !msg.agent) appendSystemMsg('✓ Done');
@@ -576,12 +576,12 @@ function handleSlashCommand(text) {
576
576
  switch (cmd.toLowerCase()) {
577
577
  case 'agent': {
578
578
  if (!arg) {
579
- appendSystemMsg(`Current agent: ${currentAgent.peek()} · agents: claude | codex | grok`);
579
+ appendSystemMsg(`Current agent: ${currentAgent.peek()} · agents: claude | codex | grok | gemini | cursor`);
580
580
  return true;
581
581
  }
582
582
  const a = arg.toLowerCase();
583
- if (!['claude', 'codex', 'grok'].includes(a)) {
584
- appendSystemMsg(`Unknown agent "${arg}" · valid: claude | codex | grok`);
583
+ if (!['claude', 'codex', 'grok', 'gemini', 'cursor'].includes(a)) {
584
+ appendSystemMsg(`Unknown agent "${arg}" · valid: claude | codex | grok | gemini | cursor`);
585
585
  return true;
586
586
  }
587
587
  // Switching agent starts a fresh conversation
@@ -639,7 +639,7 @@ function handleSlashCommand(text) {
639
639
  return true;
640
640
  }
641
641
  case 'help': {
642
- appendSystemMsg(`Available slash commands:\n /agent [claude|codex|grok]\n /model [haiku|sonnet|opus|<model-id>]\n /effort [low|medium|high|xhigh|max]\n /usage\n /clear\n /help`);
642
+ appendSystemMsg(`Available slash commands:\n /agent [claude|codex|grok|gemini|cursor]\n /model [haiku|sonnet|opus|<model-id>]\n /effort [low|medium|high|xhigh|max]\n /usage\n /clear\n /help`);
643
643
  return true;
644
644
  }
645
645
  default: {
package/public/files.js CHANGED
@@ -146,7 +146,7 @@ function renderTextFile(path, content, size) {
146
146
 
147
147
  // ── Office previews ───────────────────────────────────────────────────────────
148
148
  async function renderDocx(arrayBuffer) {
149
- await loadScript('https://cdn.jsdelivr.net/npm/mammoth@1.8.0/mammoth.browser.min.js');
149
+ await loadScript('/vendor/mammoth.browser.min.js');
150
150
  if (typeof mammoth === 'undefined') throw new Error('mammoth failed to load');
151
151
  const result = await mammoth.convertToHtml({ arrayBuffer });
152
152
  const warn = result.messages?.length
@@ -156,7 +156,7 @@ async function renderDocx(arrayBuffer) {
156
156
  }
157
157
 
158
158
  async function renderXlsx(arrayBuffer) {
159
- await loadScript('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js');
159
+ await loadScript('/vendor/xlsx.full.min.js');
160
160
  if (typeof XLSX === 'undefined') throw new Error('SheetJS failed to load');
161
161
  const wb = XLSX.read(new Uint8Array(arrayBuffer), { type: 'array' });
162
162
  const names = wb.SheetNames || [];
@@ -206,7 +206,7 @@ function pptxExtractSlideTexts(xml) {
206
206
  }
207
207
 
208
208
  async function renderPptx(arrayBuffer) {
209
- await loadScript('https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js');
209
+ await loadScript('/vendor/jszip.min.js');
210
210
  if (typeof JSZip === 'undefined') throw new Error('JSZip failed to load');
211
211
  const zip = await JSZip.loadAsync(arrayBuffer);
212
212
  const slideFiles = Object.keys(zip.files)
@@ -240,10 +240,10 @@ async function renderPptx(arrayBuffer) {
240
240
  }
241
241
 
242
242
  async function ensurePdfJs() {
243
- await loadScript('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js');
243
+ await loadScript('/vendor/pdf.min.js');
244
244
  if (typeof pdfjsLib === 'undefined') throw new Error('pdf.js failed to load');
245
245
  pdfjsLib.GlobalWorkerOptions.workerSrc =
246
- 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
246
+ '/vendor/pdf.worker.min.js';
247
247
  return pdfjsLib;
248
248
  }
249
249
 
package/public/index.html CHANGED
@@ -78,11 +78,10 @@
78
78
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
79
79
  <link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png">
80
80
  <title>Agent UI</title>
81
- <link href="https://cdn.jsdelivr.net/npm/daisyui@5/daisyui.css" rel="stylesheet">
82
- <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
83
- <script src="https://cdn.jsdelivr.net/npm/marked@9/marked.min.js"></script>
84
- <link id="hljs-theme" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css">
85
- <script src="https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/highlight.min.js"></script>
81
+ <link href="/ui.min.css" rel="stylesheet">
82
+ <script src="/vendor/marked.min.js"></script>
83
+ <link id="hljs-theme" rel="stylesheet" href="/vendor/highlight-dark.min.css">
84
+ <script src="/vendor/highlight.min.js"></script>
86
85
  <link rel="stylesheet" href="style.css?v=ui-11">
87
86
  </head>
88
87
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
package/public/lib.js CHANGED
@@ -3,5 +3,5 @@
3
3
  // unpinned @latest here means every push to the mini-react repo — tested or
4
4
  // not — goes live immediately. Bump deliberately after verifying against
5
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';
6
+ export * from './vendor/mini-react.min.js';
7
7
  // doc from https://cdn.jsdelivr.net/gh/forechoandlook/mini-react@v0.1.13/README.mini.md
package/public/router.js CHANGED
@@ -1,4 +1,4 @@
1
- // router.js — Hash routing: #/ #/session/:id #/session/:id/:tab
1
+ // router.js — Hash routing: #/ #/session/:id/:tab?machine=&agent=
2
2
  import { createRouter, watch } from './lib.js';
3
3
  import { currentTab, ctx } from './state.js';
4
4
 
@@ -26,15 +26,26 @@ export function initRouter(resumeSession, switchTab) {
26
26
 
27
27
  // Keep hash in sync when session/tab changes
28
28
  watch(currentTab, tab => {
29
- if (ctx.sessionId) setHash(`/session/${ctx.sessionId}/${tab}`);
29
+ if (ctx.sessionId) syncHash();
30
30
  });
31
31
  }
32
32
 
33
- /** Parse `#/session/:id/:tab?` → { id, tab } or null */
33
+ /** Parse `#/session/:id/:tab?machine=&agent=` → identity + tab, or null. */
34
34
  export function parseHashPath(path) {
35
- const m = (path || '').match(/^\/session\/([^/]+)(?:\/([^/]+))?$/);
35
+ const [pathname, query = ''] = String(path || '').split('?', 2);
36
+ const m = pathname.match(/^\/session\/([^/]+)(?:\/([^/]+))?$/);
36
37
  if (!m) return null;
37
- return { id: m[1], tab: m[2] || null };
38
+ const params = new URLSearchParams(query);
39
+ try {
40
+ return {
41
+ id: decodeURIComponent(m[1]),
42
+ tab: m[2] ? decodeURIComponent(m[2]) : null,
43
+ machineId: params.get('machine') || null,
44
+ agent: params.get('agent') || null,
45
+ };
46
+ } catch {
47
+ return null;
48
+ }
38
49
  }
39
50
 
40
51
  // Called by app.js when hash is not already handled by showApp deep-link
@@ -49,12 +60,12 @@ function applyHash(path) {
49
60
  document.dispatchEvent(new CustomEvent('router:home'));
50
61
  return;
51
62
  }
52
- const { id, tab } = parsed;
63
+ const { id, tab, machineId, agent } = parsed;
53
64
  if (!_resumeSession || !_switchTab) return;
54
65
 
55
66
  if (id !== ctx.sessionId) {
56
67
  // Need to look up cwd from loaded sessions — defer to shell
57
- document.dispatchEvent(new CustomEvent('router:session', { detail: { id, tab } }));
68
+ document.dispatchEvent(new CustomEvent('router:session', { detail: { id, tab, machineId, agent } }));
58
69
  } else if (tab && tab !== currentTab.peek()) {
59
70
  _switchTab(tab);
60
71
  }
@@ -67,5 +78,22 @@ export function setHash(path) {
67
78
 
68
79
  // Convenience: call after resumeSession completes to set URL
69
80
  export function syncHash() {
70
- if (ctx.sessionId) setHash(`/session/${ctx.sessionId}/${currentTab.peek()}`);
81
+ if (!ctx.sessionId) return;
82
+ const query = new URLSearchParams();
83
+ if (ctx.machineId) query.set('machine', ctx.machineId);
84
+ if (ctx.agent) query.set('agent', ctx.agent);
85
+ const suffix = query.size ? `?${query}` : '';
86
+ setHash(`/session/${encodeURIComponent(ctx.sessionId)}/${encodeURIComponent(currentTab.peek())}${suffix}`);
87
+ }
88
+
89
+ /** A portable, non-secret URL for the active session. Authentication is never embedded. */
90
+ export function currentSessionShareUrl() {
91
+ if (!ctx.sessionId) return null;
92
+ const url = new URL(location.href);
93
+ const query = new URLSearchParams();
94
+ if (ctx.machineId) query.set('machine', ctx.machineId);
95
+ if (ctx.agent) query.set('agent', ctx.agent);
96
+ const suffix = query.size ? `?${query}` : '';
97
+ url.hash = `/session/${encodeURIComponent(ctx.sessionId)}/${encodeURIComponent(currentTab.peek())}${suffix}`;
98
+ return url.toString();
71
99
  }
@@ -1,14 +1,14 @@
1
1
  // settings.js — Workspace settings modal
2
2
  import { esc, $ } from './lib.js';
3
3
  import { api } from './api.js';
4
- import { workspacesData } from './state.js';
4
+ import { workspacesData, hubMode, selectedMachineId } from './state.js';
5
5
  import { setCachedWorkspaces } from './cache.js';
6
6
 
7
7
  export function initSettings() {
8
8
  document.getElementById('root').insertAdjacentHTML('beforeend', `
9
9
  <dialog id="modal-settings" class="modal">
10
10
  <div class="modal-box max-w-lg">
11
- <h3 class="font-bold text-base mb-4">⚙️ Settings — Workspaces</h3>
11
+ <h3 class="font-bold text-base mb-4">Settings</h3>
12
12
  <div id="ws-list" class="flex flex-col gap-2 mb-4"></div>
13
13
  <div class="border-t border-base-300 pt-4">
14
14
  <div class="text-xs uppercase tracking-wider text-base-content/50 mb-2">Add workspace</div>
@@ -19,6 +19,25 @@ export function initSettings() {
19
19
  </div>
20
20
  <div id="ws-error" class="alert alert-error text-xs py-1.5 hidden"></div>
21
21
  </div>
22
+ <div id="mcp-token-settings" class="border-t border-base-300 pt-4 mt-4">
23
+ <div class="flex items-center justify-between gap-2 mb-2">
24
+ <div>
25
+ <div class="text-xs uppercase tracking-wider text-base-content/50">MCP access token</div>
26
+ <p class="text-[11px] text-base-content/45 mt-0.5">The token is shown once. Set its expiry before generating.</p>
27
+ </div>
28
+ <select id="mcp-token-expiry" class="select select-xs select-bordered">
29
+ <option value="86400">24 hours</option>
30
+ <option value="604800">7 days</option>
31
+ <option value="2592000">30 days</option>
32
+ </select>
33
+ </div>
34
+ <button id="mcp-token-generate" class="btn btn-sm btn-primary w-full">Generate MCP token</button>
35
+ <div id="mcp-token-result" class="hidden mt-2">
36
+ <textarea id="mcp-token-value" readonly class="textarea textarea-bordered textarea-xs w-full h-20 font-mono"></textarea>
37
+ <button id="mcp-token-copy" class="btn btn-ghost btn-xs mt-1">Copy token</button>
38
+ </div>
39
+ <div id="mcp-token-error" class="alert alert-error text-xs py-1.5 hidden mt-2"></div>
40
+ </div>
22
41
  <div class="modal-action">
23
42
  <form method="dialog"><button class="btn btn-ghost btn-sm">Close</button></form>
24
43
  </div>
@@ -31,6 +50,8 @@ export function initSettings() {
31
50
  document.getElementById('ws-add-btn').addEventListener('click', addWorkspace);
32
51
  document.getElementById('ws-new-name').addEventListener('keydown', e => { if (e.key === 'Enter') addWorkspace(); });
33
52
  document.getElementById('ws-new-dir').addEventListener('keydown', e => { if (e.key === 'Enter') addWorkspace(); });
53
+ document.getElementById('mcp-token-generate').addEventListener('click', generateMcpToken);
54
+ document.getElementById('mcp-token-copy').addEventListener('click', copyMcpToken);
34
55
  }
35
56
 
36
57
  function renderWsList() {
@@ -86,5 +107,36 @@ async function deleteWorkspace(id) {
86
107
 
87
108
  export function openSettings() {
88
109
  renderWsList();
110
+ const mcp = $('mcp-token-settings');
111
+ if (mcp) mcp.style.display = hubMode.peek() ? '' : 'none';
89
112
  $('modal-settings')?.showModal();
90
113
  }
114
+
115
+ async function generateMcpToken() {
116
+ const result = $('mcp-token-result');
117
+ const err = $('mcp-token-error');
118
+ result?.classList.add('hidden');
119
+ err?.classList.add('hidden');
120
+ const machineId = selectedMachineId.peek();
121
+ if (!machineId) {
122
+ err.textContent = '请选择一台在线机器后再生成 Token。';
123
+ err.classList.remove('hidden');
124
+ return;
125
+ }
126
+ try {
127
+ const expiresInSeconds = Number($('mcp-token-expiry').value);
128
+ const data = await api('POST', `/api/mcp/tokens?machine=${encodeURIComponent(machineId)}`, { expiresInSeconds });
129
+ $('mcp-token-value').value = data.token || '';
130
+ result.classList.remove('hidden');
131
+ } catch (e) {
132
+ err.textContent = e.message;
133
+ err.classList.remove('hidden');
134
+ }
135
+ }
136
+
137
+ async function copyMcpToken() {
138
+ const value = $('mcp-token-value')?.value;
139
+ if (!value) return;
140
+ try { await navigator.clipboard.writeText(value); }
141
+ catch { $('mcp-token-value')?.select(); document.execCommand('copy'); }
142
+ }
@@ -98,10 +98,10 @@ export async function showApp(opts = {}) {
98
98
  || (last?.sessionId === deep.id ? last : null);
99
99
  await resumeSession(
100
100
  deep.id,
101
- s?.cwd || last?.cwd || null,
102
- s?.configDir || last?.configDir || null,
103
- s?.agent || last?.agent || null,
104
- s?.machineId || last?.machineId || remembered || null,
101
+ s?.cwd || null,
102
+ s?.configDir || null,
103
+ deep.agent || s?.agent || null,
104
+ deep.machineId || s?.machineId || remembered || null,
105
105
  { tab: deep.tab || 'chat' },
106
106
  );
107
107
  // ensure machines eventually known
@@ -150,9 +150,9 @@ export async function showApp(opts = {}) {
150
150
  || (last?.sessionId === deep.id ? last : null);
151
151
  await resumeSession(
152
152
  deep.id,
153
- s?.cwd || last?.cwd || null,
154
- s?.configDir || last?.configDir || null,
155
- s?.agent || last?.agent || null,
153
+ s?.cwd || null,
154
+ s?.configDir || null,
155
+ deep.agent || s?.agent || null,
156
156
  null,
157
157
  { tab: deep.tab || 'chat' },
158
158
  );
@@ -113,9 +113,8 @@ export function applyTheme(theme) {
113
113
  }
114
114
  if (link) {
115
115
  const dark = theme === 'dark' || theme === 'night' || theme === 'black';
116
- link.href = dark
117
- ? 'https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github-dark.min.css'
118
- : 'https://cdn.jsdelivr.net/npm/@highlightjs/cdn-assets@11/styles/github.min.css';
116
+ // Keep a local theme so a Hub remains usable on disconnected/restricted networks.
117
+ link.href = '/vendor/highlight-dark.min.css';
119
118
  }
120
119
  }
121
120
 
@@ -23,11 +23,10 @@ function normalizeSessions(v) {
23
23
  }
24
24
 
25
25
  function sessionsQuery() {
26
- const days = parseInt(timeRange.peek() || '14', 10);
27
26
  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');
27
+ // Project index is small and should remain complete; the Hub/browser cache
28
+ // keeps this inexpensive after the first fetch.
29
+ q.set('limit', '2000');
31
30
  const s = q.toString();
32
31
  return s ? `/api/sessions?${s}` : '/api/sessions';
33
32
  }
@@ -274,7 +273,6 @@ export function renderProjectGroups(groups) {
274
273
  } else {
275
274
  open = forceOpen || key === topCwd || groups.length <= 3;
276
275
  }
277
- const latestTitle = g.latest?.display || g.latest?.snippet || '';
278
276
  const machineTag = g.machineId
279
277
  ? `<span class="text-[9px] font-mono text-base-content/40 flex-shrink-0">@${esc(g.machineId)}</span>`
280
278
  : '';
@@ -287,11 +285,6 @@ export function renderProjectGroups(groups) {
287
285
  ${machineTag}
288
286
  <span class="text-[9px] text-base-content/30 flex-shrink-0">${formatTime(g.updatedAt)}</span>
289
287
  </div>
290
- <div class="flex items-center gap-1.5 mt-0.5 pl-4">
291
- <span class="text-[10px] text-base-content/35 font-mono truncate flex-1" title="${esc(g.cwd || '')}">${esc(shortPath(g.cwd))}</span>
292
- <span class="flex items-center gap-0.5 flex-shrink-0">${agentCountPills(g.agents)}</span>
293
- </div>
294
- ${latestTitle && !open ? `<div class="pl-4 mt-0.5 text-[10px] text-base-content/45 truncate" title="${esc(latestTitle)}">↳ ${esc(latestTitle)}</div>` : ''}
295
288
  </div>`;
296
289
  const sessions = g.sessions.slice(0, 40);
297
290
  const more = g.sessions.length > 40
@@ -362,7 +355,7 @@ export function syncSidebarChrome() {
362
355
  status.textContent = 'Searching content…';
363
356
  } else if (q && hitResults) {
364
357
  status.classList.remove('hidden');
365
- status.textContent = `${hitResults.length} hit${hitResults.length === 1 ? '' : 's'} · ${timeRange.peek() === '0' ? 'all time' : timeRange.peek() + 'd'}${hits.deep === false ? '' : ' (incl. content)'}`;
358
+ status.textContent = `${hitResults.length} hit${hitResults.length === 1 ? '' : 's'}${hits.deep === false ? '' : ' (incl. content)'}`;
366
359
  } else if (q) {
367
360
  status.classList.remove('hidden');
368
361
  status.textContent = 'Local filter only — type to deep-search';
@@ -391,7 +384,7 @@ export async function runActivitySearch(q) {
391
384
  try {
392
385
  const params = new URLSearchParams({
393
386
  q: query,
394
- days: timeRange.peek() || '14',
387
+ days: '0',
395
388
  limit: '50',
396
389
  deep: '1',
397
390
  });
@@ -6,7 +6,7 @@ export const AuthScreen = () => `
6
6
  <div class="card bg-base-200 border border-base-300 w-full max-w-sm shadow-xl">
7
7
  <div class="card-body gap-3 p-8">
8
8
  <h1 class="text-xl font-bold">🤖 Agent UI</h1>
9
- <p id="auth-subtitle" class="text-sm text-base-content/60 mb-1">Claude · Codex · Grok</p>
9
+ <p id="auth-subtitle" class="text-sm text-base-content/60 mb-1">Claude · Codex · Grok · Gemini · Cursor</p>
10
10
  <form id="auth-form" class="flex flex-col gap-3" autocomplete="on" action="#">
11
11
  <label class="form-control">
12
12
  <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Username</span></div>
@@ -79,6 +79,7 @@ export const AppShell = (opts = {}) => {
79
79
  <span id="topbar-project" class="text-sm text-base-content/50 flex-1 truncate min-w-0">Select a session</span>
80
80
  <span id="conn-dot" class="w-2 h-2 rounded-full bg-base-content/30 flex-shrink-0" title="连接状态"></span>
81
81
  <div class="flex gap-2 topbar-actions">
82
+ <button id="btn-share-session" class="btn btn-ghost btn-xs border border-base-300" title="复制当前会话分享链接">分享</button>
82
83
  <button id="btn-refresh-conn" class="btn btn-ghost btn-xs border border-base-300" title="重新连接并同步">↻</button>
83
84
  <button id="btn-pwa-install" class="btn btn-ghost btn-xs border border-base-300 hidden" title="安装到主屏幕">安装</button>
84
85
  <button id="btn-meta-agent" class="btn btn-ghost btn-xs border border-primary/40 text-primary" title="Meta Agent — 报告 / 问答 / VLM"><span class="hidden sm:inline">✦ Agent</span><span class="sm:hidden">✦</span></button>
@@ -134,29 +135,26 @@ export const AppShell = (opts = {}) => {
134
135
  </div>
135
136
  </div>
136
137
  <div id="ws-tabs" class="flex overflow-x-auto border-b border-base-300 flex-shrink-0" style="scrollbar-width:none"></div>
137
- <div class="px-2 py-1.5 border-b border-base-300 flex-shrink-0 flex flex-col gap-1.5">
138
- <input id="session-search" type="text" placeholder="Search projects & recent work…"
139
- class="input input-xs input-bordered w-full text-xs" autocomplete="off">
140
- <div class="flex items-center gap-1 flex-wrap">
141
- <div id="view-tabs" class="flex rounded-md border border-base-300 overflow-hidden text-[10px]">
142
- <button data-view="projects" class="px-2 py-0.5 bg-primary/15 text-primary font-medium">Projects</button>
143
- <button data-view="timeline" class="px-2 py-0.5 text-base-content/50 hover:bg-base-300">Recent</button>
144
- </div>
145
- <select id="time-range" class="select select-xs select-bordered text-[10px] h-6 min-h-0 py-0 pl-1 pr-6" title="Time range">
146
- <option value="1">Today</option>
147
- <option value="7">7d</option>
148
- <option value="14" selected>14d</option>
149
- <option value="30">30d</option>
150
- <option value="0">All</option>
151
- </select>
138
+ <div class="px-2 py-1.5 border-b border-base-300 flex-shrink-0 relative">
139
+ <div class="flex gap-1.5">
140
+ <input id="session-search" type="text" placeholder="Search projects & recent work…"
141
+ class="input input-xs input-bordered flex-1 min-w-0 text-xs" autocomplete="off">
142
+ <button id="sidebar-filter-toggle" type="button" class="btn btn-ghost btn-xs border border-base-300 px-2" title="筛选项目" aria-expanded="false">⚙</button>
152
143
  </div>
153
- <div id="agent-filter" class="flex gap-1 flex-wrap">
154
- <button data-agent-filter="all" class="agent-pill active">All</button>
155
- <button data-agent-filter="claude" class="agent-pill">Claude</button>
156
- <button data-agent-filter="codex" class="agent-pill">Codex</button>
157
- <button data-agent-filter="grok" class="agent-pill">Grok</button>
158
- <button id="btn-fav-filter" class="agent-pill" title="Show only favorites">★ Fav</button>
159
- <button id="btn-hidden-filter" class="agent-pill" title="Show only hidden sessions">🙈 Hidden</button>
144
+ <div id="sidebar-filter-menu" class="hidden absolute left-2 right-2 top-[calc(100%-.25rem)] z-30 rounded-lg border border-base-300 bg-base-100 p-2 shadow-xl">
145
+ <div class="text-[10px] uppercase tracking-wide text-base-content/45 mb-1.5">Agents · multi-select</div>
146
+ <div class="grid grid-cols-2 gap-1 text-xs">
147
+ <label><input type="checkbox" data-agent-option="claude"> Claude</label>
148
+ <label><input type="checkbox" data-agent-option="codex"> Codex</label>
149
+ <label><input type="checkbox" data-agent-option="grok"> Grok</label>
150
+ <label><input type="checkbox" data-agent-option="gemini"> Gemini</label>
151
+ <label><input type="checkbox" data-agent-option="cursor"> Cursor</label>
152
+ </div>
153
+ <div class="mt-2 pt-2 border-t border-base-300 flex gap-3 text-xs">
154
+ <label><input id="filter-favorites" type="checkbox"> Favorites</label>
155
+ <label><input id="filter-hidden" type="checkbox"> Hidden</label>
156
+ <button id="filter-clear" type="button" class="ml-auto text-primary">Clear</button>
157
+ </div>
160
158
  </div>
161
159
  <div id="search-status" class="hidden text-[10px] text-base-content/40 px-0.5"></div>
162
160
  </div>
@@ -178,7 +176,7 @@ export const AppShell = (opts = {}) => {
178
176
  <div id="welcome" class="${welcomeCls}">
179
177
  <div class="max-w-4xl w-full text-center flex flex-col items-center gap-2 mt-2 md:mt-4">
180
178
  <h2 class="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent select-none">🤖 Agent UI</h2>
181
- <p class="text-sm text-base-content/65 max-w-md">Claude · Codex · Grok — Select a session from the sidebar or click a day on the calendar to view activity.</p>
179
+ <p class="text-sm text-base-content/65 max-w-md">Claude · Codex · Grok · Gemini — Select a session from the sidebar or click a day on the calendar to view activity.</p>
182
180
  <div class="flex gap-2 mt-0.5">
183
181
  <button id="btn-new-session-welcome" class="btn btn-primary btn-sm shadow-md">+ New Session</button>
184
182
  <button id="btn-meta-agent-welcome" class="btn btn-outline btn-primary btn-sm">✦ Meta Agent</button>
@@ -272,6 +270,8 @@ export const AppShell = (opts = {}) => {
272
270
  <option value="claude">Claude</option>
273
271
  <option value="codex">Codex</option>
274
272
  <option value="grok">Grok</option>
273
+ <option value="gemini">Gemini</option>
274
+ <option value="cursor">Cursor</option>
275
275
  </select>
276
276
  <select id="sel-convert-agent" class="select select-xs select-bordered text-xs font-semibold" title="Convert session to another agent">
277
277
  <option value="" disabled selected>Convert to...</option>
@@ -406,6 +406,8 @@ export const AppShell = (opts = {}) => {
406
406
  <option value="claude">Claude Code</option>
407
407
  <option value="codex">OpenAI Codex</option>
408
408
  <option value="grok">Grok</option>
409
+ <option value="gemini">Google Gemini (agy)</option>
410
+ <option value="cursor">Cursor Agent</option>
409
411
  </select>
410
412
  </label>
411
413
  <label class="form-control" id="new-session-machine-wrap" style="display:none">
@@ -39,10 +39,12 @@ export function agentBadge(agent, compact = true) {
39
39
  claude: 'bg-orange-500/15 text-orange-400',
40
40
  codex: 'bg-emerald-500/15 text-emerald-400',
41
41
  grok: 'bg-sky-500/15 text-sky-400',
42
+ gemini: 'bg-violet-500/15 text-violet-400',
43
+ cursor: 'bg-amber-500/15 text-amber-400',
42
44
  };
43
45
  const cls = colors[a] || 'bg-base-300 text-base-content/50';
44
46
  const label = compact
45
- ? (a === 'claude' ? 'CC' : a === 'codex' ? 'CX' : a === 'grok' ? 'GX' : a.slice(0, 2).toUpperCase())
47
+ ? (a === 'claude' ? 'CC' : a === 'codex' ? 'CX' : a === 'grok' ? 'GX' : a === 'gemini' ? 'GM' : a === 'cursor' ? 'CU' : a.slice(0, 2).toUpperCase())
46
48
  : (AGENT_LABELS[a] || a);
47
49
  return `<span class="text-[9px] px-1 py-0.5 rounded font-mono font-semibold flex-shrink-0 ${cls}" title="${esc(AGENT_LABELS[a] || a)}">${label}</span>`;
48
50
  }
package/public/shell.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  expandedFolders, collapsedFolders, filesPath, viewingFile, filteredSessions, projectGroups,
8
8
  currentProject, currentTab, filesRoot, gitRoot,
9
9
  currentModel, currentEffort, currentPermission, currentAgent, setAgent, setCurrentModel,
10
- sidebarView, agentFilter, timeRange, activityHits, activityLoading,
10
+ sidebarView, agentFilter, agentFilters, timeRange, activityHits, activityLoading,
11
11
  chatDensity, setChatDensity,
12
12
  favoritesOnly, showHiddenOnly, hubMode, hubMachineReady, selectedMachineId, setSelectedMachine,
13
13
  machinesList, AGENT_LABELS, getDefaultModel, sessionMetaMap, ctx,
@@ -20,7 +20,7 @@ import {
20
20
  connectWS, clearMessages, appendMsg, appendSystemMsg, sendMessage, stopProcessing,
21
21
  applyChatDensity,
22
22
  } from './chat.js';
23
- import { syncHash } from './router.js';
23
+ import { syncHash, currentSessionShareUrl } from './router.js';
24
24
  import { initSettings, openSettings } from './settings.js';
25
25
  import { openMetaAgent, toggleMetaAgent } from './agent-panel.js';
26
26
  import {
@@ -322,20 +322,31 @@ export function initShell(opts = {}) {
322
322
  });
323
323
  document.addEventListener('router:home', goHome);
324
324
 
325
- document.addEventListener('router:session', async ({ detail: { id, tab } }) => {
325
+ document.addEventListener('router:session', async ({ detail: { id, tab, machineId, agent } }) => {
326
326
  const s = sessionsData.peek().find(x => x.sessionId === id);
327
327
  const last = getLastSessionContext();
328
328
  await resumeSession(
329
329
  id,
330
330
  s?.cwd || (last?.sessionId === id ? last.cwd : null) || null,
331
331
  s?.configDir || (last?.sessionId === id ? last.configDir : null) || null,
332
- s?.agent || (last?.sessionId === id ? last.agent : null) || null,
333
- s?.machineId || (last?.sessionId === id ? last.machineId : null) || null,
332
+ agent || s?.agent || (last?.sessionId === id ? last.agent : null) || null,
333
+ machineId || s?.machineId || (last?.sessionId === id ? last.machineId : null) || null,
334
334
  { tab: tab || 'chat' },
335
335
  );
336
336
  });
337
337
 
338
338
  delegate.on('click', '#btn-home', goHome);
339
+ delegate.on('click', '#btn-share-session', async (_, el) => {
340
+ const url = currentSessionShareUrl();
341
+ if (!url) return;
342
+ try {
343
+ await navigator.clipboard.writeText(url);
344
+ el.textContent = '已复制';
345
+ setTimeout(() => { el.textContent = '分享'; }, 1400);
346
+ } catch {
347
+ window.prompt('复制会话链接:', url);
348
+ }
349
+ });
339
350
  delegate.on('click', '#auth-btn', submitAuth);
340
351
  delegate.on('submit', '#auth-form', e => { e.preventDefault(); submitAuth(); });
341
352
  delegate.on('keydown', '#auth-username, #auth-password', e => { if (e.key === 'Enter') submitAuth(); });
@@ -353,6 +364,41 @@ export function initShell(opts = {}) {
353
364
  sessionSearch.value = el.value;
354
365
  scheduleActivitySearch(el.value);
355
366
  });
367
+ delegate.on('click', '#sidebar-filter-toggle', () => {
368
+ const menu = $('sidebar-filter-menu');
369
+ if (!menu) return;
370
+ const open = menu.classList.toggle('hidden');
371
+ $('sidebar-filter-toggle')?.setAttribute('aria-expanded', String(!open));
372
+ });
373
+ delegate.on('change', '[data-agent-option]', (_, el) => {
374
+ const next = new Set(agentFilters.peek());
375
+ if (el.checked) next.add(el.dataset.agentOption);
376
+ else next.delete(el.dataset.agentOption);
377
+ const values = [...next];
378
+ agentFilters.value = values;
379
+ localStorage.setItem('agentFilters', JSON.stringify(values));
380
+ agentFilter.value = values.length === 1 ? values[0] : 'all';
381
+ });
382
+ delegate.on('change', '#filter-favorites', (_, el) => {
383
+ favoritesOnly.value = el.checked;
384
+ localStorage.setItem('favoritesOnly', el.checked ? '1' : '0');
385
+ });
386
+ delegate.on('change', '#filter-hidden', (_, el) => {
387
+ showHiddenOnly.value = el.checked;
388
+ localStorage.setItem('showHiddenOnly', el.checked ? '1' : '0');
389
+ });
390
+ delegate.on('click', '#filter-clear', () => {
391
+ agentFilters.value = [];
392
+ agentFilter.value = 'all';
393
+ favoritesOnly.value = false;
394
+ showHiddenOnly.value = false;
395
+ localStorage.setItem('agentFilters', '[]');
396
+ localStorage.setItem('favoritesOnly', '0');
397
+ localStorage.setItem('showHiddenOnly', '0');
398
+ document.querySelectorAll('[data-agent-option]').forEach(el => { el.checked = false; });
399
+ if ($('filter-favorites')) $('filter-favorites').checked = false;
400
+ if ($('filter-hidden')) $('filter-hidden').checked = false;
401
+ });
356
402
  delegate.on('click', '#session-filter-badge', () => { sessionFilter.value = null; });
357
403
  delegate.on('click', '[data-ws-tab]', (_, el) => { sessionFilter.value = el.dataset.wsTab || null; });
358
404
  delegate.on('click', '#sidebar-refresh', () => {
@@ -368,12 +414,6 @@ export function initShell(opts = {}) {
368
414
  localStorage.setItem('agentFilter', el.dataset.agentFilter);
369
415
  if (sessionSearch.peek().trim()) scheduleActivitySearch(sessionSearch.peek());
370
416
  });
371
- delegate.on('change', '#time-range', (_, el) => {
372
- timeRange.value = el.value;
373
- localStorage.setItem('timeRange', el.value);
374
- loadAllSessions({ waitFresh: true });
375
- if (sessionSearch.peek().trim()) scheduleActivitySearch(sessionSearch.peek());
376
- });
377
417
  delegate.on('dblclick', '[data-project-cwd]', (_, el) => {
378
418
  const cwd = el.dataset.projectCwd;
379
419
  if (!cwd) return;
@@ -783,8 +823,9 @@ export function initShell(opts = {}) {
783
823
  applyChatDensity();
784
824
  const dens = $('sel-density');
785
825
  if (dens) dens.value = chatDensity.peek() || 'normal';
786
- const tr = $('time-range');
787
- if (tr) tr.value = timeRange.peek() || '14';
826
+ document.querySelectorAll('[data-agent-option]').forEach(el => { el.checked = agentFilters.peek().includes(el.dataset.agentOption); });
827
+ if ($('filter-favorites')) $('filter-favorites').checked = favoritesOnly.peek();
828
+ if ($('filter-hidden')) $('filter-hidden').checked = showHiddenOnly.peek();
788
829
  document.querySelectorAll('[data-agent-filter]').forEach(btn => {
789
830
  btn.classList.toggle('active', btn.dataset.agentFilter === agentFilter.peek());
790
831
  });