claude-simple 0.2.24 → 0.2.26

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.24",
3
+ "version": "0.2.26",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-simple": "server.js",
package/public/api.js CHANGED
@@ -26,20 +26,26 @@ function isHubOnlyPath(path) {
26
26
  || p === '/api/notifications/read'
27
27
  || p === '/api/ai/notes'
28
28
  || p.startsWith('/api/ai/notes/')
29
- || p.startsWith('/api/auth/');
29
+ || p.startsWith('/api/auth/')
30
+ || p.startsWith('/api/collab/');
30
31
  }
31
32
 
32
33
  export async function api(method, path, body, signal, opts = {}) {
33
- const machineId = currentMachineId(opts);
34
+ const machineId = opts.hub ? null : currentMachineId(opts);
34
35
  const headers = {
35
36
  'Content-Type': 'application/json',
36
37
  ...(ctx.token ? { Authorization: `Bearer ${ctx.token}` } : {}),
37
38
  };
38
39
 
39
40
  // Hub mode: route data APIs to the selected edge (sessions/files/git/…)
40
- if (hubMode.peek() && machineId && !isHubOnlyPath(path)) {
41
+ if (!opts.hub && hubMode.peek() && machineId && !isHubOnlyPath(path)) {
41
42
  headers['X-Machine-Id'] = machineId;
42
43
  }
44
+ const mutating = method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD';
45
+ if (mutating && ctx.lock?.token && ctx.lock?.key && !String(path).startsWith('/api/collab/')) {
46
+ headers['X-Lock-Token'] = ctx.lock.token;
47
+ headers['X-Lock-Key'] = ctx.lock.key;
48
+ }
43
49
 
44
50
  const etagKey = opts.etag === true
45
51
  ? `${ctx.token || ''}:${machineId || ''}:${path}`
@@ -54,6 +60,7 @@ export async function api(method, path, body, signal, opts = {}) {
54
60
  method,
55
61
  signal,
56
62
  headers,
63
+ ...(opts.cache ? { cache: opts.cache } : {}),
57
64
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
58
65
  });
59
66
  if (res.status === 304) return NOT_MODIFIED;
package/public/app.js CHANGED
@@ -3,7 +3,7 @@ import { ctx } from './state.js';
3
3
  import { probeHub } from './api.js';
4
4
  import { initShell, showApp, initAuth, resumeSession, switchTab, shouldRestoreSessionOnBoot } from './shell.js';
5
5
  import { initChat } from './chat.js';
6
- import { initFilesTab } from './files.js';
6
+ import { initFilesTab } from './files.js?v=ui-22';
7
7
  import { initGitTab } from './git.js';
8
8
  import { initSettings } from './settings.js';
9
9
  import { initTerminal } from './terminal.js';
package/public/chat.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import { watch, delegate, esc, $ } from './lib.js';
3
3
  import { ctx, isProcessing, currentProject, currentTab, currentModel, currentEffort, currentPermission,
4
4
  currentAgent, setAgent, AGENT_LABELS, getDefaultModel, getModelsForAgent, getEffortsForModel,
5
- getDefaultEffort, addCustomModel, setCurrentModel, chatDensity, wsStatus } from './state.js';
5
+ getDefaultEffort, addCustomModel, setCurrentModel, chatDensity, wsStatus, sessionMetaMap, metaKey } from './state.js';
6
6
  import { sendWs, api, authHeaders, flushWsQueue, clearWsQueue } from './api.js';
7
+ import { acquireSessionLock, canWriteNow } from './locks.js';
7
8
  import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
8
9
  import { pushNotification } from './notifications.js';
9
10
  import { renderMermaid } from './mermaid.js';
@@ -37,13 +38,39 @@ let sawDisconnect = false;
37
38
  let lastWsMachine = null;
38
39
  let reconnectQuiet = false;
39
40
  const HUB_CURSOR_KEY = 'hubStreamCursors';
41
+ const assistantMessageRefs = new Map();
42
+ let assistantMessageSeq = 0;
43
+ const renderedGrokQueuePrompts = new Set();
44
+ const PENDING_QUEUE_KEY = 'pendingUserTexts';
40
45
  /** FIFO messages deliberately queued while a turn is running. */
41
- let pendingUserTexts = [];
46
+ let pendingUserTexts = (() => {
47
+ try {
48
+ const saved = JSON.parse(sessionStorage.getItem(PENDING_QUEUE_KEY) || '[]');
49
+ return Array.isArray(saved) ? saved.filter(x => x?.key && x?.text).slice(-30) : [];
50
+ } catch { return []; }
51
+ })();
52
+ const announcedRestoredQueues = new Set();
42
53
  // Composer state belongs to a conversation, never to the shared textarea.
43
54
  // Keeping this in memory deliberately avoids persisting potentially sensitive
44
55
  // prompts to disk while still making session switches lossless.
45
56
  const composerDrafts = new Map();
46
57
 
58
+ function persistPendingUserTexts() {
59
+ try {
60
+ if (pendingUserTexts.length) sessionStorage.setItem(PENDING_QUEUE_KEY, JSON.stringify(pendingUserTexts.slice(-30)));
61
+ else sessionStorage.removeItem(PENDING_QUEUE_KEY);
62
+ } catch { /* private mode / quota: in-memory queue still works */ }
63
+ }
64
+
65
+ function announceRestoredQueue() {
66
+ const key = draftKey();
67
+ const count = pendingUserTexts.filter(item => item.key === key).length;
68
+ if (count && !announcedRestoredQueues.has(key)) {
69
+ announcedRestoredQueues.add(key);
70
+ appendSystemMsg(`⌛ Restored ${count} queued prompt${count === 1 ? '' : 's'} in this browser tab`);
71
+ }
72
+ }
73
+
47
74
  function hubCursorKey(sessionId = ctx.sessionId, agent = ctx.agent, machineId = ctx.machineId) {
48
75
  return sessionId ? `${machineId || ''}:${agent || 'claude'}:${sessionId}` : '';
49
76
  }
@@ -106,6 +133,7 @@ export function parkQueuedMessages(identity) {
106
133
  const queued = pendingUserTexts.filter(item => item.key === key);
107
134
  if (!queued.length) return;
108
135
  pendingUserTexts = pendingUserTexts.filter(item => item.key !== key);
136
+ persistPendingUserTexts();
109
137
  const existing = composerDrafts.get(key) || '';
110
138
  composerDrafts.set(key, [existing, ...queued.map(item => item.text)].filter(Boolean).join('\n\n'));
111
139
  }
@@ -126,7 +154,7 @@ function bindSocketLifecycle() {
126
154
  ensureChatSocket({ probe: true });
127
155
  // Re-subscribe so server can re-attach stream / clear stuck busy state
128
156
  if (ctx.ws?.readyState === WebSocket.OPEN && ctx.sessionId) {
129
- try { ctx.ws.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
157
+ try { ctx.ws.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId, agent: ctx.agent })); } catch { /* ignore */ }
130
158
  }
131
159
  }
132
160
  });
@@ -225,6 +253,50 @@ function syncWakeLock() {
225
253
  setWakeLockDesired(need);
226
254
  }
227
255
 
256
+ function composerBlockReason() {
257
+ if ((ctx.role || 'editor') === 'viewer') return '只读账号,无法输入';
258
+ if (ctx.lock?.foreign) {
259
+ const who = ctx.lock.holder && ctx.lock.holder !== 'viewer' ? ctx.lock.holder : '其他用户';
260
+ return `会话由 ${who} 占用(锁)`;
261
+ }
262
+ return '当前无法输入';
263
+ }
264
+
265
+ /** Enable typing whenever this user is allowed to write (role + lease). */
266
+ export function applyComposerAccess() {
267
+ const writable = canWriteNow();
268
+ ctx.externalSessionReadOnly = !writable;
269
+ const input = $('chat-input');
270
+ const send = $('send-btn');
271
+ const attach = $('btn-attach');
272
+ const banner = $('session-lock-banner');
273
+ if (input) {
274
+ input.disabled = !writable;
275
+ input.readOnly = !writable;
276
+ if (!writable) input.placeholder = composerBlockReason();
277
+ else if (isProcessing.peek()) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
278
+ else updateInputMode(input.value || '');
279
+ }
280
+ if (send) send.disabled = !writable;
281
+ if (attach) attach.disabled = !writable;
282
+ if (banner) {
283
+ if (!writable) {
284
+ banner.classList.remove('hidden');
285
+ banner.textContent = composerBlockReason();
286
+ } else {
287
+ banner.classList.add('hidden');
288
+ banner.textContent = '';
289
+ }
290
+ }
291
+ }
292
+
293
+ /** Kept for session-nav resets. Never use this to lock the composer on
294
+ * native JSONL sync — UI-owned sessions write the same files, which left
295
+ * holders unable to type after a turn. */
296
+ export function setExternalSessionReadOnly(_readOnly) {
297
+ applyComposerAccess();
298
+ }
299
+
228
300
  /** Manual reconnect (toolbar / banner). Resets backoff and forces a new socket. */
229
301
  export function refreshConnection() {
230
302
  reconnectDelay = 400;
@@ -273,9 +345,10 @@ export function connectWS(opts = {}) {
273
345
  wsStatus.value = 'open';
274
346
  updateConnDot();
275
347
  if (ctx.sessionId) {
276
- try { sock.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
348
+ try { sock.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId, agent: ctx.agent })); } catch { /* ignore */ }
277
349
  }
278
350
  flushWsQueue();
351
+ announceRestoredQueue();
279
352
  startHeartbeat(sock, gen);
280
353
  syncWakeLock();
281
354
  if (sawDisconnect && !reconnectQuiet) {
@@ -307,6 +380,10 @@ export function connectWS(opts = {}) {
307
380
  // Server says idle — unlock (fixes Grok lingering process / missed complete)
308
381
  isProcessing.value = false;
309
382
  flushPendingUserText();
383
+ } else {
384
+ // After reload the UI starts idle; server status is still authoritative
385
+ // enough to release browser-restored queued prompts.
386
+ flushPendingUserText();
310
387
  }
311
388
  syncWakeLock();
312
389
  return;
@@ -336,6 +413,7 @@ function flushPendingUserText() {
336
413
  // opened (or are converted to a draft during navigation).
337
414
  if (index < 0) return;
338
415
  const [pending] = pendingUserTexts.splice(index, 1);
416
+ persistPendingUserTexts();
339
417
  const input = $('chat-input');
340
418
  if (input) {
341
419
  input.value = pending.text;
@@ -379,10 +457,25 @@ function handleWsMessage(msg) {
379
457
  document.dispatchEvent(new CustomEvent('sessions-changed'));
380
458
  return;
381
459
  }
460
+ if (msg.type === 'external-sessions-changed') {
461
+ document.dispatchEvent(new CustomEvent('sessions-changed'));
462
+ return;
463
+ }
464
+ if (msg.type === 'external-session-updated') {
465
+ // Native JSONL changed (our agent or a terminal CLI). Refresh history but
466
+ // do not disable the composer: that flag stuck after every UI turn.
467
+ document.dispatchEvent(new CustomEvent('sessions-changed'));
468
+ document.dispatchEvent(new CustomEvent('external-session-updated', { detail: msg }));
469
+ return;
470
+ }
382
471
  if (msg.type === 'notification' && msg.notification) {
383
472
  pushNotification(msg.notification);
384
473
  return;
385
474
  }
475
+ if (msg.type === 'grok-queue-status') {
476
+ renderGrokQueuePrompts(msg);
477
+ return;
478
+ }
386
479
  if (msg.type === 'result') {
387
480
  finalizeThoughtStream();
388
481
  finalizeAssistantStream();
@@ -422,6 +515,10 @@ function handleWsMessage(msg) {
422
515
  if (msg.type === 'error') {
423
516
  finalizeThoughtStream();
424
517
  finalizeAssistantStream();
518
+ if (msg.code === 423 || /locked/i.test(String(msg.message || ''))) {
519
+ ctx.lock = { ...(ctx.lock || {}), held: false, foreign: true, holder: msg.lock?.holder || ctx.lock?.holder || 'another user' };
520
+ applyComposerAccess();
521
+ }
425
522
  appendMsg('error', 'Error', msg.message);
426
523
  markTurnDone();
427
524
  return;
@@ -555,6 +652,7 @@ function finalizeAssistantStream() {
555
652
  liveAsstBody.classList.add('md');
556
653
  liveAsstBody.innerHTML = renderMarkdown(text);
557
654
  enhanceMarkdown(liveAsstEl);
655
+ addAssistantMessageActions(liveAsstEl, text);
558
656
  } else {
559
657
  liveAsstEl.remove();
560
658
  }
@@ -811,6 +909,22 @@ export function sendMessage() {
811
909
  const input = $('chat-input');
812
910
  const text = input?.value.trim();
813
911
  if (!text || !currentProject.peek()) return;
912
+ if (!canWriteNow()) {
913
+ appendSystemMsg(composerBlockReason());
914
+ acquireSessionLock().then(() => applyComposerAccess()).catch(() => {});
915
+ return;
916
+ }
917
+ if (ctx.sessionId && !ctx.lock?.held && !ctx.lock?.unmanaged) {
918
+ acquireSessionLock().then(r => {
919
+ if (!r?.ok && !r?.skipped) {
920
+ applyComposerAccess();
921
+ appendSystemMsg(composerBlockReason());
922
+ return;
923
+ }
924
+ sendMessage();
925
+ }).catch(() => sendMessage());
926
+ return;
927
+ }
814
928
 
815
929
  // While a turn is running, queue the next message (common Grok complaint).
816
930
  if (isProcessing.peek()) {
@@ -821,22 +935,26 @@ export function sendMessage() {
821
935
  const effort = currentEffort.peek() || getDefaultEffort(agent, currentModel.peek()) || 'low';
822
936
  const ok = sendWs({
823
937
  type: 'command', cmd: 'turn.start', agent, command: text, sessionId: ctx.sessionId,
938
+ lockToken: ctx.lock?.token || undefined,
824
939
  options: {
825
940
  agent, cwd: currentProject.peek().path, sessionId: ctx.sessionId,
826
941
  configDir: ctx.configDir, model: currentModel.peek(),
942
+ lockToken: ctx.lock?.token || undefined,
827
943
  effort,
828
944
  ...(currentPermission.peek() && { permissionMode: currentPermission.peek() }),
829
945
  ...(currentPermission.peek() === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
830
946
  },
831
947
  });
948
+ appendQueuedGrokPrompt(text);
832
949
  input.value = '';
833
950
  autoResize(input);
834
951
  updateInputMode('');
835
952
  clearPendingAttachments();
836
- appendSystemMsg(ok ? '已加入 Grok 服务端队列,刷新页面不会丢失' : '连接未就绪,消息将在重连后发送');
953
+ appendSystemMsg(ok ? '已加入 Grok 服务端队列,刷新页面仍会显示' : '连接未就绪,消息将在重连后发送');
837
954
  return;
838
955
  }
839
956
  pendingUserTexts.push({ key: draftKey(), text });
957
+ persistPendingUserTexts();
840
958
  input.value = '';
841
959
  autoResize(input);
842
960
  updateInputMode('');
@@ -887,12 +1005,14 @@ export function sendMessage() {
887
1005
  agent,
888
1006
  command: text,
889
1007
  sessionId: ctx.sessionId,
1008
+ lockToken: ctx.lock?.token || undefined,
890
1009
  options: {
891
1010
  agent,
892
1011
  cwd: currentProject.peek().path,
893
1012
  sessionId: ctx.sessionId,
894
1013
  configDir: ctx.configDir,
895
1014
  model: currentModel.peek(),
1015
+ lockToken: ctx.lock?.token || undefined,
896
1016
  ...(effort && { effort }),
897
1017
  ...(permission && { permissionMode: permission }),
898
1018
  ...(permission === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
@@ -907,6 +1027,8 @@ export function stopProcessing() {
907
1027
  sendWs({ type: 'command', cmd: 'turn.interrupt', sessionId: ctx.sessionId });
908
1028
  }
909
1029
  pendingUserTexts = pendingUserTexts.filter(item => item.key !== draftKey());
1030
+ persistPendingUserTexts();
1031
+ markQueuedGrokPromptsCancelled();
910
1032
  markTurnDone();
911
1033
  }
912
1034
 
@@ -1017,7 +1139,7 @@ function bubbleHtml(role, text, ts) {
1017
1139
  case 'assistant': {
1018
1140
  const long = text.length > ASST_COLLAPSE;
1019
1141
  const inner = long ? `<div class="msg-body">${renderMarkdown(text)}</div>${expandBtn}` : renderMarkdown(text);
1020
- bubble = `<div class="max-w-[min(92%,52rem)] rounded-2xl rounded-tl-sm px-3.5 py-2.5 text-sm bg-base-200 border border-base-300/40 break-words md leading-relaxed${long ? ' msg-card' : ''}">${inner}</div>`;
1142
+ bubble = `<div class="msg-assistant-content max-w-[min(92%,52rem)] rounded-2xl rounded-tl-sm px-3.5 py-2.5 text-sm bg-base-200 border border-base-300/40 break-words md leading-relaxed${long ? ' msg-card' : ''}">${inner}</div>`;
1021
1143
  break;
1022
1144
  }
1023
1145
  case 'error':
@@ -1047,6 +1169,7 @@ export function appendMsg(role, _label, text, opts = {}) {
1047
1169
  const ts = coerceTs(opts.ts) ?? (role === 'system' ? null : Date.now());
1048
1170
  msgs.insertAdjacentHTML('beforeend', bubbleHtml(role, text, ts));
1049
1171
  enhanceMarkdown(msgs.lastElementChild);
1172
+ if (role === 'assistant') addAssistantMessageActions(msgs.lastElementChild, text, opts);
1050
1173
  stickBottom(msgs, role === 'user');
1051
1174
  }
1052
1175
 
@@ -1345,6 +1468,122 @@ export function clearMessages() {
1345
1468
  toolBatch = null;
1346
1469
  liveAsstEl = liveAsstBody = null;
1347
1470
  liveThoughtEl = liveThoughtBody = null;
1471
+ assistantMessageRefs.clear();
1472
+ renderedGrokQueuePrompts.clear();
1473
+ }
1474
+
1475
+ function queuedGrokPromptKey(text, sessionId = ctx.sessionId) {
1476
+ return `${sessionId || ''}\n${String(text || '')}`;
1477
+ }
1478
+
1479
+ /** Paint a server-accepted Grok follow-up before the native transcript writes
1480
+ * it. This makes the queued user prompt visible both immediately and after a
1481
+ * reconnect (the server sends the same snapshot on subscribe). */
1482
+ function appendQueuedGrokPrompt(text, sessionId = ctx.sessionId) {
1483
+ const key = queuedGrokPromptKey(text, sessionId);
1484
+ if (!text || renderedGrokQueuePrompts.has(key)) return;
1485
+ renderedGrokQueuePrompts.add(key);
1486
+ appendMsg('user', 'You', text, { ts: Date.now() });
1487
+ $('messages')?.lastElementChild?.classList.add('queued-user-msg');
1488
+ }
1489
+
1490
+ function renderGrokQueuePrompts(msg) {
1491
+ if ((msg.sessionId || ctx.sessionId) !== ctx.sessionId) return;
1492
+ const items = Array.isArray(msg.items) ? msg.items.filter(x => typeof x === 'string' && x.trim()) : [];
1493
+ for (const text of items) appendQueuedGrokPrompt(text, msg.sessionId || ctx.sessionId);
1494
+ if (items.length) appendSystemMsg(`⌛ Grok queue · ${items.length} prompt${items.length === 1 ? '' : 's'} waiting`);
1495
+ }
1496
+
1497
+ function markQueuedGrokPromptsCancelled() {
1498
+ const msgs = $('messages');
1499
+ if (!msgs) return;
1500
+ msgs.querySelectorAll('.queued-user-msg').forEach(row => {
1501
+ row.classList.remove('queued-user-msg');
1502
+ row.classList.add('cancelled-queued-user-msg');
1503
+ });
1504
+ renderedGrokQueuePrompts.clear();
1505
+ }
1506
+
1507
+ /** Per-answer controls stay below the assistant message. Raw answer text lives
1508
+ * only in this in-memory map, never in a DOM data attribute. */
1509
+ function addAssistantMessageActions(row, text, opts = {}) {
1510
+ if (!row || !text || row.querySelector('.msg-actions')) return;
1511
+ const key = `a${++assistantMessageSeq}`;
1512
+ assistantMessageRefs.set(key, {
1513
+ text,
1514
+ historyIndex: Number.isInteger(opts.historyIndex) ? opts.historyIndex : null,
1515
+ sessionId: ctx.sessionId,
1516
+ agent: ctx.agent || currentAgent.peek() || 'claude',
1517
+ machineId: ctx.machineId || null,
1518
+ });
1519
+ const canFork = !['gemini', 'cursor'].includes(ctx.agent || currentAgent.peek() || 'claude');
1520
+ const actions = document.createElement('div');
1521
+ actions.className = 'msg-actions';
1522
+ actions.innerHTML = `
1523
+ <button type="button" class="msg-action-btn" data-copy-message="${esc(key)}" title="Copy answer">Copy</button>
1524
+ ${canFork ? `<button type="button" class="msg-action-btn" data-fork-message="${esc(key)}" title="Start a branch from this answer">Fork</button>` : ''}`;
1525
+ const timestamp = row.querySelector('.msg-ts');
1526
+ row.insertBefore(actions, timestamp || null);
1527
+ }
1528
+
1529
+ async function copyAssistantMessage(button) {
1530
+ const ref = assistantMessageRefs.get(button.dataset.copyMessage || '');
1531
+ if (!ref) return;
1532
+ try {
1533
+ await navigator.clipboard.writeText(ref.text);
1534
+ button.textContent = 'Copied';
1535
+ } catch {
1536
+ button.textContent = 'Failed';
1537
+ }
1538
+ setTimeout(() => { button.textContent = 'Copy'; }, 1300);
1539
+ }
1540
+
1541
+ async function forkAssistantMessage(button) {
1542
+ const ref = assistantMessageRefs.get(button.dataset.forkMessage || '');
1543
+ if (!ref || !ref.sessionId || ref.sessionId !== ctx.sessionId) return;
1544
+ button.disabled = true;
1545
+ button.textContent = 'Forking…';
1546
+ try {
1547
+ const res = await api('POST', `/api/sessions/${encodeURIComponent(ref.sessionId)}/fork`, {
1548
+ sourceAgent: ref.agent,
1549
+ ...(ref.historyIndex != null ? { upTo: ref.historyIndex } : {}),
1550
+ assistantText: ref.text,
1551
+ }, undefined, { machineId: ref.machineId || undefined });
1552
+ if (!res?.success || !res.targetSessionId) throw new Error('Fork did not create a session');
1553
+ const branch = {
1554
+ parentSessionId: ref.sessionId,
1555
+ parentAgent: ref.agent,
1556
+ parentMachineId: ref.machineId,
1557
+ forkMessageIndex: Number.isInteger(res.forkMessageIndex) ? res.forkMessageIndex : ref.historyIndex,
1558
+ forkedAt: Date.now(),
1559
+ };
1560
+ const metaRes = await api('PUT', '/api/sessions/meta', {
1561
+ sessionId: res.targetSessionId, agent: res.targetAgent || ref.agent, branch,
1562
+ machineId: ref.machineId || undefined,
1563
+ }, undefined, { machineId: ref.machineId || undefined });
1564
+ const key = metaKey(res.targetAgent || ref.agent, res.targetSessionId, ref.machineId);
1565
+ sessionMetaMap.value = {
1566
+ ...sessionMetaMap.peek(),
1567
+ [key]: {
1568
+ favorite: Boolean(metaRes?.favorite), notes: metaRes?.notes || '', title: metaRes?.title || '',
1569
+ hidden: Boolean(metaRes?.hidden), branch: metaRes?.branch || branch, updatedAt: metaRes?.updatedAt || Date.now(),
1570
+ },
1571
+ };
1572
+ const [{ loadAllSessions }, { resumeSession }] = await Promise.all([
1573
+ import('./shell/session-list.js'), import('./shell/session-nav.js'),
1574
+ ]);
1575
+ await loadAllSessions({ waitFresh: true });
1576
+ await resumeSession(res.targetSessionId, currentProject.peek()?.path || null, ctx.configDir || null,
1577
+ res.targetAgent || ref.agent, ref.machineId || null);
1578
+ appendSystemMsg('Fork created — this branch can now continue independently.');
1579
+ } catch (err) {
1580
+ button.textContent = 'Fork failed';
1581
+ appendSystemMsg(`Fork failed: ${err.message}`);
1582
+ setTimeout(() => { button.textContent = 'Fork'; }, 1800);
1583
+ } finally {
1584
+ button.disabled = false;
1585
+ if (button.textContent === 'Forking…') button.textContent = 'Fork';
1586
+ }
1348
1587
  }
1349
1588
 
1350
1589
  function appendStreamBubble(role) {
@@ -1620,12 +1859,8 @@ export function initChat() {
1620
1859
  watch(isProcessing, val => {
1621
1860
  $('send-btn')?.classList.toggle('hidden', val);
1622
1861
  $('stop-btn')?.classList.toggle('hidden', !val);
1623
- const input = $('chat-input');
1624
- if (input) {
1625
- input.disabled = false;
1626
- if (val) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
1627
- else updateInputMode(input.value || '');
1628
- }
1862
+ applyComposerAccess();
1863
+ if (!val) $('send-btn')?.toggleAttribute('disabled', !canWriteNow());
1629
1864
  const msgs = $('messages');
1630
1865
  if (!msgs) return;
1631
1866
  const existing = $('typing-indicator');
@@ -1641,6 +1876,8 @@ export function initChat() {
1641
1876
  });
1642
1877
 
1643
1878
  watch(chatDensity, () => applyChatDensity());
1879
+ document.addEventListener('session-lock-changed', () => applyComposerAccess());
1880
+ applyComposerAccess();
1644
1881
 
1645
1882
  // Chat input: ⌘/Ctrl+Enter = send. On phones, Enter sends (Shift+Enter newline).
1646
1883
  delegate.on('keydown', '#chat-input', e => {
@@ -1747,6 +1984,17 @@ export function initChat() {
1747
1984
  el.textContent = expanded ? '▲ Show less' : '▼ Show more';
1748
1985
  });
1749
1986
 
1987
+ delegate.on('click', '[data-copy-message]', (e, el) => {
1988
+ e.preventDefault();
1989
+ e.stopPropagation();
1990
+ void copyAssistantMessage(el);
1991
+ });
1992
+ delegate.on('click', '[data-fork-message]', (e, el) => {
1993
+ e.preventDefault();
1994
+ e.stopPropagation();
1995
+ void forkAssistantMessage(el);
1996
+ });
1997
+
1750
1998
  // Copy code from fenced blocks
1751
1999
  delegate.on('click', '.code-copy', async (e, el) => {
1752
2000
  e.preventDefault();
package/public/files.js CHANGED
@@ -2,6 +2,8 @@
2
2
  import { effect, asyncEffect, delegate, esc, $ } from './lib.js';
3
3
  import { currentProject, currentTab, filesRoot, filesPath, viewingFile, ctx } from './state.js';
4
4
  import { api, authHeaders, authQuery } from './api.js';
5
+ import { renderMermaid } from './mermaid.js';
6
+ import { renderMath } from './math.js';
5
7
 
6
8
  // ── Helpers ───────────────────────────────────────────────────────────────────
7
9
  function fileIcon(name) {
@@ -24,6 +26,11 @@ function officeKind(path) {
24
26
  return e === 'docx' || e === 'pptx' || e === 'xlsx' || e === 'pdf' ? e : null;
25
27
  }
26
28
 
29
+ function isMarkdownFile(path) {
30
+ const ext = extOf(path);
31
+ return ext === 'md' || ext === 'markdown' || ext === 'mdx';
32
+ }
33
+
27
34
  function sizeLabel(size) {
28
35
  if (size == null) return '';
29
36
  if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
@@ -136,6 +143,7 @@ function renderDir(files) {
136
143
 
137
144
  function renderTextFile(path, content, size) {
138
145
  const isBinary = /[\x00-\x08\x0e-\x1f]/.test(content.slice(0, 512));
146
+ if (!isBinary && isMarkdownFile(path)) return renderMarkdownFile(path, content, size);
139
147
  const lang = langFromPath(path);
140
148
  const langClass = lang ? `language-${esc(lang)}` : '';
141
149
  const body = isBinary
@@ -144,6 +152,37 @@ function renderTextFile(path, content, size) {
144
152
  return fileChrome(path, size, isBinary ? 'binary' : (lang || 'text')) + body;
145
153
  }
146
154
 
155
+ function markdownHtml(content) {
156
+ if (typeof marked !== 'undefined') {
157
+ try {
158
+ return marked.parse(content, { breaks: true, gfm: true });
159
+ } catch { /* Show the source below if parsing fails. */ }
160
+ }
161
+ return esc(content).replace(/\n/g, '<br>');
162
+ }
163
+
164
+ function renderMarkdownFile(path, content, size) {
165
+ return fileChrome(path, size, 'Markdown') + `
166
+ <div class="file-markdown-toolbar" role="group" aria-label="Markdown view">
167
+ <button type="button" class="file-markdown-mode active" data-file-render-mode="preview">Preview</button>
168
+ <button type="button" class="file-markdown-mode" data-file-render-mode="code">Code</button>
169
+ </div>
170
+ <div class="file-markdown-preview md p-4 overflow-auto flex-1">${markdownHtml(content)}</div>
171
+ <pre class="file-markdown-code file-preview p-4 font-mono text-xs leading-relaxed overflow-auto flex-1 m-0 hidden"><code class="file-preview-code language-markdown">${esc(content)}</code></pre>`;
172
+ }
173
+
174
+ function enhanceMarkdownPreview(root) {
175
+ const preview = root?.querySelector('.file-markdown-preview');
176
+ if (!preview) return;
177
+ if (typeof hljs !== 'undefined') {
178
+ preview.querySelectorAll('pre code:not(.hljs)').forEach(el => {
179
+ try { hljs.highlightElement(el); } catch { /* retain readable source */ }
180
+ });
181
+ }
182
+ renderMath(preview);
183
+ renderMermaid(preview);
184
+ }
185
+
147
186
  // ── Office previews ───────────────────────────────────────────────────────────
148
187
  async function renderDocx(arrayBuffer) {
149
188
  await loadScript('/vendor/mammoth.browser.min.js');
@@ -426,6 +465,7 @@ async function showFile(area, file, data, signal) {
426
465
  }
427
466
  area.innerHTML = renderTextFile(file, data.content || '', size);
428
467
  highlightFilePreview(area);
468
+ if (isMarkdownFile(file)) enhanceMarkdownPreview(area);
429
469
  }
430
470
 
431
471
  // ── Init ──────────────────────────────────────────────────────────────────────
@@ -494,6 +534,16 @@ export function initFilesTab() {
494
534
  if (!path) return;
495
535
  deleteFile(currentProject.peek(), path);
496
536
  });
537
+ delegate.on('click', '[data-file-render-mode]', (_, el) => {
538
+ const root = el.closest('#files-area');
539
+ if (!root) return;
540
+ const showCode = el.dataset.fileRenderMode === 'code';
541
+ root.querySelector('.file-markdown-preview')?.classList.toggle('hidden', showCode);
542
+ root.querySelector('.file-markdown-code')?.classList.toggle('hidden', !showCode);
543
+ root.querySelectorAll('[data-file-render-mode]').forEach(button => {
544
+ button.classList.toggle('active', button === el);
545
+ });
546
+ });
497
547
  // Excel sheet tabs
498
548
  delegate.on('click', '.office-sheet-tab', (_, el) => {
499
549
  const root = el.closest('.office-xlsx');
package/public/index.html CHANGED
@@ -85,7 +85,7 @@
85
85
  <script src="/vendor/mermaid.min.js"></script>
86
86
  <link rel="stylesheet" href="/vendor/katex.min.css">
87
87
  <script src="/vendor/katex.min.js"></script>
88
- <link rel="stylesheet" href="style.css?v=ui-20">
88
+ <link rel="stylesheet" href="style.css?v=ui-22">
89
89
  </head>
90
90
  <body class="h-dvh overflow-hidden flex flex-col bg-base-100 text-base-content">
91
91
  <div id="root" class="flex flex-col flex-1 overflow-hidden"></div>
@@ -101,6 +101,6 @@
101
101
  }
102
102
  }, 10000);
103
103
  </script>
104
- <script type="module" src="app.js?v=ui-20"></script>
104
+ <script type="module" src="app.js?v=ui-22"></script>
105
105
  </body>
106
106
  </html>