synabun 2026.4.21 → 2026.4.25-beta.2
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/CHANGELOG.md +175 -881
- package/README.md +1 -1
- package/hooks/claude-code/prompt-submit.mjs +39 -18
- package/hooks/claude-code/session-start.mjs +10 -3
- package/hooks/claude-code/shared.mjs +39 -2
- package/hooks/claude-code/stop.mjs +33 -6
- package/mcp-server/src/index.ts +9 -1
- package/mcp-server/src/services/neural-interface.ts +42 -14
- package/mcp-server/src/services/profiles.ts +8 -7
- package/mcp-server/src/tools/browser-advanced.ts +12 -10
- package/mcp-server/src/tools/browser-interact.ts +39 -38
- package/mcp-server/src/tools/browser-navigate.ts +12 -4
- package/mcp-server/src/tools/loop.ts +7 -3
- package/mcp-server/src/tools/profile.ts +19 -1
- package/neural-interface/lib/loop-logger.js +103 -0
- package/neural-interface/lib/mcp-installer.js +109 -0
- package/neural-interface/lib/plugin-installer.js +264 -0
- package/neural-interface/package.json +1 -1
- package/neural-interface/public/shared/api.js +66 -17
- package/neural-interface/public/shared/cdx/cdx-icons.js +5 -4
- package/neural-interface/public/shared/cdx/cdx-panel.js +117 -14
- package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
- package/neural-interface/public/shared/cdx/cdx-styles.js +55 -10
- package/neural-interface/public/shared/cdx/cdx-tabs.js +394 -72
- package/neural-interface/public/shared/cli-status.js +157 -0
- package/neural-interface/public/shared/constants.js +3 -2
- package/neural-interface/public/shared/html-shell.js +32 -11
- package/neural-interface/public/shared/ocp/ocp-panel.js +127 -28
- package/neural-interface/public/shared/ocp/ocp-render.js +52 -44
- package/neural-interface/public/shared/ocp/ocp-styles.js +33 -0
- package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
- package/neural-interface/public/shared/styles.css +635 -0
- package/neural-interface/public/shared/ui-automation-studio.js +38 -12
- package/neural-interface/public/shared/ui-claude-panel.js +322 -15
- package/neural-interface/public/shared/ui-file-explorer.js +38 -18
- package/neural-interface/public/shared/ui-menubar.js +34 -10
- package/neural-interface/public/shared/ui-navbar.js +8 -6
- package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
- package/neural-interface/public/shared/ui-settings.js +274 -50
- package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
- package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
- package/neural-interface/public/shared/ui-sync.js +9 -5
- package/neural-interface/public/shared/ui-terminal.js +76 -27
- package/neural-interface/public/shared/ui-tool-updates.js +49 -15
- package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
- package/neural-interface/public/shared/ui-tutorial.js +24 -18
- package/neural-interface/public/shared/ui-whiteboard.js +79 -31
- package/neural-interface/public/shared/vterm-buffer.js +9 -8
- package/neural-interface/public/variant/2d/main.js +9 -7
- package/neural-interface/public/variant/3d/main.js +10 -8
- package/neural-interface/server.js +1292 -255
- package/package.json +1 -1
- package/skills/leonardo/SKILL.md +5 -1
- package/skills/leonardo/modules/image-prompter.md +5 -1
- package/skills/leonardo/modules/video-prompter.md +5 -1
- package/skills/synabun/SKILL.md +44 -26
- package/skills/synabun/modules/audit.md +40 -36
- package/skills/synabun/modules/changelog.md +22 -18
- package/skills/synabun/modules/memorize.md +19 -15
- package/skills/synabun/modules/schedule.md +285 -0
- package/CLAUDE.md +0 -217
|
@@ -42,8 +42,29 @@ export const STOR = {
|
|
|
42
42
|
agent: 'synabun-ocp-agent',
|
|
43
43
|
project: 'synabun-ocp-project',
|
|
44
44
|
activity: 'synabun-ocp-activity-open',
|
|
45
|
+
sessionSnapshots: 'synabun-ocp-session-snapshots', // global, per-sessionId rendered HTML cache
|
|
45
46
|
};
|
|
46
47
|
|
|
48
|
+
// ── Session HTML snapshot cache ──
|
|
49
|
+
// Mirrors the Codex/Claude snapshot pattern: every render fills a per-session
|
|
50
|
+
// HTML cache so a fresh browser load can blit the exact transcript back into
|
|
51
|
+
// the DOM instead of rebuilding from upstream `messages:list`, which loses
|
|
52
|
+
// streaming state, MCP card formatting, and intermediate UI cards.
|
|
53
|
+
const MAX_OCP_SESSION_SNAPSHOTS = 24;
|
|
54
|
+
const MAX_OCP_SESSION_SNAPSHOT_CHARS = 2_500_000;
|
|
55
|
+
let _sessionSnapshots = (() => {
|
|
56
|
+
try {
|
|
57
|
+
const raw = storage.getItem('synabun-ocp-session-snapshots');
|
|
58
|
+
if (!raw) return {};
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
|
|
61
|
+
} catch { return {}; }
|
|
62
|
+
})();
|
|
63
|
+
let _snapshotObserver = null;
|
|
64
|
+
let _snapshotObserverContainer = null;
|
|
65
|
+
let _snapshotDebounceTimer = null;
|
|
66
|
+
let _snapshotRestoring = false;
|
|
67
|
+
|
|
47
68
|
const ACTIVITY_MAX = 500;
|
|
48
69
|
|
|
49
70
|
function ensureNotifState(tab) {
|
|
@@ -110,6 +131,7 @@ let _onUpdate = null; // callback to refresh UI
|
|
|
110
131
|
let _panelVisible = false;
|
|
111
132
|
let _showPanel = null; // callback to show panel from tray pill click
|
|
112
133
|
let _hidePanel = null; // callback to close panel (last-tab-close)
|
|
134
|
+
let _activeChildSessionId = null; // sessionId of the currently active child agent's subprocess
|
|
113
135
|
|
|
114
136
|
export function getTabs() { return _tabs; }
|
|
115
137
|
export function getActiveTabIdx() { return _activeTabIdx; }
|
|
@@ -177,6 +199,140 @@ function getModelContextWindow(modelStr) {
|
|
|
177
199
|
|
|
178
200
|
function panelEl(sel) { return _panelEl?.querySelector(sel) || null; }
|
|
179
201
|
|
|
202
|
+
function runtimeSet(tab, key) {
|
|
203
|
+
if (!tab) return new Set();
|
|
204
|
+
if (!(tab[key] instanceof Set)) tab[key] = new Set();
|
|
205
|
+
return tab[key];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function runtimeMap(tab, key) {
|
|
209
|
+
if (!tab) return new Map();
|
|
210
|
+
if (!(tab[key] instanceof Map)) tab[key] = new Map();
|
|
211
|
+
return tab[key];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function userMsgIds(tab) {
|
|
215
|
+
return runtimeSet(tab, '_userMsgIds');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function partTypes(tab) {
|
|
219
|
+
return runtimeMap(tab, '_partTypes');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function renderedAssistantMsgIds(tab) {
|
|
223
|
+
return runtimeSet(tab, '_renderedAssistantMsgIds');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function currentTurn(tab) {
|
|
227
|
+
if (!tab) return null;
|
|
228
|
+
if (!tab._currentTurn) {
|
|
229
|
+
tab._currentTurn = {
|
|
230
|
+
id: '',
|
|
231
|
+
startAssistantCount: 0,
|
|
232
|
+
streamedText: false,
|
|
233
|
+
assistantIds: new Set(),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (!(tab._currentTurn.assistantIds instanceof Set)) {
|
|
237
|
+
tab._currentTurn.assistantIds = new Set(tab._currentTurn.assistantIds || []);
|
|
238
|
+
}
|
|
239
|
+
return tab._currentTurn;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function beginTurn(tab, container) {
|
|
243
|
+
const turn = currentTurn(tab);
|
|
244
|
+
turn.id = (globalThis.crypto?.randomUUID?.() || `turn-${Date.now()}-${Math.random()}`);
|
|
245
|
+
turn.startAssistantCount = container?.querySelectorAll('.ocp-msg-assistant').length || 0;
|
|
246
|
+
turn.streamedText = false;
|
|
247
|
+
turn.assistantIds.clear();
|
|
248
|
+
return turn;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function markAssistantRendered(tab, messageId) {
|
|
252
|
+
if (!tab || !messageId) return;
|
|
253
|
+
renderedAssistantMsgIds(tab).add(messageId);
|
|
254
|
+
_renderedAssistantMsgIds.add(messageId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function hasAssistantRendered(tab, messageId) {
|
|
258
|
+
if (!messageId) return false;
|
|
259
|
+
return renderedAssistantMsgIds(tab).has(messageId) || _renderedAssistantMsgIds.has(messageId);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function tagLatestAssistant(container, tab, messageId = '') {
|
|
263
|
+
if (!container || !tab) return null;
|
|
264
|
+
const els = container.querySelectorAll('.ocp-msg-assistant');
|
|
265
|
+
const el = els[els.length - 1] || null;
|
|
266
|
+
if (!el) return null;
|
|
267
|
+
const turn = currentTurn(tab);
|
|
268
|
+
if (turn?.id) el.dataset.turnId = turn.id;
|
|
269
|
+
if (messageId) el.dataset.messageId = String(messageId);
|
|
270
|
+
return el;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function cssEscape(value) {
|
|
274
|
+
if (globalThis.CSS?.escape) return globalThis.CSS.escape(String(value));
|
|
275
|
+
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function markCurrentTurnAssistantContent(tab, container, messageId = '') {
|
|
279
|
+
const turn = currentTurn(tab);
|
|
280
|
+
if (!turn) return;
|
|
281
|
+
turn.streamedText = true;
|
|
282
|
+
if (messageId) turn.assistantIds.add(messageId);
|
|
283
|
+
tagLatestAssistant(container, tab, messageId);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function currentTurnHasAssistantContent(tab, container, messageId = '') {
|
|
287
|
+
const turn = currentTurn(tab);
|
|
288
|
+
if (!turn) return false;
|
|
289
|
+
if (messageId && turn.assistantIds.has(messageId)) return true;
|
|
290
|
+
if (turn.streamedText) return true;
|
|
291
|
+
if (!container) return false;
|
|
292
|
+
if (turn.id && container.querySelector(`.ocp-msg-assistant[data-turn-id="${cssEscape(turn.id)}"]`)) return true;
|
|
293
|
+
const count = container.querySelectorAll('.ocp-msg-assistant').length;
|
|
294
|
+
return count > (turn.startAssistantCount || 0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function endTurn(tab) {
|
|
298
|
+
if (!tab) return;
|
|
299
|
+
const turn = currentTurn(tab);
|
|
300
|
+
turn.id = '';
|
|
301
|
+
turn.startAssistantCount = 0;
|
|
302
|
+
turn.streamedText = false;
|
|
303
|
+
turn.assistantIds.clear();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function eventSessionId(event) {
|
|
307
|
+
return event?.sessionID || event?.sessionId || event?.session?.id || event?.part?.sessionID || event?.part?.sessionId || '';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function payloadHasAssistantText(value) {
|
|
311
|
+
if (value == null) return false;
|
|
312
|
+
if (typeof value === 'string') return Boolean(value.trim());
|
|
313
|
+
if (typeof value === 'number' || typeof value === 'boolean') return true;
|
|
314
|
+
if (Array.isArray(value)) return value.some(payloadHasAssistantText);
|
|
315
|
+
if (typeof value !== 'object') return false;
|
|
316
|
+
const type = String(value.type || '').toLowerCase();
|
|
317
|
+
if (type.includes('tool')) return false;
|
|
318
|
+
if (['text', 'reasoning', 'thinking', 'thought'].includes(type)) {
|
|
319
|
+
return payloadHasAssistantText(value.text ?? value.content ?? value.value ?? value.message ?? value.reasoning ?? '');
|
|
320
|
+
}
|
|
321
|
+
return payloadHasAssistantText(value.content)
|
|
322
|
+
|| payloadHasAssistantText(value.parts)
|
|
323
|
+
|| payloadHasAssistantText(value.text)
|
|
324
|
+
|| payloadHasAssistantText(value.value)
|
|
325
|
+
|| payloadHasAssistantText(value.message);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function findTabBySessionId(sessionId) {
|
|
329
|
+
if (!sessionId) return null;
|
|
330
|
+
const sid = String(sessionId);
|
|
331
|
+
return _tabs.find((tab) => tab?.sessionId === sid)
|
|
332
|
+
|| _tabs.find((tab) => tab?.toolActivityChildSessions && tab.toolActivityChildSessions[sid])
|
|
333
|
+
|| null;
|
|
334
|
+
}
|
|
335
|
+
|
|
180
336
|
function hasActiveQuestion() {
|
|
181
337
|
return _activeQuestionToolId !== null;
|
|
182
338
|
}
|
|
@@ -336,6 +492,7 @@ export function createTab(opts = {}) {
|
|
|
336
492
|
planFilePath: opts.planFilePath || '',
|
|
337
493
|
editedPlanContent: opts.editedPlanContent || '',
|
|
338
494
|
showPostPlanActions: opts.showPostPlanActions || false,
|
|
495
|
+
postPlanHeader: opts.postPlanHeader || 'PLAN COMPLETE',
|
|
339
496
|
pendingTitle: opts.pendingTitle || null, // user-named before server session exists; applied via renameSession once sessionId assigned
|
|
340
497
|
toolActivity: [],
|
|
341
498
|
toolActivityChildSessions: {},
|
|
@@ -358,6 +515,7 @@ export function switchTab(idx) {
|
|
|
358
515
|
const prev = activeTab();
|
|
359
516
|
const input = panelEl('#ocp-input');
|
|
360
517
|
if (prev && input) prev.draft = input.value;
|
|
518
|
+
if (prev) flushSessionSnapshotSave(prev);
|
|
361
519
|
|
|
362
520
|
_activeTabIdx = idx;
|
|
363
521
|
|
|
@@ -375,6 +533,7 @@ export function switchTab(idx) {
|
|
|
375
533
|
export function closeTab(idx) {
|
|
376
534
|
if (idx < 0 || idx >= _tabs.length) return;
|
|
377
535
|
const tab = _tabs[idx];
|
|
536
|
+
flushSessionSnapshotSave(tab);
|
|
378
537
|
if (tab.pillEl) tab.pillEl.remove();
|
|
379
538
|
if (tab.trayPillEl) tab.trayPillEl.remove();
|
|
380
539
|
_tabs.splice(idx, 1);
|
|
@@ -470,7 +629,8 @@ export function saveTabs() {
|
|
|
470
629
|
const data = _tabs.map(t => ({
|
|
471
630
|
id: t.id, sessionId: t.sessionId, sessionTitle: t.sessionTitle,
|
|
472
631
|
model: t.model, mode: t.mode, agent: t.agent, project: t.project, draft: t.draft,
|
|
473
|
-
planContent: t.planContent, planFilePath: t.planFilePath,
|
|
632
|
+
planContent: t.planContent, planFilePath: t.planFilePath, editedPlanContent: t.editedPlanContent,
|
|
633
|
+
showPostPlanActions: t.showPostPlanActions, postPlanHeader: t.postPlanHeader,
|
|
474
634
|
threadTokenUsage: t.threadTokenUsage,
|
|
475
635
|
}));
|
|
476
636
|
storage.setItem(STOR.tabs, JSON.stringify({ activeIdx: _activeTabIdx, tabs: data }));
|
|
@@ -509,8 +669,7 @@ export async function createSession() {
|
|
|
509
669
|
const tab = activeTab();
|
|
510
670
|
reconcileTabMode(tab);
|
|
511
671
|
const body = {};
|
|
512
|
-
if (tab?.
|
|
513
|
-
if (tab?.agent) body.agent = tab.agent;
|
|
672
|
+
if (tab?.pendingTitle) body.title = tab.pendingTitle;
|
|
514
673
|
const cwd = tab?.project || '';
|
|
515
674
|
const resp = await requestWs('session:create', { body, ...(cwd ? { cwd } : {}) });
|
|
516
675
|
const session = resp.data;
|
|
@@ -622,25 +781,61 @@ export async function executeCommand(sessionId, command, args = '') {
|
|
|
622
781
|
// ── Plan mode helpers ──
|
|
623
782
|
|
|
624
783
|
function capturePlanContent(tab) {
|
|
625
|
-
if (!tab || tab.mode !== 'plan') return;
|
|
784
|
+
if (!tab || tab.mode !== 'plan') return tab?.planContent || '';
|
|
626
785
|
const container = panelEl('#ocp-messages');
|
|
627
|
-
if (!container) return;
|
|
786
|
+
if (!container) return tab.planContent || '';
|
|
628
787
|
const assistantEls = container.querySelectorAll('.ocp-msg-assistant');
|
|
629
|
-
if (!assistantEls.length) return;
|
|
788
|
+
if (!assistantEls.length) return tab.planContent || '';
|
|
630
789
|
const lastEl = assistantEls[assistantEls.length - 1];
|
|
631
790
|
const text = (lastEl.textContent || '').trim();
|
|
632
791
|
if (text.length > 80) tab.planContent = text;
|
|
792
|
+
return tab.planContent || '';
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export async function ensurePlanFile(tab) {
|
|
796
|
+
if (!tab) return '';
|
|
797
|
+
if (tab.planFilePath) return tab.planFilePath;
|
|
798
|
+
if (tab._planFilePromise) return tab._planFilePromise;
|
|
799
|
+
const planText = tab.planContent || capturePlanContent(tab);
|
|
800
|
+
if (!planText) return '';
|
|
801
|
+
|
|
802
|
+
tab._planFilePromise = fetch('/api/create-plan', {
|
|
803
|
+
method: 'POST',
|
|
804
|
+
headers: { 'Content-Type': 'application/json' },
|
|
805
|
+
body: JSON.stringify({ content: planText, cwd: tab.project || '' }),
|
|
806
|
+
})
|
|
807
|
+
.then(async (res) => {
|
|
808
|
+
const result = await res.json().catch(() => ({}));
|
|
809
|
+
if (!res.ok || !result?.ok || !result.path) {
|
|
810
|
+
throw new Error(result?.error || 'create-plan failed');
|
|
811
|
+
}
|
|
812
|
+
tab.planFilePath = result.path;
|
|
813
|
+
saveTabs();
|
|
814
|
+
return result.path;
|
|
815
|
+
})
|
|
816
|
+
.catch((err) => {
|
|
817
|
+
console.error('[ocp-tabs] create-plan failed:', err);
|
|
818
|
+
return '';
|
|
819
|
+
})
|
|
820
|
+
.finally(() => {
|
|
821
|
+
tab._planFilePromise = null;
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
return tab._planFilePromise;
|
|
633
825
|
}
|
|
634
826
|
|
|
635
|
-
function showPostPlanUI(tab) {
|
|
827
|
+
export function showPostPlanUI(tab, headerText = null) {
|
|
636
828
|
if (!tab) return;
|
|
637
829
|
const container = panelEl('#ocp-messages');
|
|
638
830
|
if (!container) return;
|
|
639
831
|
// Remove any existing post-plan cards before appending a new one
|
|
640
832
|
removePostPlanCards(container);
|
|
641
833
|
tab.showPostPlanActions = true;
|
|
834
|
+
tab.postPlanHeader = headerText || tab.postPlanHeader || 'PLAN COMPLETE';
|
|
835
|
+
ensurePlanFile(tab);
|
|
642
836
|
saveTabs();
|
|
643
837
|
renderPostPlanCard(container, {
|
|
838
|
+
headerText: tab.postPlanHeader,
|
|
644
839
|
onContinue: () => {
|
|
645
840
|
tab.showPostPlanActions = false;
|
|
646
841
|
saveTabs();
|
|
@@ -656,9 +851,32 @@ function showPostPlanUI(tab) {
|
|
|
656
851
|
onEditPlan: () => {
|
|
657
852
|
if (typeof _onEditPlan === 'function') _onEditPlan(tab);
|
|
658
853
|
},
|
|
854
|
+
onContinuePlanning: () => {
|
|
855
|
+
tab.showPostPlanActions = false;
|
|
856
|
+
saveTabs();
|
|
857
|
+
update();
|
|
858
|
+
},
|
|
659
859
|
});
|
|
660
860
|
}
|
|
661
861
|
|
|
862
|
+
export function applySavedPlan(tab, content, filePath = '') {
|
|
863
|
+
if (!tab || !content) return;
|
|
864
|
+
tab.planContent = content;
|
|
865
|
+
tab.editedPlanContent = content;
|
|
866
|
+
tab.showPostPlanActions = true;
|
|
867
|
+
tab.postPlanHeader = 'PLAN UPDATED';
|
|
868
|
+
if (filePath) tab.planFilePath = filePath;
|
|
869
|
+
|
|
870
|
+
const container = panelEl('#ocp-messages');
|
|
871
|
+
if (container && tab === activeTab()) {
|
|
872
|
+
removePostPlanCards(container);
|
|
873
|
+
renderAssistantPayload(container, content, { textMode: 'render' });
|
|
874
|
+
showPostPlanUI(tab, 'PLAN UPDATED');
|
|
875
|
+
} else {
|
|
876
|
+
saveTabs();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
662
880
|
// ── Send message ──
|
|
663
881
|
|
|
664
882
|
export async function sendMessage(content, options = {}) {
|
|
@@ -666,6 +884,11 @@ export async function sendMessage(content, options = {}) {
|
|
|
666
884
|
const text = String(content || '').trim();
|
|
667
885
|
const images = Array.isArray(options.images) ? options.images.filter(Boolean) : [];
|
|
668
886
|
if (!tab || tab.running || (!text && !images.length)) return false;
|
|
887
|
+
if (tab.showPostPlanActions) {
|
|
888
|
+
const container = panelEl('#ocp-messages');
|
|
889
|
+
if (container) renderErrorMessage(container, 'Choose Continue with implementation, Continue planning, Compact context, or Edit plan before sending another message.');
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
669
892
|
reconcileTabMode(tab);
|
|
670
893
|
// Clear any pending question queue when user sends a new message
|
|
671
894
|
clearQuestionQueue();
|
|
@@ -683,6 +906,7 @@ export async function sendMessage(content, options = {}) {
|
|
|
683
906
|
update();
|
|
684
907
|
|
|
685
908
|
const container = panelEl('#ocp-messages');
|
|
909
|
+
beginTurn(tab, container);
|
|
686
910
|
if (container) {
|
|
687
911
|
const userParts = [];
|
|
688
912
|
if (text) userParts.push({ type: 'text', text });
|
|
@@ -713,6 +937,7 @@ export async function sendMessage(content, options = {}) {
|
|
|
713
937
|
}
|
|
714
938
|
tab.running = false;
|
|
715
939
|
tab.turnStartedAt = 0;
|
|
940
|
+
endTurn(tab);
|
|
716
941
|
setTurnStatus(tab);
|
|
717
942
|
update();
|
|
718
943
|
return false;
|
|
@@ -750,21 +975,26 @@ export async function sendMessage(content, options = {}) {
|
|
|
750
975
|
if (tab.running && resp?.data) {
|
|
751
976
|
const info = resp.data.info || resp.data;
|
|
752
977
|
const parts = resp.data.parts || info.parts || info.content;
|
|
753
|
-
|
|
754
|
-
const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
978
|
+
const assistantMid = info.id || resp.data.messageID || resp.data.messageId || '';
|
|
979
|
+
const hasCurrentTurnContent = currentTurnHasAssistantContent(tab, container, assistantMid);
|
|
980
|
+
if (container) {
|
|
981
|
+
if (parts && !hasCurrentTurnContent) {
|
|
982
|
+
removeThinking(container);
|
|
983
|
+
renderAssistantPayload(container, parts, { textMode: 'stream' });
|
|
984
|
+
if (payloadHasAssistantText(parts)) markCurrentTurnAssistantContent(tab, container, assistantMid);
|
|
985
|
+
finalizeStreamingMessage(container);
|
|
986
|
+
} else {
|
|
987
|
+
removeThinking(container);
|
|
988
|
+
finalizeStreamingMessage(container);
|
|
989
|
+
}
|
|
990
|
+
} else if (parts) {
|
|
991
|
+
tab._needsHistoryRefresh = true;
|
|
763
992
|
}
|
|
764
993
|
// Mark this assistant message as rendered so late terminal SSE
|
|
765
994
|
// message.updated events (which arrive after POST resolves on some
|
|
766
995
|
// Windows/model combos) don't re-stream the full payload into a new bubble.
|
|
767
|
-
if (
|
|
996
|
+
if (assistantMid) markAssistantRendered(tab, assistantMid);
|
|
997
|
+
partTypes(tab).clear();
|
|
768
998
|
tab.running = false;
|
|
769
999
|
tab.turnStartedAt = 0;
|
|
770
1000
|
if (info.tokens || info.usage) {
|
|
@@ -774,7 +1004,12 @@ export async function sendMessage(content, options = {}) {
|
|
|
774
1004
|
tab.outputTokens = raw.output || raw.outputTokens || raw.output_tokens || 0;
|
|
775
1005
|
}
|
|
776
1006
|
setTurnStatus(tab);
|
|
1007
|
+
if (tab.mode === 'plan' && !tab._exitPlanDetected) {
|
|
1008
|
+
capturePlanContent(tab);
|
|
1009
|
+
showPostPlanUI(tab);
|
|
1010
|
+
}
|
|
777
1011
|
notifyOpenCodeOutcome(NOTIF_TYPE.DONE, tab, info.id || tab.sessionId || tab.id);
|
|
1012
|
+
endTurn(tab);
|
|
778
1013
|
update();
|
|
779
1014
|
}
|
|
780
1015
|
return true;
|
|
@@ -790,8 +1025,10 @@ export async function sendMessage(content, options = {}) {
|
|
|
790
1025
|
if (!isAbort) {
|
|
791
1026
|
tab.running = false;
|
|
792
1027
|
tab.turnStartedAt = 0;
|
|
1028
|
+
endTurn(tab);
|
|
793
1029
|
setTurnStatus(tab);
|
|
794
1030
|
notifyOpenCodeOutcome(NOTIF_TYPE.ERROR, tab, tab.sessionId || tab.id);
|
|
1031
|
+
_activeChildSessionId = null;
|
|
795
1032
|
update();
|
|
796
1033
|
}
|
|
797
1034
|
return false;
|
|
@@ -803,7 +1040,33 @@ export function anyTabRunning() {
|
|
|
803
1040
|
}
|
|
804
1041
|
|
|
805
1042
|
export async function abortMessage() {
|
|
806
|
-
|
|
1043
|
+
const tab = activeTab();
|
|
1044
|
+
if (!tab?.running) return false;
|
|
1045
|
+
const container = panelEl('#ocp-messages');
|
|
1046
|
+
const stopped = abortTab(tab, container);
|
|
1047
|
+
if (stopped) update();
|
|
1048
|
+
return stopped;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function abortTab(tab, container = null) {
|
|
1052
|
+
if (!tab?.running) return false;
|
|
1053
|
+
if (tab.sessionId) {
|
|
1054
|
+
try { sendWs({ type: 'message:abort', sessionId: tab.sessionId }); } catch {}
|
|
1055
|
+
}
|
|
1056
|
+
if (tab._activeSendRequestId) {
|
|
1057
|
+
rejectPending(tab._activeSendRequestId, 'Aborted by user');
|
|
1058
|
+
if (_activeSendRequestId === tab._activeSendRequestId) _activeSendRequestId = null;
|
|
1059
|
+
tab._activeSendRequestId = null;
|
|
1060
|
+
}
|
|
1061
|
+
if (container) {
|
|
1062
|
+
removeThinking(container);
|
|
1063
|
+
finalizeStreamingMessage(container);
|
|
1064
|
+
}
|
|
1065
|
+
tab.running = false;
|
|
1066
|
+
tab.turnStartedAt = 0;
|
|
1067
|
+
endTurn(tab);
|
|
1068
|
+
setTurnStatus(tab);
|
|
1069
|
+
return true;
|
|
807
1070
|
}
|
|
808
1071
|
|
|
809
1072
|
export async function abortAllTabs() {
|
|
@@ -811,24 +1074,10 @@ export async function abortAllTabs() {
|
|
|
811
1074
|
const activeContainer = panelEl('#ocp-messages');
|
|
812
1075
|
const active = activeTab();
|
|
813
1076
|
for (const tab of _tabs) {
|
|
814
|
-
|
|
815
|
-
stoppedAny = true;
|
|
816
|
-
if (tab.sessionId) {
|
|
817
|
-
try { sendWs({ type: 'message:abort', sessionId: tab.sessionId }); } catch {}
|
|
818
|
-
}
|
|
819
|
-
if (tab._activeSendRequestId) {
|
|
820
|
-
rejectPending(tab._activeSendRequestId, 'Aborted by user');
|
|
821
|
-
tab._activeSendRequestId = null;
|
|
822
|
-
}
|
|
823
|
-
if (tab === active && activeContainer) {
|
|
824
|
-
removeThinking(activeContainer);
|
|
825
|
-
finalizeStreamingMessage(activeContainer);
|
|
826
|
-
}
|
|
827
|
-
tab.running = false;
|
|
828
|
-
tab.turnStartedAt = 0;
|
|
829
|
-
setTurnStatus(tab);
|
|
1077
|
+
stoppedAny = abortTab(tab, tab === active ? activeContainer : null) || stoppedAny;
|
|
830
1078
|
}
|
|
831
1079
|
_activeSendRequestId = null;
|
|
1080
|
+
_activeChildSessionId = null;
|
|
832
1081
|
if (stoppedAny) update();
|
|
833
1082
|
return stoppedAny;
|
|
834
1083
|
}
|
|
@@ -910,6 +1159,11 @@ export function findAgentEntryByChildSession(tab, sessionId) {
|
|
|
910
1159
|
return findActivityEntry(tab, key);
|
|
911
1160
|
}
|
|
912
1161
|
|
|
1162
|
+
export function findAgentEntryByToolId(tab, toolId) {
|
|
1163
|
+
if (!tab || !toolId || !Array.isArray(tab.toolActivity)) return null;
|
|
1164
|
+
return tab.toolActivity.find(e => e.toolId === String(toolId) && e.status === 'running') || null;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
913
1167
|
export function recordToolStart(tab, rawName, toolId, toolInput) {
|
|
914
1168
|
if (!tab) return;
|
|
915
1169
|
const name = String(rawName || 'tool');
|
|
@@ -979,6 +1233,7 @@ export function recordToolResult(tab, rawName, toolId, toolInput, result, isErro
|
|
|
979
1233
|
step.updatedAt = now;
|
|
980
1234
|
}
|
|
981
1235
|
}
|
|
1236
|
+
_activeChildSessionId = null;
|
|
982
1237
|
return;
|
|
983
1238
|
}
|
|
984
1239
|
const entry = {
|
|
@@ -1001,6 +1256,7 @@ export function recordToolResult(tab, rawName, toolId, toolInput, result, isErro
|
|
|
1001
1256
|
};
|
|
1002
1257
|
tab.toolActivity.push(entry);
|
|
1003
1258
|
if (childSid) linkChildSession(tab, entry, childSid);
|
|
1259
|
+
if (isError) _activeChildSessionId = null;
|
|
1004
1260
|
}
|
|
1005
1261
|
|
|
1006
1262
|
export function recordAgentChildEvent(tab, entry, eventType, event) {
|
|
@@ -1080,6 +1336,87 @@ export function recordAgentChildEvent(tab, entry, eventType, event) {
|
|
|
1080
1336
|
return false;
|
|
1081
1337
|
}
|
|
1082
1338
|
|
|
1339
|
+
function toolStatusDetail(rawName, toolInput) {
|
|
1340
|
+
const name = String(rawName || 'tool');
|
|
1341
|
+
const desc = describeTool(name, toolInput) || {};
|
|
1342
|
+
const display = desc.displayName || name;
|
|
1343
|
+
const summary = desc.summary || '';
|
|
1344
|
+
return summary && summary !== display ? `${display} · ${summary}` : display;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function activeAgentStep(tab) {
|
|
1348
|
+
const entries = Array.isArray(tab?.toolActivity) ? tab.toolActivity : [];
|
|
1349
|
+
let latest = null;
|
|
1350
|
+
for (const entry of entries) {
|
|
1351
|
+
if (entry?.status !== 'running') continue;
|
|
1352
|
+
const steps = Array.isArray(entry.steps) ? entry.steps : [];
|
|
1353
|
+
const activeKey = entry.activeStep || '';
|
|
1354
|
+
const active = activeKey ? steps.find((step) => step.key === activeKey && step.status === 'running') : null;
|
|
1355
|
+
const fallback = active || [...steps].reverse().find((step) => step.status === 'running');
|
|
1356
|
+
if (!fallback) continue;
|
|
1357
|
+
const latestAt = latest ? (latest.updatedAt || latest.startedAt || 0) : 0;
|
|
1358
|
+
const fallbackAt = fallback.updatedAt || fallback.startedAt || 0;
|
|
1359
|
+
if (!latest || fallbackAt >= latestAt) latest = fallback;
|
|
1360
|
+
}
|
|
1361
|
+
return latest;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
function agentStepStatusDetail(step) {
|
|
1365
|
+
if (!step) return '';
|
|
1366
|
+
const name = step.name || step.rawName || 'tool';
|
|
1367
|
+
const summary = step.summary || '';
|
|
1368
|
+
return summary && summary !== name ? `${name} · ${summary}` : name;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function syncActingIndicator(container, tab, options = {}) {
|
|
1372
|
+
if (!container || !tab?.running) return;
|
|
1373
|
+
if (hasActiveQuestion() || _activePermissionId) return;
|
|
1374
|
+
const step = options.ignoreActiveStep ? null : activeAgentStep(tab);
|
|
1375
|
+
const title = options.title || (step ? 'Using tool…' : (tab.statusText || 'Thinking…'));
|
|
1376
|
+
const detail = options.detail !== undefined
|
|
1377
|
+
? options.detail
|
|
1378
|
+
: (step ? agentStepStatusDetail(step) : (tab.statusDetail || tabStatusDetail(tab)));
|
|
1379
|
+
updateThinking(container, {
|
|
1380
|
+
title,
|
|
1381
|
+
detail,
|
|
1382
|
+
startedAt: tab.turnStartedAt || Date.now(),
|
|
1383
|
+
waiting: options.waiting !== undefined ? !!options.waiting : title !== 'Using tool…',
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
function syncAgentChildIndicator(container, tab, eventType, event) {
|
|
1388
|
+
if (!tab?.running) return;
|
|
1389
|
+
if (eventType === 'tool.result') {
|
|
1390
|
+
setTurnStatus(tab, 'Thinking…', 'Processing tool result');
|
|
1391
|
+
syncActingIndicator(container, tab, {
|
|
1392
|
+
title: 'Thinking…',
|
|
1393
|
+
detail: 'Processing tool result',
|
|
1394
|
+
waiting: true,
|
|
1395
|
+
ignoreActiveStep: true,
|
|
1396
|
+
});
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
if (eventType === 'session.idle'
|
|
1400
|
+
|| (eventType === 'session.status' && (event?.status?.type || event?.type) === 'idle')) {
|
|
1401
|
+
setTurnStatus(tab, 'Thinking…', 'Agent completed');
|
|
1402
|
+
syncActingIndicator(container, tab, {
|
|
1403
|
+
title: 'Thinking…',
|
|
1404
|
+
detail: 'Agent completed',
|
|
1405
|
+
waiting: true,
|
|
1406
|
+
ignoreActiveStep: true,
|
|
1407
|
+
});
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
const step = activeAgentStep(tab);
|
|
1411
|
+
if (!step) return;
|
|
1412
|
+
const detail = agentStepStatusDetail(step);
|
|
1413
|
+
setTurnStatus(tab, 'Using tool…', detail);
|
|
1414
|
+
syncActingIndicator(container, tab, {
|
|
1415
|
+
title: 'Using tool…',
|
|
1416
|
+
detail,
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1083
1420
|
export function markStuckActivityAsAborted(tab) {
|
|
1084
1421
|
if (!tab || !Array.isArray(tab.toolActivity)) return;
|
|
1085
1422
|
const now = Date.now();
|
|
@@ -1097,6 +1434,7 @@ export function markStuckActivityAsAborted(tab) {
|
|
|
1097
1434
|
}
|
|
1098
1435
|
}
|
|
1099
1436
|
}
|
|
1437
|
+
_activeChildSessionId = null;
|
|
1100
1438
|
}
|
|
1101
1439
|
|
|
1102
1440
|
export function setActivityVisible(tab, visible) {
|
|
@@ -1132,32 +1470,118 @@ export function abortAgent(tab, key) {
|
|
|
1132
1470
|
step.updatedAt = now;
|
|
1133
1471
|
}
|
|
1134
1472
|
}
|
|
1473
|
+
_activeChildSessionId = null;
|
|
1135
1474
|
update();
|
|
1136
1475
|
return true;
|
|
1137
1476
|
}
|
|
1138
1477
|
|
|
1139
1478
|
// ── SSE Event handling ──
|
|
1140
1479
|
|
|
1480
|
+
function isIdleEvent(eventType, event) {
|
|
1481
|
+
return eventType === 'session.idle'
|
|
1482
|
+
|| (eventType === 'session.status' && (event?.status?.type || event?.type) === 'idle');
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function handleBackgroundEvent(tab, eventType, event) {
|
|
1486
|
+
if (!tab) return;
|
|
1487
|
+
tab._needsHistoryRefresh = true;
|
|
1488
|
+
if (eventType === 'message.updated') {
|
|
1489
|
+
const info = event.info || event;
|
|
1490
|
+
const mid = info.id || event.messageID;
|
|
1491
|
+
if (info.role === 'user') {
|
|
1492
|
+
if (mid) {
|
|
1493
|
+
userMsgIds(tab).add(mid);
|
|
1494
|
+
_userMsgIds.add(mid);
|
|
1495
|
+
}
|
|
1496
|
+
} else if (info.tokens || info.usage) {
|
|
1497
|
+
const raw = info.tokens || info.usage || {};
|
|
1498
|
+
tab.threadTokenUsage = normalizeThreadTokenUsage(raw);
|
|
1499
|
+
tab.inputTokens = raw.input || raw.inputTokens || raw.input_tokens || 0;
|
|
1500
|
+
tab.outputTokens = raw.output || raw.outputTokens || raw.output_tokens || 0;
|
|
1501
|
+
if (mid) markAssistantRendered(tab, mid);
|
|
1502
|
+
}
|
|
1503
|
+
} else if (eventType === 'session.updated') {
|
|
1504
|
+
if (event.session?.title || event.title) {
|
|
1505
|
+
const sid = event.session?.id || event.sessionID || event.sessionId || tab.sessionId;
|
|
1506
|
+
updateSessionTitleState(sid, event.session?.title || event.title);
|
|
1507
|
+
renderPills();
|
|
1508
|
+
saveTabs();
|
|
1509
|
+
}
|
|
1510
|
+
} else if (isIdleEvent(eventType, event)) {
|
|
1511
|
+
tab.running = false;
|
|
1512
|
+
tab.turnStartedAt = 0;
|
|
1513
|
+
endTurn(tab);
|
|
1514
|
+
setTurnStatus(tab);
|
|
1515
|
+
markStuckActivityAsAborted(tab);
|
|
1516
|
+
if (tab.mode === 'plan' && !tab._exitPlanDetected) {
|
|
1517
|
+
tab._pendingPostPlanCheck = true;
|
|
1518
|
+
tab.showPostPlanActions = true;
|
|
1519
|
+
}
|
|
1520
|
+
notifyOpenCodeOutcome(NOTIF_TYPE.DONE, tab, tab.sessionId || tab.id);
|
|
1521
|
+
} else if (eventType === 'session.error' || eventType === 'error') {
|
|
1522
|
+
tab.running = false;
|
|
1523
|
+
tab.turnStartedAt = 0;
|
|
1524
|
+
endTurn(tab);
|
|
1525
|
+
setTurnStatus(tab, 'Error', '');
|
|
1526
|
+
notifyOpenCodeOutcome(NOTIF_TYPE.ERROR, tab, event.id || event.messageID || tab.sessionId || tab.id);
|
|
1527
|
+
}
|
|
1528
|
+
update();
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1141
1531
|
export function handleSSEEvent(eventType, event) {
|
|
1142
|
-
|
|
1532
|
+
let tab = eventSessionId(event) ? findTabBySessionId(eventSessionId(event)) : activeTab();
|
|
1533
|
+
if (!tab) tab = activeTab();
|
|
1143
1534
|
if (!tab) return;
|
|
1144
1535
|
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1536
|
+
const isActiveTab = tab === activeTab();
|
|
1537
|
+
const container = isActiveTab ? panelEl('#ocp-messages') : null;
|
|
1147
1538
|
|
|
1148
1539
|
// Filter events to current session (OpenCode uses sessionID). When the active
|
|
1149
1540
|
// tab has no sessionId yet (freshly created via "+"), drop any session-scoped
|
|
1150
1541
|
// event so the still-running previous session doesn't render into it.
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1542
|
+
const eventSid = eventSessionId(event);
|
|
1543
|
+
const isAgentEvent = (eventType === 'tool.start' || eventType === 'tool.result' || eventType === 'message.part.updated');
|
|
1544
|
+
const isAgentLifecycleEvent = eventType === 'session.idle' || eventType === 'session.status';
|
|
1545
|
+
|
|
1546
|
+
// Helper to extract toolId from event or its part field
|
|
1547
|
+
function extractToolId(evt) {
|
|
1548
|
+
return evt?.toolCallId || evt?.id ||
|
|
1549
|
+
(evt?.part ? (evt.part?.toolCallId || evt.part?.callID || evt.part?.tool_use_id || evt.part?.id) : '');
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Route agent tool events to the running agent entry by toolId — events may arrive
|
|
1553
|
+
// under the parent session ID even when they're for the child agent's tool loop.
|
|
1554
|
+
if (isAgentEvent) {
|
|
1555
|
+
const toolId = extractToolId(event);
|
|
1556
|
+
if (toolId) {
|
|
1557
|
+
const agentEntry = findAgentEntryByToolId(tab, toolId);
|
|
1558
|
+
if (agentEntry && recordAgentChildEvent(tab, agentEntry, eventType, event)) {
|
|
1559
|
+
if (container) syncAgentChildIndicator(container, tab, eventType, event);
|
|
1560
|
+
update();
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
// eventSid is falsy or matches tab.sessionId — fall through to normal switch
|
|
1565
|
+
}
|
|
1566
|
+
if (eventSid && eventSid !== tab.sessionId) {
|
|
1567
|
+
// Route tracked child-agent events, then drop all other session-scoped
|
|
1568
|
+
// events so another OpenCode session cannot mutate the active render area.
|
|
1569
|
+
if (isAgentEvent || isAgentLifecycleEvent) {
|
|
1570
|
+
const agentEntry = findAgentEntryByChildSession(tab, eventSid);
|
|
1571
|
+
if (agentEntry && recordAgentChildEvent(tab, agentEntry, eventType, event)) {
|
|
1572
|
+
if (container) syncAgentChildIndicator(container, tab, eventType, event);
|
|
1573
|
+
update();
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1157
1576
|
}
|
|
1158
1577
|
return;
|
|
1159
1578
|
}
|
|
1160
1579
|
|
|
1580
|
+
if (!container) {
|
|
1581
|
+
handleBackgroundEvent(tab, eventType, event);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1161
1585
|
switch (eventType) {
|
|
1162
1586
|
// Message updated — fires for both user and assistant messages
|
|
1163
1587
|
case 'message.updated': {
|
|
@@ -1165,7 +1589,10 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1165
1589
|
// Track user messages so their parts don't echo in the chat
|
|
1166
1590
|
if (info.role === 'user') {
|
|
1167
1591
|
const mid = info.id || event.messageID;
|
|
1168
|
-
if (mid)
|
|
1592
|
+
if (mid) {
|
|
1593
|
+
userMsgIds(tab).add(mid);
|
|
1594
|
+
_userMsgIds.add(mid);
|
|
1595
|
+
}
|
|
1169
1596
|
break;
|
|
1170
1597
|
}
|
|
1171
1598
|
// Guard: once we've rendered this assistant message's final payload, ignore
|
|
@@ -1173,7 +1600,7 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1173
1600
|
// late duplicate after finalize, which would otherwise re-stream the whole
|
|
1174
1601
|
// response into a brand-new assistant bubble).
|
|
1175
1602
|
const assistantMid = info.id || event.messageID;
|
|
1176
|
-
if (assistantMid &&
|
|
1603
|
+
if (assistantMid && hasAssistantRendered(tab, assistantMid)) {
|
|
1177
1604
|
update();
|
|
1178
1605
|
break;
|
|
1179
1606
|
}
|
|
@@ -1186,16 +1613,19 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1186
1613
|
const rendered = renderAssistantPayload(container, info.content ?? info.parts ?? info.text ?? '', {
|
|
1187
1614
|
textMode: hasStreaming ? 'skip' : 'stream',
|
|
1188
1615
|
});
|
|
1616
|
+
if (rendered && !hasStreaming && payloadHasAssistantText(info.content ?? info.parts ?? info.text ?? '')) {
|
|
1617
|
+
markCurrentTurnAssistantContent(tab, container, assistantMid);
|
|
1618
|
+
}
|
|
1189
1619
|
if (rendered || hasStreaming || (explicitTerminal && hasToolCards) || (info.tokens && hasToolCards)) {
|
|
1190
1620
|
removeThinking(container);
|
|
1191
1621
|
finalizeStreamingMessage(container);
|
|
1192
|
-
|
|
1622
|
+
partTypes(tab).clear();
|
|
1193
1623
|
// Only mark the ID as rendered at the terminal signal (tokens/usage
|
|
1194
1624
|
// arrived or status is explicitly terminal). Mid-turn message.updated
|
|
1195
1625
|
// events may fire before streaming starts and shouldn't lock out later
|
|
1196
1626
|
// part.delta / part.updated events.
|
|
1197
1627
|
if (assistantMid && (explicitTerminal || info.tokens || info.usage)) {
|
|
1198
|
-
|
|
1628
|
+
markAssistantRendered(tab, assistantMid);
|
|
1199
1629
|
}
|
|
1200
1630
|
if (info.tokens || info.usage) {
|
|
1201
1631
|
const raw = info.tokens || info.usage || {};
|
|
@@ -1208,12 +1638,12 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1208
1638
|
// running here hides the Stop button while OpenCode is still working.
|
|
1209
1639
|
// Authoritative end-of-turn: session.idle, session.status:idle, or the
|
|
1210
1640
|
// synchronous POST /message response returning.
|
|
1641
|
+
syncActingIndicator(container, tab);
|
|
1211
1642
|
} else if (tab.running) {
|
|
1212
1643
|
setTurnStatus(tab, 'Waiting for response…', tabStatusDetail(tab));
|
|
1213
|
-
|
|
1644
|
+
syncActingIndicator(container, tab, {
|
|
1214
1645
|
title: 'Waiting for response…',
|
|
1215
1646
|
detail: tabStatusDetail(tab),
|
|
1216
|
-
startedAt: tab.turnStartedAt || Date.now(),
|
|
1217
1647
|
waiting: true,
|
|
1218
1648
|
});
|
|
1219
1649
|
}
|
|
@@ -1222,17 +1652,18 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1222
1652
|
}
|
|
1223
1653
|
// Streaming text delta — the primary streaming event from OpenCode
|
|
1224
1654
|
case 'message.part.delta': {
|
|
1225
|
-
if (_userMsgIds.has(event.messageID)) break; // skip user message echoes
|
|
1655
|
+
if (userMsgIds(tab).has(event.messageID) || _userMsgIds.has(event.messageID)) break; // skip user message echoes
|
|
1226
1656
|
// Skip deltas for assistant messages already finalized (late echoes).
|
|
1227
|
-
if (event.messageID &&
|
|
1657
|
+
if (event.messageID && hasAssistantRendered(tab, event.messageID)) break;
|
|
1228
1658
|
if (event.delta) {
|
|
1229
|
-
const partType = String(event.part?.type || event.partType ||
|
|
1659
|
+
const partType = String(event.part?.type || event.partType || partTypes(tab).get(event.partID) || '').trim().toLowerCase();
|
|
1230
1660
|
const isReasoning = partType === 'reasoning' || partType === 'thinking' || partType === 'thought';
|
|
1231
1661
|
if (isReasoning) {
|
|
1232
1662
|
appendThinkChunk(container, event.delta);
|
|
1233
1663
|
setTurnStatus(tab, 'Thinking…', tabStatusDetail(tab));
|
|
1234
1664
|
} else {
|
|
1235
1665
|
appendStreamChunk(container, event.delta);
|
|
1666
|
+
markCurrentTurnAssistantContent(tab, container, event.messageID || '');
|
|
1236
1667
|
setTurnStatus(tab, 'Writing response…', tabStatusDetail(tab));
|
|
1237
1668
|
}
|
|
1238
1669
|
if (tab.running) repositionThinking(container);
|
|
@@ -1243,10 +1674,10 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1243
1674
|
// Part updated — full part state (text finalized, tool state change, etc.)
|
|
1244
1675
|
case 'message.part.updated': {
|
|
1245
1676
|
const part = event.part || event;
|
|
1246
|
-
if (_userMsgIds.has(part.messageID)) break; // skip user message echoes
|
|
1677
|
+
if (userMsgIds(tab).has(part.messageID) || _userMsgIds.has(part.messageID)) break; // skip user message echoes
|
|
1247
1678
|
const updatedType = String(part?.type || '').trim().toLowerCase();
|
|
1248
1679
|
// Track part type so message.part.delta can look it up by partID
|
|
1249
|
-
if (part.id && updatedType)
|
|
1680
|
+
if (part.id && updatedType) partTypes(tab).set(part.id, updatedType);
|
|
1250
1681
|
if (updatedType === 'reasoning' || updatedType === 'thinking' || updatedType === 'thought') {
|
|
1251
1682
|
// Reasoning part finalized — mark think block as complete (not partial)
|
|
1252
1683
|
const streamingEl = container.querySelector('.ocp-msg-assistant.streaming');
|
|
@@ -1263,8 +1694,9 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1263
1694
|
// finalized by POST response or a prior message.updated — otherwise we
|
|
1264
1695
|
// create a duplicate bubble when late text part.updated events arrive.
|
|
1265
1696
|
const partMsgId = part.messageID || part.messageId;
|
|
1266
|
-
if (!hasStreaming && !(partMsgId &&
|
|
1697
|
+
if (!hasStreaming && !(partMsgId && hasAssistantRendered(tab, partMsgId))) {
|
|
1267
1698
|
renderAssistantPayload(container, part, { textMode: 'stream' });
|
|
1699
|
+
markCurrentTurnAssistantContent(tab, container, partMsgId || '');
|
|
1268
1700
|
}
|
|
1269
1701
|
setTurnStatus(tab, 'Writing response…', tabStatusDetail(tab));
|
|
1270
1702
|
if (tab.running) {
|
|
@@ -1294,11 +1726,11 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1294
1726
|
|
|
1295
1727
|
renderAssistantPayload(container, part, { textMode: 'skip' });
|
|
1296
1728
|
recordToolStart(tab, toolName, toolId, toolInput);
|
|
1297
|
-
|
|
1298
|
-
|
|
1729
|
+
const detail = toolStatusDetail(toolName, toolInput);
|
|
1730
|
+
setTurnStatus(tab, 'Using tool…', detail);
|
|
1731
|
+
syncActingIndicator(container, tab, {
|
|
1299
1732
|
title: 'Using tool…',
|
|
1300
|
-
detail
|
|
1301
|
-
startedAt: tab.turnStartedAt || Date.now(),
|
|
1733
|
+
detail,
|
|
1302
1734
|
});
|
|
1303
1735
|
update();
|
|
1304
1736
|
}
|
|
@@ -1315,9 +1747,10 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1315
1747
|
case 'message.completed': {
|
|
1316
1748
|
removeThinking(container);
|
|
1317
1749
|
const completedMid = event.messageID || event.id;
|
|
1318
|
-
if (completedMid &&
|
|
1750
|
+
if (completedMid && hasAssistantRendered(tab, completedMid)) {
|
|
1319
1751
|
tab.running = false;
|
|
1320
1752
|
tab.turnStartedAt = 0;
|
|
1753
|
+
endTurn(tab);
|
|
1321
1754
|
setTurnStatus(tab);
|
|
1322
1755
|
update();
|
|
1323
1756
|
break;
|
|
@@ -1327,11 +1760,13 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1327
1760
|
renderAssistantPayload(container, event.content ?? event.parts ?? event.text ?? '', {
|
|
1328
1761
|
textMode: hasStreamingLegacy ? 'skip' : 'stream',
|
|
1329
1762
|
});
|
|
1763
|
+
if (!hasStreamingLegacy) markCurrentTurnAssistantContent(tab, container, completedMid || '');
|
|
1330
1764
|
finalizeStreamingMessage(container);
|
|
1331
|
-
|
|
1332
|
-
if (completedMid)
|
|
1765
|
+
partTypes(tab).clear();
|
|
1766
|
+
if (completedMid) markAssistantRendered(tab, completedMid);
|
|
1333
1767
|
tab.running = false;
|
|
1334
1768
|
tab.turnStartedAt = 0;
|
|
1769
|
+
endTurn(tab);
|
|
1335
1770
|
setTurnStatus(tab);
|
|
1336
1771
|
if (event.usage) {
|
|
1337
1772
|
const raw = event.usage || {};
|
|
@@ -1393,11 +1828,11 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1393
1828
|
capturePlanContent(tab);
|
|
1394
1829
|
showPostPlanUI(tab);
|
|
1395
1830
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1831
|
+
const detail = toolStatusDetail(toolName, toolInput);
|
|
1832
|
+
setTurnStatus(tab, 'Using tool…', detail);
|
|
1833
|
+
syncActingIndicator(container, tab, {
|
|
1398
1834
|
title: 'Using tool…',
|
|
1399
|
-
detail
|
|
1400
|
-
startedAt: tab.turnStartedAt || Date.now(),
|
|
1835
|
+
detail,
|
|
1401
1836
|
});
|
|
1402
1837
|
update();
|
|
1403
1838
|
break;
|
|
@@ -1425,11 +1860,11 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1425
1860
|
recordToolResult(tab, toolName, toolId, event.input ?? event.args, event.result ?? event.output ?? event.error, !!event.error);
|
|
1426
1861
|
if (tab.running) {
|
|
1427
1862
|
setTurnStatus(tab, 'Thinking…', 'Processing tool result');
|
|
1428
|
-
|
|
1863
|
+
syncActingIndicator(container, tab, {
|
|
1429
1864
|
title: 'Thinking…',
|
|
1430
1865
|
detail: 'Processing tool result',
|
|
1431
|
-
startedAt: tab.turnStartedAt || Date.now(),
|
|
1432
1866
|
waiting: true,
|
|
1867
|
+
ignoreActiveStep: true,
|
|
1433
1868
|
});
|
|
1434
1869
|
update();
|
|
1435
1870
|
}
|
|
@@ -1448,8 +1883,10 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1448
1883
|
case 'session.status': {
|
|
1449
1884
|
const statusType = event.status?.type || event.type || '';
|
|
1450
1885
|
if (statusType === 'idle') {
|
|
1886
|
+
finalizeStreamingMessage(container);
|
|
1451
1887
|
tab.running = false;
|
|
1452
1888
|
tab.turnStartedAt = 0;
|
|
1889
|
+
endTurn(tab);
|
|
1453
1890
|
setTurnStatus(tab);
|
|
1454
1891
|
removeThinking(container);
|
|
1455
1892
|
markStuckActivityAsAborted(tab);
|
|
@@ -1464,8 +1901,10 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1464
1901
|
break;
|
|
1465
1902
|
}
|
|
1466
1903
|
case 'session.idle': {
|
|
1904
|
+
finalizeStreamingMessage(container);
|
|
1467
1905
|
tab.running = false;
|
|
1468
1906
|
tab.turnStartedAt = 0;
|
|
1907
|
+
endTurn(tab);
|
|
1469
1908
|
setTurnStatus(tab);
|
|
1470
1909
|
removeThinking(container);
|
|
1471
1910
|
markStuckActivityAsAborted(tab);
|
|
@@ -1498,9 +1937,16 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1498
1937
|
}
|
|
1499
1938
|
break;
|
|
1500
1939
|
}
|
|
1501
|
-
case 'permission.asked':
|
|
1940
|
+
case 'permission.asked':
|
|
1941
|
+
case 'permission.updated': {
|
|
1502
1942
|
const permId = event.id || event.permissionID || '';
|
|
1503
1943
|
if (!permId) break;
|
|
1944
|
+
const status = String(event.status || event.state || '').toLowerCase();
|
|
1945
|
+
if (status === 'replied' || status === 'resolved' || status === 'approved' || status === 'rejected') {
|
|
1946
|
+
lockPermissionCard(container, permId);
|
|
1947
|
+
if (_activePermissionId === permId) _activePermissionId = null;
|
|
1948
|
+
break;
|
|
1949
|
+
}
|
|
1504
1950
|
if (_activePermissionId === permId) break;
|
|
1505
1951
|
renderOpencodePermission(tab, container, event, permId);
|
|
1506
1952
|
break;
|
|
@@ -1523,8 +1969,10 @@ export function handleSSEEvent(eventType, event) {
|
|
|
1523
1969
|
renderErrorMessage(container, msg);
|
|
1524
1970
|
tab.running = false;
|
|
1525
1971
|
tab.turnStartedAt = 0;
|
|
1972
|
+
endTurn(tab);
|
|
1526
1973
|
setTurnStatus(tab, 'Error', msg);
|
|
1527
1974
|
notifyOpenCodeOutcome(NOTIF_TYPE.ERROR, tab, event.id || event.messageID || tab.sessionId || tab.id);
|
|
1975
|
+
_activeChildSessionId = null;
|
|
1528
1976
|
update();
|
|
1529
1977
|
break;
|
|
1530
1978
|
}
|
|
@@ -1544,6 +1992,12 @@ function renderOpencodeQuestion(tab, container, event, requestID) {
|
|
|
1544
1992
|
lockQuestionCard(container, requestID);
|
|
1545
1993
|
const payload = buildQuestionReplyPayload(questions, answers);
|
|
1546
1994
|
requestWs('question:reply', { requestID, body: payload }, 15000)
|
|
1995
|
+
.then(() => {
|
|
1996
|
+
if (_activeQuestionToolId === requestID) {
|
|
1997
|
+
_activeQuestionToolId = null;
|
|
1998
|
+
processQuestionQueue(tab, container);
|
|
1999
|
+
}
|
|
2000
|
+
})
|
|
1547
2001
|
.catch((err) => console.error('[ocp-tabs] question:reply failed:', err));
|
|
1548
2002
|
},
|
|
1549
2003
|
});
|
|
@@ -1723,7 +2177,12 @@ function lockPermissionCard(container, permissionID) {
|
|
|
1723
2177
|
// Convert the question card's answer payload (string | {questionText: answerString})
|
|
1724
2178
|
// into OpenCode's expected shape: { answers: string[][] } with one string[] per question.
|
|
1725
2179
|
function buildQuestionReplyPayload(questions, answers) {
|
|
1726
|
-
const asArray = (val) =>
|
|
2180
|
+
const asArray = (val) => {
|
|
2181
|
+
if (val == null) return [];
|
|
2182
|
+
if (Array.isArray(val)) return val.map((entry) => String(entry).trim()).filter(Boolean);
|
|
2183
|
+
const text = String(val).trim();
|
|
2184
|
+
return text ? [text] : [];
|
|
2185
|
+
};
|
|
1727
2186
|
if (typeof answers === 'string') {
|
|
1728
2187
|
const list = [asArray(answers)];
|
|
1729
2188
|
while (list.length < questions.length) list.push([]);
|
|
@@ -1731,9 +2190,9 @@ function buildQuestionReplyPayload(questions, answers) {
|
|
|
1731
2190
|
}
|
|
1732
2191
|
if (answers && typeof answers === 'object') {
|
|
1733
2192
|
return {
|
|
1734
|
-
answers: questions.map((q) => {
|
|
1735
|
-
const key = q.question || q.header ||
|
|
1736
|
-
const v = answers[key] ?? answers[q.header] ?? '';
|
|
2193
|
+
answers: questions.map((q, idx) => {
|
|
2194
|
+
const key = q.id || q.question || q.header || `question_${idx}`;
|
|
2195
|
+
const v = answers[key] ?? answers[q.question] ?? answers[q.header] ?? answers[q.id] ?? answers[`question_${idx}`] ?? '';
|
|
1737
2196
|
return asArray(v);
|
|
1738
2197
|
}),
|
|
1739
2198
|
};
|
|
@@ -1783,6 +2242,14 @@ export function setTabMode(mode) {
|
|
|
1783
2242
|
if (!tab) return;
|
|
1784
2243
|
const nextMode = normalizeMode(mode) || DEFAULT_MODE;
|
|
1785
2244
|
if (nextMode === 'plan') tab._exitPlanDetected = false;
|
|
2245
|
+
// Leaving plan mode invalidates any pending post-plan card — clear the flag
|
|
2246
|
+
// so a later renderTabMessages (tab switch, WS reconnect) can't resurrect it.
|
|
2247
|
+
if (tab.mode === 'plan' && nextMode !== 'plan') {
|
|
2248
|
+
tab.showPostPlanActions = false;
|
|
2249
|
+
tab._pendingPostPlanCheck = false;
|
|
2250
|
+
const container = panelEl('#ocp-messages');
|
|
2251
|
+
if (container) removePostPlanCards(container);
|
|
2252
|
+
}
|
|
1786
2253
|
tab.mode = nextMode;
|
|
1787
2254
|
tab.agent = resolveAgentForMode(nextMode);
|
|
1788
2255
|
storage.setItem(STOR.mode, nextMode);
|
|
@@ -2044,11 +2511,130 @@ export function populateModelDropdown(ddEl) {
|
|
|
2044
2511
|
}
|
|
2045
2512
|
}
|
|
2046
2513
|
|
|
2514
|
+
// ── Session HTML snapshot helpers ──
|
|
2515
|
+
|
|
2516
|
+
function _normalizeSnapshotEntry(entry) {
|
|
2517
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
2518
|
+
const html = typeof entry.html === 'string' ? entry.html : '';
|
|
2519
|
+
if (!html) return null;
|
|
2520
|
+
return {
|
|
2521
|
+
html,
|
|
2522
|
+
updatedAt: Number(entry.updatedAt) || 0,
|
|
2523
|
+
title: typeof entry.title === 'string' ? entry.title : '',
|
|
2524
|
+
itemCount: Number(entry.itemCount) || 0,
|
|
2525
|
+
threadTokenUsage: entry.threadTokenUsage || null,
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
function _persistSessionSnapshots() {
|
|
2530
|
+
try {
|
|
2531
|
+
const source = (_sessionSnapshots && typeof _sessionSnapshots === 'object' && !Array.isArray(_sessionSnapshots))
|
|
2532
|
+
? _sessionSnapshots : {};
|
|
2533
|
+
const entries = Object.entries(source)
|
|
2534
|
+
.map(([sid, e]) => [sid, _normalizeSnapshotEntry(e)])
|
|
2535
|
+
.filter(([, e]) => !!e)
|
|
2536
|
+
.sort(([, a], [, b]) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
2537
|
+
const next = {};
|
|
2538
|
+
let totalChars = 0;
|
|
2539
|
+
for (const [sid, entry] of entries) {
|
|
2540
|
+
const size = entry.html.length;
|
|
2541
|
+
if (Object.keys(next).length >= MAX_OCP_SESSION_SNAPSHOTS) break;
|
|
2542
|
+
if (totalChars + size > MAX_OCP_SESSION_SNAPSHOT_CHARS && Object.keys(next).length) continue;
|
|
2543
|
+
totalChars += size;
|
|
2544
|
+
next[sid] = entry;
|
|
2545
|
+
}
|
|
2546
|
+
_sessionSnapshots = next;
|
|
2547
|
+
storage.setItem('synabun-ocp-session-snapshots', JSON.stringify(next));
|
|
2548
|
+
} catch {}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
export function getSessionSnapshot(sid) {
|
|
2552
|
+
if (!sid) return null;
|
|
2553
|
+
return _normalizeSnapshotEntry(_sessionSnapshots?.[sid]);
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
export function writeSessionSnapshot(tab) {
|
|
2557
|
+
const sid = tab?.sessionId || null;
|
|
2558
|
+
const container = panelEl('#ocp-messages');
|
|
2559
|
+
if (!sid || !container) return;
|
|
2560
|
+
// Only snapshot when this tab is the one currently rendered in #ocp-messages.
|
|
2561
|
+
if (tab !== activeTab()) return;
|
|
2562
|
+
const html = container.innerHTML || '';
|
|
2563
|
+
if (!html.trim()) return;
|
|
2564
|
+
// Skip empty-state placeholder
|
|
2565
|
+
const onlyChild = container.children.length === 1 ? container.firstElementChild : null;
|
|
2566
|
+
if (onlyChild?.classList?.contains('ocp-empty')) return;
|
|
2567
|
+
if (onlyChild?.classList?.contains('ocp-thinking') && container.children.length === 1) return;
|
|
2568
|
+
if (!_sessionSnapshots || typeof _sessionSnapshots !== 'object' || Array.isArray(_sessionSnapshots)) {
|
|
2569
|
+
_sessionSnapshots = {};
|
|
2570
|
+
}
|
|
2571
|
+
const itemCount = [...container.children].filter(n =>
|
|
2572
|
+
!n.classList?.contains('ocp-empty') && !n.classList?.contains('ocp-thinking')
|
|
2573
|
+
).length;
|
|
2574
|
+
_sessionSnapshots[sid] = {
|
|
2575
|
+
html,
|
|
2576
|
+
updatedAt: Date.now(),
|
|
2577
|
+
title: tab.sessionTitle || '',
|
|
2578
|
+
itemCount,
|
|
2579
|
+
threadTokenUsage: tab.threadTokenUsage || null,
|
|
2580
|
+
};
|
|
2581
|
+
_persistSessionSnapshots();
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
export function scheduleSessionSnapshotSave(tab, ms = 350) {
|
|
2585
|
+
if (!tab?.sessionId) return;
|
|
2586
|
+
if (_snapshotDebounceTimer) clearTimeout(_snapshotDebounceTimer);
|
|
2587
|
+
_snapshotDebounceTimer = setTimeout(() => {
|
|
2588
|
+
_snapshotDebounceTimer = null;
|
|
2589
|
+
writeSessionSnapshot(tab);
|
|
2590
|
+
}, ms);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
export function flushSessionSnapshotSave(tab) {
|
|
2594
|
+
if (!tab) return;
|
|
2595
|
+
if (_snapshotDebounceTimer) { clearTimeout(_snapshotDebounceTimer); _snapshotDebounceTimer = null; }
|
|
2596
|
+
writeSessionSnapshot(tab);
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
export function flushAllSessionSnapshots() {
|
|
2600
|
+
// Only the active tab is rendered into #ocp-messages, so flush just that one.
|
|
2601
|
+
flushSessionSnapshotSave(activeTab());
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
function _installSnapshotObserver(container) {
|
|
2605
|
+
if (!container || _snapshotObserverContainer === container) return;
|
|
2606
|
+
if (_snapshotObserver) try { _snapshotObserver.disconnect(); } catch {}
|
|
2607
|
+
_snapshotObserverContainer = container;
|
|
2608
|
+
_snapshotObserver = new MutationObserver(() => {
|
|
2609
|
+
if (_snapshotRestoring) return;
|
|
2610
|
+
const tab = activeTab();
|
|
2611
|
+
if (tab?.sessionId) scheduleSessionSnapshotSave(tab);
|
|
2612
|
+
});
|
|
2613
|
+
_snapshotObserver.observe(container, { childList: true, subtree: true, characterData: true });
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
function _renderStoredSession(snapshot, container) {
|
|
2617
|
+
const norm = _normalizeSnapshotEntry(snapshot);
|
|
2618
|
+
if (!norm || !container) return false;
|
|
2619
|
+
_snapshotRestoring = true;
|
|
2620
|
+
try {
|
|
2621
|
+
container.innerHTML = norm.html;
|
|
2622
|
+
// Restored DOM has no event handlers and any mid-turn UI (permission cards,
|
|
2623
|
+
// ask cards, post-plan, thinking dots) is stale — disable / strip.
|
|
2624
|
+
container.querySelectorAll('button, input, select, textarea').forEach(node => { node.disabled = true; });
|
|
2625
|
+
container.querySelectorAll('.ocp-thinking, .ocp-permission-card, .ocp-question-card.active, .ocp-post-plan-card').forEach(n => n.remove());
|
|
2626
|
+
} finally {
|
|
2627
|
+
_snapshotRestoring = false;
|
|
2628
|
+
}
|
|
2629
|
+
return true;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2047
2632
|
// ── Render current tab's messages ──
|
|
2048
2633
|
|
|
2049
2634
|
export async function renderTabMessages() {
|
|
2050
2635
|
const container = panelEl('#ocp-messages');
|
|
2051
2636
|
if (!container) return;
|
|
2637
|
+
_installSnapshotObserver(container);
|
|
2052
2638
|
const tab = activeTab();
|
|
2053
2639
|
const seq = ++_renderSeq;
|
|
2054
2640
|
|
|
@@ -2057,24 +2643,49 @@ export async function renderTabMessages() {
|
|
|
2057
2643
|
return;
|
|
2058
2644
|
}
|
|
2059
2645
|
|
|
2646
|
+
// Try local snapshot first — exact replay of the live render. Skip while
|
|
2647
|
+
// a turn is actively running so streaming state isn't masked by a stale snapshot.
|
|
2648
|
+
if (!tab.running) {
|
|
2649
|
+
const snap = getSessionSnapshot(tab.sessionId);
|
|
2650
|
+
if (snap && _renderStoredSession(snap, container)) {
|
|
2651
|
+
// Background refresh: pull latest messages and rebuild only if upstream
|
|
2652
|
+
// is materially ahead of the snapshot, otherwise the cheap restore wins.
|
|
2653
|
+
try {
|
|
2654
|
+
const messages = await loadMessages(tab.sessionId);
|
|
2655
|
+
if (seq !== _renderSeq) return;
|
|
2656
|
+
const upstreamCount = Array.isArray(messages) ? messages.length : 0;
|
|
2657
|
+
if (upstreamCount > (snap.itemCount || 0) + 1) {
|
|
2658
|
+
renderHistory(container, messages);
|
|
2659
|
+
writeSessionSnapshot(tab);
|
|
2660
|
+
}
|
|
2661
|
+
} catch {}
|
|
2662
|
+
if (tab.mode === 'plan' && (tab.showPostPlanActions || tab._pendingPostPlanCheck)) {
|
|
2663
|
+
if (!tab.planContent || tab._pendingPostPlanCheck) capturePlanContent(tab);
|
|
2664
|
+
tab._pendingPostPlanCheck = false;
|
|
2665
|
+
if (tab.planContent) showPostPlanUI(tab);
|
|
2666
|
+
}
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2060
2671
|
container.innerHTML = '<div class="ocp-thinking"><span class="ocp-thinking-dots"><span></span><span></span><span></span></span> Loading…</div>';
|
|
2061
2672
|
|
|
2062
2673
|
try {
|
|
2063
2674
|
const messages = await loadMessages(tab.sessionId);
|
|
2064
2675
|
if (seq !== _renderSeq) return; // stale render — a newer one took over
|
|
2065
2676
|
renderHistory(container, messages);
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2677
|
+
writeSessionSnapshot(tab);
|
|
2678
|
+
// Restore post-plan card only while still in plan mode — leaving plan
|
|
2679
|
+
// mode must not allow the card to resurrect on tab switch / WS reconnect.
|
|
2680
|
+
if (tab.mode === 'plan' && (tab.showPostPlanActions || tab._pendingPostPlanCheck)) {
|
|
2681
|
+
if (!tab.planContent || tab._pendingPostPlanCheck) capturePlanContent(tab);
|
|
2682
|
+
tab._pendingPostPlanCheck = false;
|
|
2683
|
+
if (tab.planContent) showPostPlanUI(tab);
|
|
2069
2684
|
}
|
|
2070
2685
|
// Re-show thinking indicator if the tab is mid-turn — history render wipes it,
|
|
2071
2686
|
// and SSE events may not fire again for a while.
|
|
2072
2687
|
if (tab.running) {
|
|
2073
|
-
|
|
2074
|
-
title: tab.statusText || 'Thinking…',
|
|
2075
|
-
detail: tab.statusDetail || '',
|
|
2076
|
-
startedAt: tab.turnStartedAt || Date.now(),
|
|
2077
|
-
});
|
|
2688
|
+
syncActingIndicator(container, tab);
|
|
2078
2689
|
}
|
|
2079
2690
|
} catch {
|
|
2080
2691
|
if (seq !== _renderSeq) return;
|