claude-simple 0.2.23 → 0.2.25

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.
@@ -1,93 +1,235 @@
1
- // settings.js — Workspace settings modal
1
+ // settings.js — full-page settings (not a crowded modal)
2
2
  import { esc, $ } from './lib.js';
3
3
  import { api } from './api.js';
4
4
  import { workspacesData, hubMode, selectedMachineId } from './state.js';
5
5
  import { setCachedWorkspaces } from './cache.js';
6
6
 
7
+
8
+ const CLAUDE_ENV_FIELDS = [
9
+ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL',
10
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL',
11
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL',
12
+ 'CLAUDE_CODE_EFFORT_LEVEL',
13
+ ];
14
+
15
+ let activeSection = 'claude';
16
+
7
17
  export function initSettings() {
8
- document.getElementById('root').insertAdjacentHTML('beforeend', `
9
- <dialog id="modal-settings" class="modal">
10
- <div class="modal-box max-w-lg">
11
- <h3 class="font-bold text-base mb-4">Settings</h3>
12
- <div id="ws-list" class="flex flex-col gap-2 mb-4"></div>
13
- <div class="border-t border-base-300 pt-4">
14
- <div class="text-xs uppercase tracking-wider text-base-content/50 mb-2">Add workspace</div>
15
- <div class="flex gap-2 mb-2">
16
- <input id="ws-new-name" type="text" placeholder="Name (e.g. personal)" class="input input-sm input-bordered flex-1">
17
- <input id="ws-new-dir" type="text" placeholder="~/.claude-personal" class="input input-sm input-bordered flex-[2]">
18
- <button id="ws-add-btn" class="btn btn-primary btn-sm">Add</button>
18
+ document.getElementById('root').insertAdjacentHTML('beforeend', settingsMarkup());
19
+ bind();
20
+ }
21
+
22
+ function settingsMarkup() {
23
+ const fields = CLAUDE_ENV_FIELDS.map(key =>
24
+ `<label class="form-control"><div class="label py-1"><span class="label-text text-xs font-mono">${esc(key)}</span></div><input id="ae-${key}" class="input input-bordered input-sm font-mono text-xs" autocomplete="off" spellcheck="false"></label>`
25
+ ).join('');
26
+ return `
27
+ <div id="settings-page" class="set-page" hidden>
28
+ <header class="set-island">
29
+ <button type="button" id="set-back" class="btn btn-ghost btn-sm px-2" aria-label="返回">←</button>
30
+ <span class="font-semibold text-sm">Settings</span>
31
+ </header>
32
+ <div class="set-split">
33
+ <nav class="set-nav" aria-label="设置分区">
34
+ <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start is-on" data-set-sec="claude">Claude</button>
35
+ <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="workspaces">Workspaces</button>
36
+ <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="users" id="set-nav-users">Users</button>
37
+ <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="password">Password</button>
38
+ <button type="button" class="set-nav-item btn btn-ghost btn-sm justify-start" data-set-sec="mcp">MCP</button>
39
+ </nav>
40
+ <div class="set-main">
41
+ <section class="set-panel" data-set-panel="claude">
42
+ <h2 class="text-lg font-semibold">Claude env</h2>
43
+ <p class="text-sm text-base-content/60 mb-4">Workspace 的 <b>name</b> 对应一套 ANTHROPIC_*。Default 是底,同名覆盖。</p>
44
+ <label class="form-control mb-3">
45
+ <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Profile</span></div>
46
+ <select id="ae-profile" class="select select-bordered select-sm"></select>
47
+ </label>
48
+ <div class="set-fields">${fields}</div>
49
+ <p id="claude-env-error" class="text-error text-xs mt-2 hidden"></p>
50
+ <button type="button" id="claude-env-save" class="btn btn-primary btn-sm mt-4">Save profile</button>
51
+ </section>
52
+ <section class="set-panel" data-set-panel="workspaces" hidden>
53
+ <h2 class="text-lg font-semibold">Workspaces</h2>
54
+ <p class="text-sm text-base-content/60 mb-4">每个 name 一份 Claude 配置目录。加完后会立刻出现在上面的 Profile 列表里。</p>
55
+ <div id="ws-list" class="set-list"></div>
56
+ <div class="set-add">
57
+ <input id="ws-new-name" class="input input-bordered input-sm" placeholder="name e.g. deepseek">
58
+ <input id="ws-new-dir" class="input input-bordered input-sm font-mono text-xs" placeholder="~/.claude-deepseek">
59
+ <button type="button" id="ws-add-btn" class="btn btn-primary btn-sm">Add</button>
60
+ </div>
61
+ <p id="ws-error" class="text-error text-xs mt-2 hidden"></p>
62
+ </section>
63
+ <section class="set-panel" data-set-panel="users" hidden>
64
+ <h2 class="text-lg font-semibold">Users</h2>
65
+ <p class="text-sm text-base-content/60 mb-4">Admin 分配账号。对方用同一登录页,没有公开注册。</p>
66
+ <div id="users-list" class="set-list"></div>
67
+ <div class="set-add">
68
+ <input id="user-new-name" class="input input-bordered input-sm" placeholder="username">
69
+ <input id="user-new-pass" type="password" class="input input-bordered input-sm" placeholder="password">
70
+ <select id="user-new-role" class="select select-bordered select-sm">
71
+ <option value="editor">editor</option>
72
+ <option value="viewer">viewer</option>
73
+ <option value="admin">admin</option>
74
+ </select>
75
+ <button type="button" id="user-add-btn" class="btn btn-primary btn-sm">Invite</button>
19
76
  </div>
20
- <div id="ws-error" class="alert alert-error text-xs py-1.5 hidden"></div>
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">
77
+ <p id="users-error" class="text-error text-xs mt-2 hidden"></p>
78
+ </section>
79
+ <section class="set-panel" data-set-panel="password" hidden>
80
+ <h2 class="text-lg font-semibold">Password</h2>
81
+ <p class="text-sm text-base-content/60 mb-4">改自己的登录密码。管理员重置别人的密码在 Users。</p>
82
+ <label class="form-control mb-2">
83
+ <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Current</span></div>
84
+ <input id="pw-current" type="password" class="input input-bordered input-sm">
85
+ </label>
86
+ <label class="form-control mb-2">
87
+ <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">New</span></div>
88
+ <input id="pw-next" type="password" class="input input-bordered input-sm">
89
+ </label>
90
+ <label class="form-control mb-2">
91
+ <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Again</span></div>
92
+ <input id="pw-next2" type="password" class="input input-bordered input-sm">
93
+ </label>
94
+ <p id="pw-error" class="text-error text-xs mt-2 hidden"></p>
95
+ <button type="button" id="pw-save" class="btn btn-primary btn-sm mt-3">Update password</button>
96
+ </section>
97
+ <section class="set-panel" data-set-panel="mcp" hidden>
98
+ <h2 class="text-lg font-semibold">MCP token</h2>
99
+ <p class="text-sm text-base-content/60 mb-4">发给外部 Agent 的短期凭证,只显示一次。需要先选一台在线机器。</p>
100
+ <label class="form-control mb-3">
101
+ <div class="label py-1"><span class="label-text text-xs uppercase tracking-wide">Expiry</span></div>
102
+ <select id="mcp-token-expiry" class="select select-bordered select-sm">
29
103
  <option value="86400">24 hours</option>
30
104
  <option value="604800">7 days</option>
31
105
  <option value="2592000">30 days</option>
32
106
  </select>
107
+ </label>
108
+ <button type="button" id="mcp-token-generate" class="btn btn-primary btn-sm">Generate</button>
109
+ <div id="mcp-token-result" class="hidden set-token">
110
+ <p id="mcp-token-url" class="font-mono text-xs text-base-content/50"></p>
111
+ <textarea id="mcp-token-value" readonly class="textarea textarea-bordered font-mono text-xs" rows="5"></textarea>
112
+ <button type="button" id="mcp-token-copy" class="btn btn-ghost btn-xs border border-base-300">Copy token</button>
33
113
  </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>
41
- <div class="modal-action">
42
- <form method="dialog"><button class="btn btn-ghost btn-sm">Close</button></form>
43
- </div>
114
+ <p id="mcp-token-error" class="text-error text-xs mt-2 hidden"></p>
115
+ </section>
44
116
  </div>
45
- <form method="dialog" class="modal-backdrop"><button>close</button></form>
46
- </dialog>`);
117
+ </div>
118
+ </div>`;
119
+ }
47
120
 
121
+ function bind() {
122
+ $('set-back')?.addEventListener('click', closeSettings);
123
+ document.querySelectorAll('[data-set-sec]').forEach(btn => {
124
+ btn.addEventListener('click', () => showSection(btn.dataset.setSec));
125
+ });
126
+ $('ws-add-btn')?.addEventListener('click', addWorkspace);
127
+ $('ws-new-name')?.addEventListener('keydown', e => { if (e.key === 'Enter') addWorkspace(); });
128
+ $('ws-new-dir')?.addEventListener('keydown', e => { if (e.key === 'Enter') addWorkspace(); });
129
+ $('mcp-token-generate')?.addEventListener('click', generateMcpToken);
130
+ $('mcp-token-copy')?.addEventListener('click', copyMcpToken);
131
+ $('claude-env-save')?.addEventListener('click', saveClaudeEnv);
132
+ $('ae-profile')?.addEventListener('change', () => { void loadClaudeEnv(); });
133
+ $('user-add-btn')?.addEventListener('click', inviteUser);
134
+ $('pw-save')?.addEventListener('click', savePassword);
135
+ document.addEventListener('router:settings', () => openSettings());
136
+ document.addEventListener('router:home', () => {
137
+ if (isSettingsOpen()) closeSettings();
138
+ });
139
+ }
140
+
141
+ function showSection(id) {
142
+ activeSection = id || 'claude';
143
+ document.querySelectorAll('[data-set-sec]').forEach(b => b.classList.toggle('is-on', b.dataset.setSec === activeSection));
144
+ document.querySelectorAll('[data-set-panel]').forEach(p => {
145
+ p.hidden = p.dataset.setPanel !== activeSection;
146
+ });
147
+ }
148
+
149
+ export function openSettings(section) {
150
+ const page = $('settings-page');
151
+ if (!page) return;
152
+ page.hidden = false;
153
+ document.documentElement.classList.add('settings-open');
154
+ showSection(section || activeSection || 'claude');
48
155
  renderWsList();
156
+ fillProfileSelect();
157
+ void loadClaudeEnv();
158
+ void loadUsers();
159
+ const mcp = $('mcp-token-settings') || document.querySelector('[data-set-panel="mcp"]');
160
+ if (mcp && !hubMode.peek()) {
161
+ document.querySelector('[data-set-sec="mcp"]')?.classList.add('hidden');
162
+ }
163
+ if ((location.hash || '') !== '#/settings') location.hash = '/settings';
164
+ }
49
165
 
50
- document.getElementById('ws-add-btn').addEventListener('click', addWorkspace);
51
- document.getElementById('ws-new-name').addEventListener('keydown', e => { if (e.key === 'Enter') addWorkspace(); });
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);
166
+ export function closeSettings() {
167
+ const page = $('settings-page');
168
+ if (page) page.hidden = true;
169
+ document.documentElement.classList.remove('settings-open');
170
+ if ((location.hash || '') === '#/settings') location.hash = '';
171
+ }
172
+
173
+ export function isSettingsOpen() {
174
+ return !$('settings-page')?.hidden;
55
175
  }
56
176
 
57
177
  function renderWsList() {
58
178
  const el = $('ws-list');
59
179
  if (!el) return;
60
- const wss = workspacesData.peek();
61
- if (!wss.length) { el.innerHTML = '<div class="text-xs text-base-content/40">No workspaces configured</div>'; return; }
62
- el.innerHTML = wss.map(w => `
63
- <div class="flex items-center gap-2 px-3 py-2 bg-base-200 rounded-lg">
64
- <div class="flex-1 min-w-0">
180
+ const wss = workspacesData.peek() || [];
181
+ if (!wss.length) {
182
+ el.innerHTML = '<div class="text-xs text-base-content/40">还没有 workspace</div>';
183
+ return;
184
+ }
185
+ el.innerHTML = wss.map((w, i) => `
186
+ <div class="set-row" style="--index:${i}">
187
+ <div class="set-row-body">
65
188
  <div class="text-sm font-medium">${esc(w.name)}</div>
66
- <div class="text-xs font-mono text-base-content/50 truncate">${esc(w.configDir)}</div>
189
+ <div class="text-xs font-mono text-base-content/50">${esc(w.configDir)}</div>
67
190
  </div>
68
191
  ${wss.length > 1
69
- ? `<button class="btn btn-ghost btn-xs text-error" data-ws-delete="${esc(w.id)}">✕</button>`
70
- : '<span class="text-[10px] text-base-content/30">default</span>'}
192
+ ? `<button type="button" class="btn btn-ghost btn-xs" data-ws-delete="${esc(w.id)}">Remove</button>`
193
+ : '<span class="text-xs text-base-content/40">default</span>'}
71
194
  </div>`).join('');
72
-
73
195
  el.querySelectorAll('[data-ws-delete]').forEach(btn => {
74
196
  btn.addEventListener('click', () => deleteWorkspace(btn.dataset.wsDelete));
75
197
  });
76
198
  }
77
199
 
200
+ function fillProfileSelect(prefer) {
201
+ const sel = $('ae-profile');
202
+ if (!sel) return;
203
+ const cur = prefer != null ? prefer : sel.value;
204
+ const wss = workspacesData.peek() || [];
205
+ const names = [...new Set(wss.map(w => w.name).filter(Boolean))];
206
+ sel.innerHTML = `<option value="">Default</option>`
207
+ + names.map(n => `<option value="${esc(n)}">${esc(n)}</option>`).join('');
208
+ if ([...sel.options].some(o => o.value === cur)) sel.value = cur;
209
+ else sel.value = '';
210
+ }
211
+
78
212
  async function addWorkspace() {
79
- const name = $('ws-new-name').value.trim();
80
- const dir = $('ws-new-dir').value.trim();
81
- const errEl = $('ws-error');
213
+ const name = $('ws-new-name').value.trim();
214
+ const dir = $('ws-new-dir').value.trim();
215
+ const errEl = $('ws-error');
82
216
  errEl.classList.add('hidden');
83
- if (!name || !dir) { errEl.textContent = 'Name and path are required'; errEl.classList.remove('hidden'); return; }
217
+ if (!name || !dir) {
218
+ errEl.textContent = '需要 name 和路径';
219
+ errEl.classList.remove('hidden');
220
+ return;
221
+ }
84
222
  try {
85
223
  const ws = await api('POST', '/api/workspaces', { name, configDir: dir });
86
- workspacesData.value = [...workspacesData.peek(), ws];
87
- setCachedWorkspaces(workspacesData.peek());
224
+ const next = [...(workspacesData.peek() || []).filter(w => w.id !== ws.id), ws];
225
+ workspacesData.value = next;
226
+ setCachedWorkspaces(next);
88
227
  $('ws-new-name').value = '';
89
- $('ws-new-dir').value = '';
228
+ $('ws-new-dir').value = '';
90
229
  renderWsList();
230
+ fillProfileSelect(ws.name);
231
+ await loadClaudeEnv();
232
+ showSection('claude');
91
233
  } catch (e) {
92
234
  errEl.textContent = e.message;
93
235
  errEl.classList.remove('hidden');
@@ -97,19 +239,124 @@ async function addWorkspace() {
97
239
  async function deleteWorkspace(id) {
98
240
  try {
99
241
  await api('DELETE', `/api/workspaces/${id}`);
100
- workspacesData.value = workspacesData.peek().filter(w => w.id !== id);
101
- setCachedWorkspaces(workspacesData.peek());
242
+ const next = (workspacesData.peek() || []).filter(w => w.id !== id);
243
+ workspacesData.value = next;
244
+ setCachedWorkspaces(next);
102
245
  renderWsList();
246
+ fillProfileSelect('');
247
+ await loadClaudeEnv();
103
248
  } catch (e) {
104
- alert(e.message);
249
+ const errEl = $('ws-error');
250
+ if (errEl) { errEl.textContent = e.message; errEl.classList.remove('hidden'); }
105
251
  }
106
252
  }
107
253
 
108
- export function openSettings() {
109
- renderWsList();
110
- const mcp = $('mcp-token-settings');
111
- if (mcp) mcp.style.display = hubMode.peek() ? '' : 'none';
112
- $('modal-settings')?.showModal();
254
+ async function loadClaudeEnv() {
255
+ try {
256
+ const name = $('ae-profile')?.value || '';
257
+ const q = name ? `?name=${encodeURIComponent(name)}` : '';
258
+ const data = await api('GET', `/api/agent-env${q}`);
259
+ const env = data.env || {};
260
+ const proc = name ? {} : (data.process || {});
261
+ for (const key of CLAUDE_ENV_FIELDS) {
262
+ const el = $(`ae-${key}`);
263
+ if (!el) continue;
264
+ const v = env[key] ?? proc[key];
265
+ if (v && typeof v === 'object' && v.set) {
266
+ el.placeholder = `saved ${v.preview || '••••'}`;
267
+ el.value = '';
268
+ el.dataset.keep = '1';
269
+ } else {
270
+ el.value = typeof v === 'string' ? v : '';
271
+ el.placeholder = key;
272
+ el.dataset.keep = '';
273
+ }
274
+ }
275
+ } catch { /* older edges */ }
276
+ }
277
+
278
+ async function saveClaudeEnv() {
279
+ const errEl = $('claude-env-error');
280
+ errEl?.classList.add('hidden');
281
+ const env = {};
282
+ for (const key of CLAUDE_ENV_FIELDS) {
283
+ const el = $(`ae-${key}`);
284
+ if (!el) continue;
285
+ const val = el.value.trim();
286
+ if (val) env[key] = val;
287
+ else if (el.dataset.keep === '1') env[key] = { set: true };
288
+ else env[key] = '';
289
+ }
290
+ try {
291
+ const name = $('ae-profile')?.value || '';
292
+ await api('PUT', '/api/agent-env', { name, env });
293
+ await loadClaudeEnv();
294
+ } catch (e) {
295
+ if (errEl) { errEl.textContent = e.message; errEl.classList.remove('hidden'); }
296
+ }
297
+ }
298
+
299
+ async function loadUsers() {
300
+ const nav = $('set-nav-users');
301
+ const panel = document.querySelector('[data-set-panel="users"]');
302
+ try {
303
+ const me = await api('GET', '/api/collab/me');
304
+ const admin = me.role === 'admin' || me.role === 'owner';
305
+ nav?.classList.toggle('hidden', !admin);
306
+ if (panel) panel.hidden = !admin || activeSection !== 'users';
307
+ if (!admin) {
308
+ if (activeSection === 'users') showSection('claude');
309
+ return;
310
+ }
311
+ const users = await api('GET', '/api/collab/users');
312
+ const list = $('users-list');
313
+ const rows = Array.isArray(users) ? users : [];
314
+ list.innerHTML = rows.map(u =>
315
+ `<div class="set-row"><div class="text-sm font-medium flex-1">${esc(u.username)}</div><div class="text-xs text-base-content/50">${esc(u.role || '')}</div></div>`
316
+ ).join('') || '<div class="text-xs text-base-content/40">还没有其他用户</div>';
317
+ } catch {
318
+ nav?.classList.add('hidden');
319
+ }
320
+ }
321
+
322
+ async function inviteUser() {
323
+ const errEl = $('users-error');
324
+ errEl?.classList.add('hidden');
325
+ const username = $('user-new-name')?.value.trim();
326
+ const password = $('user-new-pass')?.value || '';
327
+ const role = $('user-new-role')?.value || 'editor';
328
+ if (!username || !password) {
329
+ if (errEl) { errEl.textContent = '需要用户名和密码'; errEl.classList.remove('hidden'); }
330
+ return;
331
+ }
332
+ try {
333
+ await api('POST', '/api/collab/users', { username, password, role });
334
+ $('user-new-name').value = '';
335
+ $('user-new-pass').value = '';
336
+ await loadUsers();
337
+ } catch (e) {
338
+ if (errEl) { errEl.textContent = e.message; errEl.classList.remove('hidden'); }
339
+ }
340
+ }
341
+
342
+ async function savePassword() {
343
+ const errEl = $('pw-error');
344
+ errEl?.classList.add('hidden');
345
+ const current = $('pw-current')?.value || '';
346
+ const next = $('pw-next')?.value || '';
347
+ const next2 = $('pw-next2')?.value || '';
348
+ if (next !== next2) {
349
+ if (errEl) { errEl.textContent = '两次新密码不一致'; errEl.classList.remove('hidden'); }
350
+ return;
351
+ }
352
+ try {
353
+ await api('POST', '/api/auth/password', { current, next });
354
+ $('pw-current').value = '';
355
+ $('pw-next').value = '';
356
+ $('pw-next2').value = '';
357
+ } catch (e) {
358
+ if (errEl) { errEl.textContent = e.message; errEl.classList.remove('hidden'); }
359
+ }
113
360
  }
114
361
 
115
362
  async function generateMcpToken() {
@@ -119,7 +366,7 @@ async function generateMcpToken() {
119
366
  err?.classList.add('hidden');
120
367
  const machineId = selectedMachineId.peek();
121
368
  if (!machineId) {
122
- err.textContent = '请选择一台在线机器后再生成 Token。';
369
+ err.textContent = '请先在主界面选一台在线机器。';
123
370
  err.classList.remove('hidden');
124
371
  return;
125
372
  }
@@ -127,6 +374,8 @@ async function generateMcpToken() {
127
374
  const expiresInSeconds = Number($('mcp-token-expiry').value);
128
375
  const data = await api('POST', `/api/mcp/tokens?machine=${encodeURIComponent(machineId)}`, { expiresInSeconds });
129
376
  $('mcp-token-value').value = data.token || '';
377
+ const urlEl = $('mcp-token-url');
378
+ if (urlEl) urlEl.textContent = `${location.origin}/mcp`;
130
379
  result.classList.remove('hidden');
131
380
  } catch (e) {
132
381
  err.textContent = e.message;
@@ -104,6 +104,7 @@ export function renderSessionNotesBar() {
104
104
  const note = (meta.notes || '').trim();
105
105
  const fav = !!meta.favorite;
106
106
  const hid = !!meta.hidden;
107
+ const branch = meta.branch?.parentSessionId ? meta.branch : null;
107
108
  const title = (meta.title || '').trim();
108
109
  const showTitle = title || sid.slice(0, 8);
109
110
  bar.style.display = 'flex';
@@ -125,6 +126,7 @@ export function renderSessionNotesBar() {
125
126
  <span class="font-bold text-base-content/40 uppercase flex-shrink-0">Note:</span>
126
127
  <span class="truncate text-base-content/70 ${note ? '' : 'italic text-base-content/40'}"
127
128
  id="session-notes-text" title="${esc(note || 'Add a note for this session')}">${esc(note || 'No session note')}</span>
129
+ ${branch ? `<span class="session-branch-current" title="Forked from ${esc(branch.parentAgent || agent)} · ${esc(branch.parentSessionId)}">⎇ from ${esc(branch.parentSessionId.slice(0, 8))}</span>` : ''}
128
130
  </div>
129
131
  <button id="btn-edit-session-notes" class="btn btn-ghost btn-xs px-1.5">Note</button>
130
132
  `;
@@ -147,7 +149,7 @@ export function renderSessionNotesEditMode() {
147
149
  }
148
150
 
149
151
  function emptyMeta() {
150
- return { favorite: false, notes: '', title: '', hidden: false };
152
+ return { favorite: false, notes: '', title: '', hidden: false, branch: null };
151
153
  }
152
154
 
153
155
  function mergeMeta(prev, patch) {
@@ -156,12 +158,13 @@ function mergeMeta(prev, patch) {
156
158
  notes: patch.notes !== undefined ? String(patch.notes || '') : String(prev.notes || ''),
157
159
  title: patch.title !== undefined ? String(patch.title || '').trim() : String(prev.title || '').trim(),
158
160
  hidden: patch.hidden !== undefined ? Boolean(patch.hidden) : Boolean(prev.hidden),
161
+ branch: patch.branch !== undefined ? (patch.branch || null) : (prev.branch || null),
159
162
  updatedAt: Date.now(),
160
163
  };
161
164
  }
162
165
 
163
166
  function isMetaEmpty(m) {
164
- return !m?.favorite && !m?.hidden && !(m?.notes || '').trim() && !(m?.title || '').trim();
167
+ return !m?.favorite && !m?.hidden && !(m?.notes || '').trim() && !(m?.title || '').trim() && !m?.branch;
165
168
  }
166
169
 
167
170
  export async function toggleSessionFavorite(sessionId, agent, { machineId } = {}) {
@@ -23,6 +23,45 @@ function normalizeSessions(v) {
23
23
  return [];
24
24
  }
25
25
 
26
+ function branchParentKey(branch, session) {
27
+ if (!branch?.parentSessionId) return '';
28
+ return metaKey(branch.parentAgent || session.agent, branch.parentSessionId,
29
+ branch.parentMachineId ?? session.machineId);
30
+ }
31
+
32
+ /** Keep fork descendants adjacent to their source so the sidebar is a small,
33
+ * readable conversation tree instead of a flat chronology. */
34
+ function orderBranchSessions(sessions) {
35
+ const list = Array.isArray(sessions) ? sessions : [];
36
+ const byKey = new Map(list.map(s => [metaKey(s.agent, s.sessionId, s.machineId), s]));
37
+ const children = new Map();
38
+ const roots = [];
39
+ for (const s of list) {
40
+ const branch = sessionMetaMap.peek()?.[metaKey(s.agent, s.sessionId, s.machineId)]?.branch;
41
+ const parent = branchParentKey(branch, s);
42
+ if (parent && byKey.has(parent) && parent !== metaKey(s.agent, s.sessionId, s.machineId)) {
43
+ const rows = children.get(parent) || [];
44
+ rows.push(s);
45
+ children.set(parent, rows);
46
+ } else {
47
+ roots.push(s);
48
+ }
49
+ }
50
+ const byRecent = (a, b) => (b.updatedAt || 0) - (a.updatedAt || 0);
51
+ roots.sort(byRecent);
52
+ for (const rows of children.values()) rows.sort(byRecent);
53
+ const out = [];
54
+ const visit = (s, depth, seen = new Set()) => {
55
+ const key = metaKey(s.agent, s.sessionId, s.machineId);
56
+ if (seen.has(key)) return;
57
+ seen.add(key);
58
+ out.push(depth ? { ...s, _branchDepth: depth } : s);
59
+ for (const child of children.get(key) || []) visit(child, depth + 1, seen);
60
+ };
61
+ for (const root of roots) visit(root, 0);
62
+ return out;
63
+ }
64
+
26
65
  function sessionsQuery({ hubIndex = false } = {}) {
27
66
  const q = new URLSearchParams();
28
67
  // Project index is small and should remain complete; the Hub/browser cache
@@ -97,6 +136,8 @@ export function sessionItemHtml(s, { showProject = false } = {}) {
97
136
  const fav = !!meta.favorite;
98
137
  const hid = !!meta.hidden;
99
138
  const hasNote = !!(meta.notes && String(meta.notes).trim());
139
+ const branch = meta.branch?.parentSessionId ? meta.branch : null;
140
+ const branchDepth = Math.max(0, Math.min(4, Number(s._branchDepth) || 0));
100
141
  const turns = Number(s.turnCount) || 0;
101
142
  const thin = turns > 0 && turns <= LOW_TURN_THRESHOLD;
102
143
  const projectLine = showProject
@@ -107,6 +148,7 @@ export function sessionItemHtml(s, { showProject = false } = {}) {
107
148
  renamed ? `<span class="session-renamed-badge" title="Custom name (original: ${esc(s.display || '')})">renamed</span>` : '',
108
149
  hid ? `<span class="session-hidden-badge" title="Hidden">hidden</span>` : '',
109
150
  hasNote ? `<span class="session-note-dot" title="${esc(meta.notes)}">📝</span>` : '',
151
+ branch ? `<span class="session-branch-badge" title="Forked from ${esc(branch.parentAgent || s.agent)}:${esc(branch.parentSessionId)}">⎇ fork</span>` : '',
110
152
  ].filter(Boolean).join('');
111
153
  const machineBadge = s.machineId
112
154
  ? `<span class="text-[9px] px-1 py-0.5 rounded bg-base-300 text-base-content/50 font-mono flex-shrink-0" title="Machine">${esc(s.machineId)}</span>`
@@ -114,8 +156,12 @@ export function sessionItemHtml(s, { showProject = false } = {}) {
114
156
  const tip = renamed
115
157
  ? `${title}\n(original: ${s.display || s.sessionId})`
116
158
  : (s.display || '');
117
- return `<div class="session-row px-2.5 py-1.5 cursor-pointer hover:bg-base-300 border-l-2
159
+ const branchLine = branch
160
+ ? `<div class="session-branch-line" title="Forked from ${esc(branch.parentAgent || s.agent)} · ${esc(branch.parentSessionId)}">↳ ${esc(branch.parentAgent || s.agent)} · ${esc(branch.parentSessionId.slice(0, 8))}</div>`
161
+ : '';
162
+ return `<div class="session-row ${branchDepth ? 'session-branch' : ''} px-2.5 py-1.5 cursor-pointer hover:bg-base-300 border-l-2
118
163
  ${active ? 'border-primary bg-primary/5' : 'border-transparent'} ${fav ? 'session-fav' : ''} ${hid ? 'session-hidden-row' : ''} ${thin ? 'session-thin' : ''}"
164
+ style="--branch-depth:${branchDepth}"
119
165
  data-session-id="${esc(s.sessionId)}"
120
166
  data-session-cwd="${esc(s.cwd || '')}"
121
167
  data-session-config-dir="${esc(s.configDir || '')}"
@@ -149,6 +195,7 @@ export function sessionItemHtml(s, { showProject = false } = {}) {
149
195
  title="${hid ? 'Unhide session' : 'Hide session'}">${hid ? '👁' : '🙈'}</button>
150
196
  </div>
151
197
  ${projectLine}
198
+ ${branchLine}
152
199
  ${badges || machineBadge ? `<div class="flex items-center gap-1 mt-0.5 flex-wrap">${machineBadge}${badges}</div>` : ''}
153
200
  </div>
154
201
  <span class="text-[9px] text-base-content/30 flex-shrink-0 mt-0.5">${formatTime(s.updatedAt)}</span>
@@ -290,7 +337,8 @@ export function renderProjectGroups(groups) {
290
337
  <span class="text-[9px] text-base-content/30 flex-shrink-0">${formatTime(g.updatedAt)}</span>
291
338
  </div>
292
339
  </div>`;
293
- const sessions = g.sessions.slice(0, 40);
340
+ const ordered = orderBranchSessions(g.sessions);
341
+ const sessions = ordered.slice(0, 40);
294
342
  const more = g.sessions.length > 40
295
343
  ? `<div class="px-3 py-1 text-[10px] text-base-content/30">+${g.sessions.length - 40} more</div>` : '';
296
344
  const body = open