claude-simple 0.2.24 → 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.
- package/README.md +20 -0
- package/client.js +20 -3
- package/dist/app.0.2.25.min.js +1164 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.25.min.css +1753 -0
- package/package.json +1 -1
- package/public/api.js +1 -0
- package/public/chat.js +213 -8
- package/public/router.js +5 -0
- package/public/settings.js +312 -63
- package/public/shell/notes.js +5 -2
- package/public/shell/session-list.js +50 -2
- package/public/shell/session-nav.js +45 -12
- package/public/state.js +3 -2
- package/public/style.css +126 -0
- package/public/ui.min.css +1 -1
- package/src/agent-env.js +211 -0
- package/src/agents/claude.js +10 -3
- package/src/agents/grok.js +24 -0
- package/src/agents/models.js +30 -1
- package/src/agents/sessions.js +12 -2
- package/src/app.js +450 -14
- package/src/collab.js +248 -0
- package/src/runtime/external-session-sync.js +106 -0
- package/src/runtime/protocol.js +3 -2
- package/dist/app.0.2.24.min.js +0 -1104
- package/dist/style.0.2.24.min.css +0 -1627
package/package.json
CHANGED
package/public/api.js
CHANGED
|
@@ -54,6 +54,7 @@ export async function api(method, path, body, signal, opts = {}) {
|
|
|
54
54
|
method,
|
|
55
55
|
signal,
|
|
56
56
|
headers,
|
|
57
|
+
...(opts.cache ? { cache: opts.cache } : {}),
|
|
57
58
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
58
59
|
});
|
|
59
60
|
if (res.status === 304) return NOT_MODIFIED;
|
package/public/chat.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
7
|
import { initWakeLock, setWakeLockDesired } from './wake-lock.js';
|
|
8
8
|
import { pushNotification } from './notifications.js';
|
|
@@ -37,13 +37,39 @@ let sawDisconnect = false;
|
|
|
37
37
|
let lastWsMachine = null;
|
|
38
38
|
let reconnectQuiet = false;
|
|
39
39
|
const HUB_CURSOR_KEY = 'hubStreamCursors';
|
|
40
|
+
const assistantMessageRefs = new Map();
|
|
41
|
+
let assistantMessageSeq = 0;
|
|
42
|
+
const renderedGrokQueuePrompts = new Set();
|
|
43
|
+
const PENDING_QUEUE_KEY = 'pendingUserTexts';
|
|
40
44
|
/** FIFO messages deliberately queued while a turn is running. */
|
|
41
|
-
let pendingUserTexts =
|
|
45
|
+
let pendingUserTexts = (() => {
|
|
46
|
+
try {
|
|
47
|
+
const saved = JSON.parse(sessionStorage.getItem(PENDING_QUEUE_KEY) || '[]');
|
|
48
|
+
return Array.isArray(saved) ? saved.filter(x => x?.key && x?.text).slice(-30) : [];
|
|
49
|
+
} catch { return []; }
|
|
50
|
+
})();
|
|
51
|
+
const announcedRestoredQueues = new Set();
|
|
42
52
|
// Composer state belongs to a conversation, never to the shared textarea.
|
|
43
53
|
// Keeping this in memory deliberately avoids persisting potentially sensitive
|
|
44
54
|
// prompts to disk while still making session switches lossless.
|
|
45
55
|
const composerDrafts = new Map();
|
|
46
56
|
|
|
57
|
+
function persistPendingUserTexts() {
|
|
58
|
+
try {
|
|
59
|
+
if (pendingUserTexts.length) sessionStorage.setItem(PENDING_QUEUE_KEY, JSON.stringify(pendingUserTexts.slice(-30)));
|
|
60
|
+
else sessionStorage.removeItem(PENDING_QUEUE_KEY);
|
|
61
|
+
} catch { /* private mode / quota: in-memory queue still works */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function announceRestoredQueue() {
|
|
65
|
+
const key = draftKey();
|
|
66
|
+
const count = pendingUserTexts.filter(item => item.key === key).length;
|
|
67
|
+
if (count && !announcedRestoredQueues.has(key)) {
|
|
68
|
+
announcedRestoredQueues.add(key);
|
|
69
|
+
appendSystemMsg(`⌛ Restored ${count} queued prompt${count === 1 ? '' : 's'} in this browser tab`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
47
73
|
function hubCursorKey(sessionId = ctx.sessionId, agent = ctx.agent, machineId = ctx.machineId) {
|
|
48
74
|
return sessionId ? `${machineId || ''}:${agent || 'claude'}:${sessionId}` : '';
|
|
49
75
|
}
|
|
@@ -106,6 +132,7 @@ export function parkQueuedMessages(identity) {
|
|
|
106
132
|
const queued = pendingUserTexts.filter(item => item.key === key);
|
|
107
133
|
if (!queued.length) return;
|
|
108
134
|
pendingUserTexts = pendingUserTexts.filter(item => item.key !== key);
|
|
135
|
+
persistPendingUserTexts();
|
|
109
136
|
const existing = composerDrafts.get(key) || '';
|
|
110
137
|
composerDrafts.set(key, [existing, ...queued.map(item => item.text)].filter(Boolean).join('\n\n'));
|
|
111
138
|
}
|
|
@@ -126,7 +153,7 @@ function bindSocketLifecycle() {
|
|
|
126
153
|
ensureChatSocket({ probe: true });
|
|
127
154
|
// Re-subscribe so server can re-attach stream / clear stuck busy state
|
|
128
155
|
if (ctx.ws?.readyState === WebSocket.OPEN && ctx.sessionId) {
|
|
129
|
-
try { ctx.ws.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
|
|
156
|
+
try { ctx.ws.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId, agent: ctx.agent })); } catch { /* ignore */ }
|
|
130
157
|
}
|
|
131
158
|
}
|
|
132
159
|
});
|
|
@@ -225,6 +252,22 @@ function syncWakeLock() {
|
|
|
225
252
|
setWakeLockDesired(need);
|
|
226
253
|
}
|
|
227
254
|
|
|
255
|
+
/** External CLI transcripts are observable, but this UI must not race their
|
|
256
|
+
* terminal owner by submitting another turn into the same native session. */
|
|
257
|
+
export function setExternalSessionReadOnly(readOnly) {
|
|
258
|
+
ctx.externalSessionReadOnly = !!readOnly;
|
|
259
|
+
const input = $('chat-input');
|
|
260
|
+
const send = $('send-btn');
|
|
261
|
+
const attach = $('btn-attach');
|
|
262
|
+
if (input) {
|
|
263
|
+
input.disabled = !!readOnly;
|
|
264
|
+
if (readOnly) input.placeholder = '外部终端会话 · 实时同步中(只读)';
|
|
265
|
+
else if (!isProcessing.peek()) updateInputMode(input.value || '');
|
|
266
|
+
}
|
|
267
|
+
if (send) send.disabled = !!readOnly;
|
|
268
|
+
if (attach) attach.disabled = !!readOnly;
|
|
269
|
+
}
|
|
270
|
+
|
|
228
271
|
/** Manual reconnect (toolbar / banner). Resets backoff and forces a new socket. */
|
|
229
272
|
export function refreshConnection() {
|
|
230
273
|
reconnectDelay = 400;
|
|
@@ -273,9 +316,10 @@ export function connectWS(opts = {}) {
|
|
|
273
316
|
wsStatus.value = 'open';
|
|
274
317
|
updateConnDot();
|
|
275
318
|
if (ctx.sessionId) {
|
|
276
|
-
try { sock.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId })); } catch { /* ignore */ }
|
|
319
|
+
try { sock.send(JSON.stringify({ type: 'subscribe', sessionId: ctx.sessionId, agent: ctx.agent })); } catch { /* ignore */ }
|
|
277
320
|
}
|
|
278
321
|
flushWsQueue();
|
|
322
|
+
announceRestoredQueue();
|
|
279
323
|
startHeartbeat(sock, gen);
|
|
280
324
|
syncWakeLock();
|
|
281
325
|
if (sawDisconnect && !reconnectQuiet) {
|
|
@@ -307,6 +351,10 @@ export function connectWS(opts = {}) {
|
|
|
307
351
|
// Server says idle — unlock (fixes Grok lingering process / missed complete)
|
|
308
352
|
isProcessing.value = false;
|
|
309
353
|
flushPendingUserText();
|
|
354
|
+
} else {
|
|
355
|
+
// After reload the UI starts idle; server status is still authoritative
|
|
356
|
+
// enough to release browser-restored queued prompts.
|
|
357
|
+
flushPendingUserText();
|
|
310
358
|
}
|
|
311
359
|
syncWakeLock();
|
|
312
360
|
return;
|
|
@@ -336,6 +384,7 @@ function flushPendingUserText() {
|
|
|
336
384
|
// opened (or are converted to a draft during navigation).
|
|
337
385
|
if (index < 0) return;
|
|
338
386
|
const [pending] = pendingUserTexts.splice(index, 1);
|
|
387
|
+
persistPendingUserTexts();
|
|
339
388
|
const input = $('chat-input');
|
|
340
389
|
if (input) {
|
|
341
390
|
input.value = pending.text;
|
|
@@ -379,10 +428,27 @@ function handleWsMessage(msg) {
|
|
|
379
428
|
document.dispatchEvent(new CustomEvent('sessions-changed'));
|
|
380
429
|
return;
|
|
381
430
|
}
|
|
431
|
+
if (msg.type === 'external-sessions-changed') {
|
|
432
|
+
document.dispatchEvent(new CustomEvent('sessions-changed'));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (msg.type === 'external-session-updated') {
|
|
436
|
+
// The external CLI owns this conversation. Reloading the normalized native
|
|
437
|
+
// transcript is safer than trying to synthesize its varied JSONL events in
|
|
438
|
+
// the browser, and keeps reconnects/idempotency straightforward.
|
|
439
|
+
setExternalSessionReadOnly(true);
|
|
440
|
+
document.dispatchEvent(new CustomEvent('sessions-changed'));
|
|
441
|
+
document.dispatchEvent(new CustomEvent('external-session-updated', { detail: msg }));
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
382
444
|
if (msg.type === 'notification' && msg.notification) {
|
|
383
445
|
pushNotification(msg.notification);
|
|
384
446
|
return;
|
|
385
447
|
}
|
|
448
|
+
if (msg.type === 'grok-queue-status') {
|
|
449
|
+
renderGrokQueuePrompts(msg);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
386
452
|
if (msg.type === 'result') {
|
|
387
453
|
finalizeThoughtStream();
|
|
388
454
|
finalizeAssistantStream();
|
|
@@ -555,6 +621,7 @@ function finalizeAssistantStream() {
|
|
|
555
621
|
liveAsstBody.classList.add('md');
|
|
556
622
|
liveAsstBody.innerHTML = renderMarkdown(text);
|
|
557
623
|
enhanceMarkdown(liveAsstEl);
|
|
624
|
+
addAssistantMessageActions(liveAsstEl, text);
|
|
558
625
|
} else {
|
|
559
626
|
liveAsstEl.remove();
|
|
560
627
|
}
|
|
@@ -811,6 +878,10 @@ export function sendMessage() {
|
|
|
811
878
|
const input = $('chat-input');
|
|
812
879
|
const text = input?.value.trim();
|
|
813
880
|
if (!text || !currentProject.peek()) return;
|
|
881
|
+
if (ctx.externalSessionReadOnly) {
|
|
882
|
+
appendSystemMsg('此会话由外部终端运行,agentui 仅实时同步,不会发送输入。');
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
814
885
|
|
|
815
886
|
// While a turn is running, queue the next message (common Grok complaint).
|
|
816
887
|
if (isProcessing.peek()) {
|
|
@@ -829,14 +900,16 @@ export function sendMessage() {
|
|
|
829
900
|
...(currentPermission.peek() === 'bypassPermissions' && { allowDangerouslySkipPermissions: true }),
|
|
830
901
|
},
|
|
831
902
|
});
|
|
903
|
+
appendQueuedGrokPrompt(text);
|
|
832
904
|
input.value = '';
|
|
833
905
|
autoResize(input);
|
|
834
906
|
updateInputMode('');
|
|
835
907
|
clearPendingAttachments();
|
|
836
|
-
appendSystemMsg(ok ? '已加入 Grok
|
|
908
|
+
appendSystemMsg(ok ? '已加入 Grok 服务端队列,刷新页面仍会显示' : '连接未就绪,消息将在重连后发送');
|
|
837
909
|
return;
|
|
838
910
|
}
|
|
839
911
|
pendingUserTexts.push({ key: draftKey(), text });
|
|
912
|
+
persistPendingUserTexts();
|
|
840
913
|
input.value = '';
|
|
841
914
|
autoResize(input);
|
|
842
915
|
updateInputMode('');
|
|
@@ -907,6 +980,8 @@ export function stopProcessing() {
|
|
|
907
980
|
sendWs({ type: 'command', cmd: 'turn.interrupt', sessionId: ctx.sessionId });
|
|
908
981
|
}
|
|
909
982
|
pendingUserTexts = pendingUserTexts.filter(item => item.key !== draftKey());
|
|
983
|
+
persistPendingUserTexts();
|
|
984
|
+
markQueuedGrokPromptsCancelled();
|
|
910
985
|
markTurnDone();
|
|
911
986
|
}
|
|
912
987
|
|
|
@@ -1017,7 +1092,7 @@ function bubbleHtml(role, text, ts) {
|
|
|
1017
1092
|
case 'assistant': {
|
|
1018
1093
|
const long = text.length > ASST_COLLAPSE;
|
|
1019
1094
|
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>`;
|
|
1095
|
+
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
1096
|
break;
|
|
1022
1097
|
}
|
|
1023
1098
|
case 'error':
|
|
@@ -1047,6 +1122,7 @@ export function appendMsg(role, _label, text, opts = {}) {
|
|
|
1047
1122
|
const ts = coerceTs(opts.ts) ?? (role === 'system' ? null : Date.now());
|
|
1048
1123
|
msgs.insertAdjacentHTML('beforeend', bubbleHtml(role, text, ts));
|
|
1049
1124
|
enhanceMarkdown(msgs.lastElementChild);
|
|
1125
|
+
if (role === 'assistant') addAssistantMessageActions(msgs.lastElementChild, text, opts);
|
|
1050
1126
|
stickBottom(msgs, role === 'user');
|
|
1051
1127
|
}
|
|
1052
1128
|
|
|
@@ -1345,6 +1421,122 @@ export function clearMessages() {
|
|
|
1345
1421
|
toolBatch = null;
|
|
1346
1422
|
liveAsstEl = liveAsstBody = null;
|
|
1347
1423
|
liveThoughtEl = liveThoughtBody = null;
|
|
1424
|
+
assistantMessageRefs.clear();
|
|
1425
|
+
renderedGrokQueuePrompts.clear();
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
function queuedGrokPromptKey(text, sessionId = ctx.sessionId) {
|
|
1429
|
+
return `${sessionId || ''}\n${String(text || '')}`;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** Paint a server-accepted Grok follow-up before the native transcript writes
|
|
1433
|
+
* it. This makes the queued user prompt visible both immediately and after a
|
|
1434
|
+
* reconnect (the server sends the same snapshot on subscribe). */
|
|
1435
|
+
function appendQueuedGrokPrompt(text, sessionId = ctx.sessionId) {
|
|
1436
|
+
const key = queuedGrokPromptKey(text, sessionId);
|
|
1437
|
+
if (!text || renderedGrokQueuePrompts.has(key)) return;
|
|
1438
|
+
renderedGrokQueuePrompts.add(key);
|
|
1439
|
+
appendMsg('user', 'You', text, { ts: Date.now() });
|
|
1440
|
+
$('messages')?.lastElementChild?.classList.add('queued-user-msg');
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function renderGrokQueuePrompts(msg) {
|
|
1444
|
+
if ((msg.sessionId || ctx.sessionId) !== ctx.sessionId) return;
|
|
1445
|
+
const items = Array.isArray(msg.items) ? msg.items.filter(x => typeof x === 'string' && x.trim()) : [];
|
|
1446
|
+
for (const text of items) appendQueuedGrokPrompt(text, msg.sessionId || ctx.sessionId);
|
|
1447
|
+
if (items.length) appendSystemMsg(`⌛ Grok queue · ${items.length} prompt${items.length === 1 ? '' : 's'} waiting`);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function markQueuedGrokPromptsCancelled() {
|
|
1451
|
+
const msgs = $('messages');
|
|
1452
|
+
if (!msgs) return;
|
|
1453
|
+
msgs.querySelectorAll('.queued-user-msg').forEach(row => {
|
|
1454
|
+
row.classList.remove('queued-user-msg');
|
|
1455
|
+
row.classList.add('cancelled-queued-user-msg');
|
|
1456
|
+
});
|
|
1457
|
+
renderedGrokQueuePrompts.clear();
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** Per-answer controls stay below the assistant message. Raw answer text lives
|
|
1461
|
+
* only in this in-memory map, never in a DOM data attribute. */
|
|
1462
|
+
function addAssistantMessageActions(row, text, opts = {}) {
|
|
1463
|
+
if (!row || !text || row.querySelector('.msg-actions')) return;
|
|
1464
|
+
const key = `a${++assistantMessageSeq}`;
|
|
1465
|
+
assistantMessageRefs.set(key, {
|
|
1466
|
+
text,
|
|
1467
|
+
historyIndex: Number.isInteger(opts.historyIndex) ? opts.historyIndex : null,
|
|
1468
|
+
sessionId: ctx.sessionId,
|
|
1469
|
+
agent: ctx.agent || currentAgent.peek() || 'claude',
|
|
1470
|
+
machineId: ctx.machineId || null,
|
|
1471
|
+
});
|
|
1472
|
+
const canFork = !['gemini', 'cursor'].includes(ctx.agent || currentAgent.peek() || 'claude');
|
|
1473
|
+
const actions = document.createElement('div');
|
|
1474
|
+
actions.className = 'msg-actions';
|
|
1475
|
+
actions.innerHTML = `
|
|
1476
|
+
<button type="button" class="msg-action-btn" data-copy-message="${esc(key)}" title="Copy answer">Copy</button>
|
|
1477
|
+
${canFork ? `<button type="button" class="msg-action-btn" data-fork-message="${esc(key)}" title="Start a branch from this answer">Fork</button>` : ''}`;
|
|
1478
|
+
const timestamp = row.querySelector('.msg-ts');
|
|
1479
|
+
row.insertBefore(actions, timestamp || null);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
async function copyAssistantMessage(button) {
|
|
1483
|
+
const ref = assistantMessageRefs.get(button.dataset.copyMessage || '');
|
|
1484
|
+
if (!ref) return;
|
|
1485
|
+
try {
|
|
1486
|
+
await navigator.clipboard.writeText(ref.text);
|
|
1487
|
+
button.textContent = 'Copied';
|
|
1488
|
+
} catch {
|
|
1489
|
+
button.textContent = 'Failed';
|
|
1490
|
+
}
|
|
1491
|
+
setTimeout(() => { button.textContent = 'Copy'; }, 1300);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async function forkAssistantMessage(button) {
|
|
1495
|
+
const ref = assistantMessageRefs.get(button.dataset.forkMessage || '');
|
|
1496
|
+
if (!ref || !ref.sessionId || ref.sessionId !== ctx.sessionId) return;
|
|
1497
|
+
button.disabled = true;
|
|
1498
|
+
button.textContent = 'Forking…';
|
|
1499
|
+
try {
|
|
1500
|
+
const res = await api('POST', `/api/sessions/${encodeURIComponent(ref.sessionId)}/fork`, {
|
|
1501
|
+
sourceAgent: ref.agent,
|
|
1502
|
+
...(ref.historyIndex != null ? { upTo: ref.historyIndex } : {}),
|
|
1503
|
+
assistantText: ref.text,
|
|
1504
|
+
}, undefined, { machineId: ref.machineId || undefined });
|
|
1505
|
+
if (!res?.success || !res.targetSessionId) throw new Error('Fork did not create a session');
|
|
1506
|
+
const branch = {
|
|
1507
|
+
parentSessionId: ref.sessionId,
|
|
1508
|
+
parentAgent: ref.agent,
|
|
1509
|
+
parentMachineId: ref.machineId,
|
|
1510
|
+
forkMessageIndex: Number.isInteger(res.forkMessageIndex) ? res.forkMessageIndex : ref.historyIndex,
|
|
1511
|
+
forkedAt: Date.now(),
|
|
1512
|
+
};
|
|
1513
|
+
const metaRes = await api('PUT', '/api/sessions/meta', {
|
|
1514
|
+
sessionId: res.targetSessionId, agent: res.targetAgent || ref.agent, branch,
|
|
1515
|
+
machineId: ref.machineId || undefined,
|
|
1516
|
+
}, undefined, { machineId: ref.machineId || undefined });
|
|
1517
|
+
const key = metaKey(res.targetAgent || ref.agent, res.targetSessionId, ref.machineId);
|
|
1518
|
+
sessionMetaMap.value = {
|
|
1519
|
+
...sessionMetaMap.peek(),
|
|
1520
|
+
[key]: {
|
|
1521
|
+
favorite: Boolean(metaRes?.favorite), notes: metaRes?.notes || '', title: metaRes?.title || '',
|
|
1522
|
+
hidden: Boolean(metaRes?.hidden), branch: metaRes?.branch || branch, updatedAt: metaRes?.updatedAt || Date.now(),
|
|
1523
|
+
},
|
|
1524
|
+
};
|
|
1525
|
+
const [{ loadAllSessions }, { resumeSession }] = await Promise.all([
|
|
1526
|
+
import('./shell/session-list.js'), import('./shell/session-nav.js'),
|
|
1527
|
+
]);
|
|
1528
|
+
await loadAllSessions({ waitFresh: true });
|
|
1529
|
+
await resumeSession(res.targetSessionId, currentProject.peek()?.path || null, ctx.configDir || null,
|
|
1530
|
+
res.targetAgent || ref.agent, ref.machineId || null);
|
|
1531
|
+
appendSystemMsg('Fork created — this branch can now continue independently.');
|
|
1532
|
+
} catch (err) {
|
|
1533
|
+
button.textContent = 'Fork failed';
|
|
1534
|
+
appendSystemMsg(`Fork failed: ${err.message}`);
|
|
1535
|
+
setTimeout(() => { button.textContent = 'Fork'; }, 1800);
|
|
1536
|
+
} finally {
|
|
1537
|
+
button.disabled = false;
|
|
1538
|
+
if (button.textContent === 'Forking…') button.textContent = 'Fork';
|
|
1539
|
+
}
|
|
1348
1540
|
}
|
|
1349
1541
|
|
|
1350
1542
|
function appendStreamBubble(role) {
|
|
@@ -1622,10 +1814,12 @@ export function initChat() {
|
|
|
1622
1814
|
$('stop-btn')?.classList.toggle('hidden', !val);
|
|
1623
1815
|
const input = $('chat-input');
|
|
1624
1816
|
if (input) {
|
|
1625
|
-
input.disabled =
|
|
1626
|
-
if (
|
|
1817
|
+
input.disabled = !!ctx.externalSessionReadOnly;
|
|
1818
|
+
if (ctx.externalSessionReadOnly) input.placeholder = '外部终端会话 · 实时同步中(只读)';
|
|
1819
|
+
else if (val) input.placeholder = '回复中… 可先写好下一条,结束后自动发送 · 或点 ■ 停止';
|
|
1627
1820
|
else updateInputMode(input.value || '');
|
|
1628
1821
|
}
|
|
1822
|
+
if (!val) $('send-btn')?.toggleAttribute('disabled', !!ctx.externalSessionReadOnly);
|
|
1629
1823
|
const msgs = $('messages');
|
|
1630
1824
|
if (!msgs) return;
|
|
1631
1825
|
const existing = $('typing-indicator');
|
|
@@ -1747,6 +1941,17 @@ export function initChat() {
|
|
|
1747
1941
|
el.textContent = expanded ? '▲ Show less' : '▼ Show more';
|
|
1748
1942
|
});
|
|
1749
1943
|
|
|
1944
|
+
delegate.on('click', '[data-copy-message]', (e, el) => {
|
|
1945
|
+
e.preventDefault();
|
|
1946
|
+
e.stopPropagation();
|
|
1947
|
+
void copyAssistantMessage(el);
|
|
1948
|
+
});
|
|
1949
|
+
delegate.on('click', '[data-fork-message]', (e, el) => {
|
|
1950
|
+
e.preventDefault();
|
|
1951
|
+
e.stopPropagation();
|
|
1952
|
+
void forkAssistantMessage(el);
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1750
1955
|
// Copy code from fenced blocks
|
|
1751
1956
|
delegate.on('click', '.code-copy', async (e, el) => {
|
|
1752
1957
|
e.preventDefault();
|
package/public/router.js
CHANGED
|
@@ -54,6 +54,11 @@ export function applyInitialRoute() {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function applyHash(path) {
|
|
57
|
+
const pathname = String(path || '').split('?')[0];
|
|
58
|
+
if (pathname === '/settings') {
|
|
59
|
+
document.dispatchEvent(new CustomEvent('router:settings'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
57
62
|
// /session/:id or /session/:id/:tab
|
|
58
63
|
const parsed = parseHashPath(path);
|
|
59
64
|
if (!parsed) {
|