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/README.md +25 -2
- package/client.js +20 -3
- package/dist/app.0.2.26.min.js +1179 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.26.min.css +1851 -0
- package/package.json +1 -1
- package/public/api.js +10 -3
- package/public/app.js +1 -1
- package/public/chat.js +260 -12
- package/public/files.js +50 -0
- package/public/index.html +2 -2
- package/public/locks.js +139 -0
- package/public/privacy.html +111 -0
- package/public/router.js +5 -0
- package/public/settings.js +312 -63
- package/public/shell/boot.js +3 -0
- package/public/shell/notes.js +11 -3
- package/public/shell/session-list.js +50 -2
- package/public/shell/session-nav.js +49 -12
- package/public/shell/templates.js +5 -1
- package/public/shell.js +16 -4
- package/public/state.js +6 -2
- package/public/style.css +227 -3
- package/public/sw.js +3 -3
- 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/meta-agent.js +89 -17
- package/src/agents/models.js +30 -1
- package/src/agents/sessions.js +12 -2
- package/src/app.js +512 -25
- package/src/collab.js +267 -0
- package/src/runtime/external-session-sync.js +106 -0
- package/src/runtime/protocol.js +11 -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
|
@@ -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
|
-
|
|
1624
|
-
if (
|
|
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-
|
|
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-
|
|
104
|
+
<script type="module" src="app.js?v=ui-22"></script>
|
|
105
105
|
</body>
|
|
106
106
|
</html>
|