@myrialabs/clopen 0.2.4 → 0.2.6
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/backend/chat/stream-manager.ts +136 -10
- package/backend/database/queries/session-queries.ts +9 -0
- package/backend/engine/adapters/claude/error-handler.ts +7 -2
- package/backend/engine/adapters/claude/stream.ts +21 -3
- package/backend/engine/adapters/opencode/message-converter.ts +37 -2
- package/backend/index.ts +25 -3
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/backend/ws/user/crud.ts +6 -3
- package/frontend/components/chat/input/ChatInput.svelte +6 -1
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- package/frontend/components/chat/message/MessageBubble.svelte +22 -1
- package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- package/frontend/components/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +23 -144
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
- package/frontend/components/preview/browser/components/Container.svelte +2 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/WorkspaceLayout.svelte +3 -1
- package/frontend/components/workspace/panels/FilesPanel.svelte +77 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +72 -28
- package/frontend/services/chat/chat.service.ts +6 -1
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
- package/frontend/stores/core/files.svelte.ts +15 -1
- package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
- package/frontend/utils/file-type.ts +68 -0
- package/index.html +1 -0
- package/package.json +1 -1
- package/shared/constants/binary-extensions.ts +40 -0
- package/shared/types/messaging/tool.ts +1 -0
- package/shared/utils/file-type-detection.ts +9 -1
- package/static/manifest.json +16 -0
|
@@ -41,6 +41,7 @@ export interface StreamState {
|
|
|
41
41
|
abortController?: AbortController;
|
|
42
42
|
streamPromise?: Promise<void>;
|
|
43
43
|
sdkSessionId?: string;
|
|
44
|
+
preStreamSdkSessionId?: string | null; // latest_sdk_session_id before this stream started
|
|
44
45
|
hasCompactBoundary?: boolean;
|
|
45
46
|
eventSeq: number; // Sequence number for deduplication
|
|
46
47
|
}
|
|
@@ -132,7 +133,17 @@ class StreamManager extends EventEmitter {
|
|
|
132
133
|
const existingStream = this.activeStreams.get(existingStreamId);
|
|
133
134
|
if (existingStream && existingStream.status === 'active') {
|
|
134
135
|
if (existingStream.projectId === request.projectId) {
|
|
135
|
-
|
|
136
|
+
if ((request.engine || 'claude-code') === 'claude-code') {
|
|
137
|
+
// Claude Code: cancel existing stream to prevent message loss from race condition.
|
|
138
|
+
// Claude Code SDK only returns session_id inside yielded messages, so a cancelled
|
|
139
|
+
// stream may never have established a valid session — safe to cancel and restart.
|
|
140
|
+
debug.log('chat', `Cancelling existing active stream ${existingStreamId} before starting new one`);
|
|
141
|
+
await this.cancelStream(existingStreamId);
|
|
142
|
+
} else {
|
|
143
|
+
// Other engines (OpenCode): return existing stream ID (original behavior).
|
|
144
|
+
// OpenCode creates sessions synchronously, so the existing stream is valid.
|
|
145
|
+
return existingStreamId;
|
|
146
|
+
}
|
|
136
147
|
}
|
|
137
148
|
}
|
|
138
149
|
}
|
|
@@ -302,6 +313,9 @@ class StreamManager extends EventEmitter {
|
|
|
302
313
|
}
|
|
303
314
|
}
|
|
304
315
|
|
|
316
|
+
// Store pre-stream session ID so cancelStream() can restore it
|
|
317
|
+
streamState.preStreamSdkSessionId = resumeSessionId ?? null;
|
|
318
|
+
|
|
305
319
|
// Prepare user message
|
|
306
320
|
const userMessage = {
|
|
307
321
|
...(prompt as SDKMessage),
|
|
@@ -378,10 +392,102 @@ class StreamManager extends EventEmitter {
|
|
|
378
392
|
return;
|
|
379
393
|
}
|
|
380
394
|
|
|
395
|
+
// Detect orphaned user messages and prepend context (claude-code only).
|
|
396
|
+
// When a stream is cancelled before the SDK returns a session_id,
|
|
397
|
+
// the user's message is saved to DB but unknown to the SDK session.
|
|
398
|
+
// We prepend those orphaned messages as context so the AI has full history.
|
|
399
|
+
let enginePrompt = prompt;
|
|
400
|
+
if (engineType === 'claude-code' && chatSessionId) {
|
|
401
|
+
try {
|
|
402
|
+
const head = sessionQueries.getHead(chatSessionId);
|
|
403
|
+
if (head) {
|
|
404
|
+
const chain = messageQueries.getPathToRoot(head);
|
|
405
|
+
// Remove the current user message (last in chain, just saved)
|
|
406
|
+
const previousChain = chain.slice(0, -1);
|
|
407
|
+
|
|
408
|
+
if (previousChain.length > 0) {
|
|
409
|
+
// Find boundary: last message with session_id matching resumeSessionId
|
|
410
|
+
let boundaryIndex = -1;
|
|
411
|
+
|
|
412
|
+
if (resumeSessionId) {
|
|
413
|
+
for (let i = previousChain.length - 1; i >= 0; i--) {
|
|
414
|
+
try {
|
|
415
|
+
const sdk = JSON.parse(previousChain[i].sdk_message);
|
|
416
|
+
if (sdk.session_id === resumeSessionId) {
|
|
417
|
+
boundaryIndex = i;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
} catch { /* skip unparseable */ }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Collect orphaned user messages after boundary
|
|
425
|
+
const orphanedUserTexts: string[] = [];
|
|
426
|
+
for (let i = boundaryIndex + 1; i < previousChain.length; i++) {
|
|
427
|
+
try {
|
|
428
|
+
const sdk = JSON.parse(previousChain[i].sdk_message);
|
|
429
|
+
if (sdk.type === 'user') {
|
|
430
|
+
const content = sdk.message?.content;
|
|
431
|
+
let text = '';
|
|
432
|
+
if (typeof content === 'string') {
|
|
433
|
+
text = content;
|
|
434
|
+
} else if (Array.isArray(content)) {
|
|
435
|
+
text = content
|
|
436
|
+
.filter((block: any) => block.type === 'text')
|
|
437
|
+
.map((block: any) => block.text)
|
|
438
|
+
.join('\n');
|
|
439
|
+
}
|
|
440
|
+
if (text.trim()) {
|
|
441
|
+
orphanedUserTexts.push(text.trim());
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
} catch { /* skip unparseable */ }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Prepend context if there are orphaned messages
|
|
448
|
+
if (orphanedUserTexts.length > 0) {
|
|
449
|
+
debug.log('chat', `Prepending ${orphanedUserTexts.length} orphaned user message(s) as context`);
|
|
450
|
+
|
|
451
|
+
const contextPrefix = [
|
|
452
|
+
'[Previous unprocessed messages from the user:]',
|
|
453
|
+
...orphanedUserTexts.map((text, i) => `${i + 1}. "${text}"`),
|
|
454
|
+
'',
|
|
455
|
+
'[Current message:]'
|
|
456
|
+
].join('\n');
|
|
457
|
+
|
|
458
|
+
const originalContent = prompt.message.content;
|
|
459
|
+
let modifiedContent: typeof originalContent;
|
|
460
|
+
|
|
461
|
+
if (typeof originalContent === 'string') {
|
|
462
|
+
modifiedContent = contextPrefix + '\n' + originalContent;
|
|
463
|
+
} else if (Array.isArray(originalContent)) {
|
|
464
|
+
modifiedContent = [
|
|
465
|
+
{ type: 'text' as const, text: contextPrefix },
|
|
466
|
+
...originalContent
|
|
467
|
+
];
|
|
468
|
+
} else {
|
|
469
|
+
modifiedContent = originalContent;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
enginePrompt = {
|
|
473
|
+
...prompt,
|
|
474
|
+
message: {
|
|
475
|
+
...prompt.message,
|
|
476
|
+
content: modifiedContent
|
|
477
|
+
}
|
|
478
|
+
} as SDKUserMessage;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
debug.error('chat', 'Failed to detect orphaned messages:', error);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
381
487
|
// Stream messages through the engine adapter
|
|
382
488
|
for await (const message of engine.streamQuery({
|
|
383
489
|
projectPath: actualProjectPath,
|
|
384
|
-
prompt:
|
|
490
|
+
prompt: enginePrompt,
|
|
385
491
|
resume: resumeSessionId,
|
|
386
492
|
model: model || 'sonnet',
|
|
387
493
|
includePartialMessages: true,
|
|
@@ -1009,15 +1115,28 @@ class StreamManager extends EventEmitter {
|
|
|
1009
1115
|
}
|
|
1010
1116
|
}
|
|
1011
1117
|
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
|
|
1118
|
+
// Claude Code only: restore latest_sdk_session_id to pre-stream value.
|
|
1119
|
+
// Claude Code SDK only returns session_id inside yielded messages, so a cancelled
|
|
1120
|
+
// stream's fork session_id is not a valid resume target. OpenCode creates sessions
|
|
1121
|
+
// synchronously, so its session_id is always valid — no restoration needed.
|
|
1122
|
+
if (streamState.engine === 'claude-code' && streamState.chatSessionId && streamState.preStreamSdkSessionId !== undefined) {
|
|
1123
|
+
try {
|
|
1124
|
+
if (streamState.preStreamSdkSessionId) {
|
|
1125
|
+
sessionQueries.updateLatestSdkSessionId(streamState.chatSessionId, streamState.preStreamSdkSessionId);
|
|
1126
|
+
} else {
|
|
1127
|
+
sessionQueries.clearLatestSdkSessionId(streamState.chatSessionId);
|
|
1128
|
+
}
|
|
1129
|
+
debug.log('chat', `Restored latest_sdk_session_id to: ${streamState.preStreamSdkSessionId || 'null'}`);
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
debug.error('chat', 'Failed to restore latest_sdk_session_id:', error);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1016
1134
|
|
|
1017
|
-
// Cancel the per-project engine — this
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1135
|
+
// Cancel the per-project engine FIRST — this sends an interrupt to the
|
|
1136
|
+
// still-alive SDK subprocess, then aborts the controller. If we abort
|
|
1137
|
+
// the controller first, the subprocess dies and the SDK's subsequent
|
|
1138
|
+
// interrupt write fails with "Operation aborted" (unhandled rejection
|
|
1139
|
+
// that crashes Bun).
|
|
1021
1140
|
const projectId = streamState.projectId || 'default';
|
|
1022
1141
|
try {
|
|
1023
1142
|
const engine = getProjectEngine(projectId, streamState.engine);
|
|
@@ -1028,6 +1147,13 @@ class StreamManager extends EventEmitter {
|
|
|
1028
1147
|
debug.error('chat', 'Error cancelling engine (non-fatal):', error);
|
|
1029
1148
|
}
|
|
1030
1149
|
|
|
1150
|
+
// Abort the stream-manager's controller as a fallback.
|
|
1151
|
+
// engine.cancel() already aborts the same controller, so this is
|
|
1152
|
+
// typically a no-op but ensures cleanup if the engine wasn't active.
|
|
1153
|
+
if (!streamState.abortController?.signal.aborted) {
|
|
1154
|
+
streamState.abortController?.abort();
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1031
1157
|
this.emitStreamEvent(streamState, 'cancelled', {
|
|
1032
1158
|
processId: streamState.processId,
|
|
1033
1159
|
timestamp: streamState.completedAt.toISOString()
|
|
@@ -66,6 +66,15 @@ export const sessionQueries = {
|
|
|
66
66
|
`).run(sdkSessionId, id);
|
|
67
67
|
},
|
|
68
68
|
|
|
69
|
+
clearLatestSdkSessionId(id: string): void {
|
|
70
|
+
const db = getDatabase();
|
|
71
|
+
db.prepare(`
|
|
72
|
+
UPDATE chat_sessions
|
|
73
|
+
SET latest_sdk_session_id = NULL
|
|
74
|
+
WHERE id = ?
|
|
75
|
+
`).run(id);
|
|
76
|
+
},
|
|
77
|
+
|
|
69
78
|
updateEngineModel(id: string, engine: string, model: string): void {
|
|
70
79
|
const db = getDatabase();
|
|
71
80
|
db.prepare(`
|
|
@@ -3,8 +3,13 @@ export function handleStreamError(error: unknown): void {
|
|
|
3
3
|
throw error;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
// Abort errors are expected during cancellation - don't re-throw
|
|
7
|
-
|
|
6
|
+
// Abort errors are expected during cancellation - don't re-throw.
|
|
7
|
+
// "Operation aborted" comes from the SDK's internal write() when the
|
|
8
|
+
// subprocess is killed during handleControlRequest.
|
|
9
|
+
if (error.name === 'AbortError'
|
|
10
|
+
|| error.message.includes('aborted')
|
|
11
|
+
|| error.message.includes('abort')
|
|
12
|
+
|| error.message === 'Operation aborted') {
|
|
8
13
|
return;
|
|
9
14
|
}
|
|
10
15
|
|
|
@@ -180,7 +180,19 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
180
180
|
* Cancel active query
|
|
181
181
|
*/
|
|
182
182
|
async cancel(): Promise<void> {
|
|
183
|
-
|
|
183
|
+
// Resolve all pending AskUserQuestion promises BEFORE aborting.
|
|
184
|
+
// This lets the SDK process the denial responses while the subprocess
|
|
185
|
+
// is still alive, preventing "Operation aborted" write errors.
|
|
186
|
+
for (const [, pending] of this.pendingUserAnswers) {
|
|
187
|
+
pending.resolve({ behavior: 'deny', message: 'Cancelled' });
|
|
188
|
+
}
|
|
189
|
+
this.pendingUserAnswers.clear();
|
|
190
|
+
|
|
191
|
+
// Only interrupt if the controller hasn't been aborted yet.
|
|
192
|
+
// Interrupting after abort causes the SDK to write to a dead subprocess,
|
|
193
|
+
// resulting in "Operation aborted" unhandled rejections that crash Bun.
|
|
194
|
+
if (this.activeQuery && typeof this.activeQuery.interrupt === 'function'
|
|
195
|
+
&& this.activeController && !this.activeController.signal.aborted) {
|
|
184
196
|
try {
|
|
185
197
|
await this.activeQuery.interrupt();
|
|
186
198
|
} catch {
|
|
@@ -188,13 +200,19 @@ export class ClaudeCodeEngine implements AIEngine {
|
|
|
188
200
|
}
|
|
189
201
|
}
|
|
190
202
|
|
|
203
|
+
// Brief delay between interrupt and abort to let the SDK flush pending
|
|
204
|
+
// write operations (e.g., handleControlRequest responses). Without this,
|
|
205
|
+
// aborting immediately after interrupt kills the subprocess while the SDK
|
|
206
|
+
// still has in-flight writes, causing "Operation aborted" rejections.
|
|
207
|
+
if (this.activeController && !this.activeController.signal.aborted) {
|
|
208
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
209
|
+
}
|
|
210
|
+
|
|
191
211
|
if (this.activeController) {
|
|
192
212
|
this.activeController.abort();
|
|
193
213
|
this.activeController = null;
|
|
194
214
|
}
|
|
195
215
|
this.activeQuery = null;
|
|
196
|
-
// Reject all pending user answer promises (abort signal handles this, but clean up the map)
|
|
197
|
-
this.pendingUserAnswers.clear();
|
|
198
216
|
}
|
|
199
217
|
|
|
200
218
|
/**
|
|
@@ -356,6 +356,35 @@ function normalizeToolInput(claudeToolName: string, raw: OCToolInput): Normalize
|
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
// ============================================================
|
|
360
|
+
// Tool Error Detection
|
|
361
|
+
// ============================================================
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Common error prefixes in tool output content.
|
|
365
|
+
* OpenCode SDK may mark a tool as 'completed' even when the output is an error
|
|
366
|
+
* (e.g. "Error: File not found"). These patterns detect such cases.
|
|
367
|
+
*/
|
|
368
|
+
const ERROR_CONTENT_PATTERNS = [
|
|
369
|
+
/^Error:\s/i,
|
|
370
|
+
/^ENOENT:\s/i,
|
|
371
|
+
/^EPERM:\s/i,
|
|
372
|
+
/^EACCES:\s/i,
|
|
373
|
+
/^Command failed/i,
|
|
374
|
+
/^Permission denied/i,
|
|
375
|
+
];
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Determine if a tool result should be marked as is_error.
|
|
379
|
+
* Returns true when the tool part status is 'error', OR when the output
|
|
380
|
+
* content matches a known error pattern (for tools that complete with error output).
|
|
381
|
+
*/
|
|
382
|
+
function isToolError(status: string, content: string): boolean {
|
|
383
|
+
if (status === 'error') return true;
|
|
384
|
+
if (!content || status !== 'completed') return false;
|
|
385
|
+
return ERROR_CONTENT_PATTERNS.some(pattern => pattern.test(content));
|
|
386
|
+
}
|
|
387
|
+
|
|
359
388
|
// ============================================================
|
|
360
389
|
// Stop Reason Mapping
|
|
361
390
|
// ============================================================
|
|
@@ -483,16 +512,19 @@ export function convertAssistantMessages(
|
|
|
483
512
|
};
|
|
484
513
|
|
|
485
514
|
if (toolPart.state.status === 'completed') {
|
|
515
|
+
const output = toolPart.state.output || '';
|
|
486
516
|
block.$result = {
|
|
487
517
|
type: 'tool_result',
|
|
488
518
|
tool_use_id: block.id,
|
|
489
|
-
content:
|
|
519
|
+
content: output,
|
|
520
|
+
...(isToolError('completed', output) && { is_error: true }),
|
|
490
521
|
};
|
|
491
522
|
} else if (toolPart.state.status === 'error') {
|
|
492
523
|
block.$result = {
|
|
493
524
|
type: 'tool_result',
|
|
494
525
|
tool_use_id: block.id,
|
|
495
526
|
content: toolPart.state.error || 'Tool execution failed',
|
|
527
|
+
is_error: true,
|
|
496
528
|
};
|
|
497
529
|
}
|
|
498
530
|
|
|
@@ -854,6 +886,8 @@ export function convertToolResultOnly(
|
|
|
854
886
|
content = '';
|
|
855
887
|
}
|
|
856
888
|
|
|
889
|
+
const hasError = isToolError(toolPart.state.status, content);
|
|
890
|
+
|
|
857
891
|
return {
|
|
858
892
|
type: 'user',
|
|
859
893
|
uuid: crypto.randomUUID(),
|
|
@@ -864,7 +898,8 @@ export function convertToolResultOnly(
|
|
|
864
898
|
content: [{
|
|
865
899
|
type: 'tool_result',
|
|
866
900
|
tool_use_id: toolUseId,
|
|
867
|
-
content
|
|
901
|
+
content,
|
|
902
|
+
...(hasError && { is_error: true }),
|
|
868
903
|
}]
|
|
869
904
|
}
|
|
870
905
|
} as unknown as SDKMessage;
|
package/backend/index.ts
CHANGED
|
@@ -180,10 +180,32 @@ process.on('SIGTERM', gracefulShutdown);
|
|
|
180
180
|
// Safety net: prevent server crash from unhandled errors.
|
|
181
181
|
// These can occur when AI engine SDKs emit asynchronous errors that bypass
|
|
182
182
|
// the normal try/catch flow (e.g., subprocess killed during initialization).
|
|
183
|
-
|
|
184
|
-
|
|
183
|
+
//
|
|
184
|
+
// IMPORTANT: Use the Web API (globalThis.addEventListener) instead of Node's
|
|
185
|
+
// process.on('unhandledRejection') because Bun only respects event.preventDefault()
|
|
186
|
+
// from the Web API to suppress the default crash behavior.
|
|
187
|
+
globalThis.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
|
188
|
+
// preventDefault() is the ONLY way to prevent Bun from exiting on unhandled rejections.
|
|
189
|
+
// process.on('unhandledRejection') alone does NOT prevent the crash in Bun 1.3.x.
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const reason = event.reason;
|
|
194
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
195
|
+
if (message.includes('Operation aborted') || message.includes('aborted')) {
|
|
196
|
+
debug.warn('server', 'Suppressed expected SDK abort rejection:', message);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
debug.error('server', 'Unhandled promise rejection (server still running):', reason);
|
|
200
|
+
} catch {
|
|
201
|
+
console.error('Unhandled promise rejection (server still running)');
|
|
202
|
+
}
|
|
185
203
|
});
|
|
186
204
|
|
|
187
205
|
process.on('uncaughtException', (error) => {
|
|
188
|
-
|
|
206
|
+
try {
|
|
207
|
+
debug.error('server', 'Uncaught exception (server still running):', error);
|
|
208
|
+
} catch {
|
|
209
|
+
console.error('Uncaught exception (server still running)');
|
|
210
|
+
}
|
|
189
211
|
});
|
|
@@ -103,17 +103,15 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
103
103
|
if (this.videoCapture.isStreaming(sessionId)) {
|
|
104
104
|
const tab = this.getTab(sessionId);
|
|
105
105
|
if (tab) {
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
this.emit('preview:browser-navigation-streaming-ready', { sessionId });
|
|
112
|
-
}
|
|
113
|
-
} catch (error) {
|
|
114
|
-
// Silently fail - frontend will request refresh if needed
|
|
106
|
+
// Restart streaming immediately — page is already navigated
|
|
107
|
+
try {
|
|
108
|
+
const success = await this.videoCapture.handleNavigation(sessionId, tab);
|
|
109
|
+
if (success) {
|
|
110
|
+
this.emit('preview:browser-navigation-streaming-ready', { sessionId });
|
|
115
111
|
}
|
|
116
|
-
}
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// Silently fail - frontend will request refresh if needed
|
|
114
|
+
}
|
|
117
115
|
}
|
|
118
116
|
}
|
|
119
117
|
});
|
|
@@ -214,14 +212,15 @@ export class BrowserPreviewService extends EventEmitter {
|
|
|
214
212
|
preNavigationSetup
|
|
215
213
|
});
|
|
216
214
|
|
|
217
|
-
// Setup console and
|
|
218
|
-
await
|
|
219
|
-
|
|
215
|
+
// Setup console, navigation tracking, and pre-inject streaming scripts in parallel
|
|
216
|
+
await Promise.all([
|
|
217
|
+
this.consoleManager.setupConsoleLogging(tab.id, tab.page, tab),
|
|
218
|
+
this.navigationTracker.setupNavigationTracking(tab.id, tab.page, tab),
|
|
219
|
+
]);
|
|
220
220
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
// await this.dialogHandler.setupDialogHandling(tab.id, tab.page, tab);
|
|
221
|
+
// Pre-inject WebCodecs scripts so startStreaming() is fast (~50-80ms vs ~200-350ms)
|
|
222
|
+
// Fire-and-forget: failure here is non-fatal, startStreaming() will retry injection
|
|
223
|
+
this.videoCapture.preInjectScripts(tab.id, tab).catch(() => {});
|
|
225
224
|
|
|
226
225
|
return tab;
|
|
227
226
|
}
|