@myrialabs/clopen 0.2.10 → 0.2.12
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 +61 -27
- package/backend/chat/stream-manager.ts +114 -16
- package/backend/database/queries/project-queries.ts +1 -4
- package/backend/database/queries/session-queries.ts +36 -1
- package/backend/database/queries/snapshot-queries.ts +122 -0
- package/backend/database/utils/connection.ts +17 -11
- package/backend/engine/adapters/claude/stream.ts +12 -2
- package/backend/engine/adapters/opencode/stream.ts +37 -19
- package/backend/index.ts +18 -2
- package/backend/mcp/servers/browser-automation/browser.ts +2 -0
- package/backend/preview/browser/browser-mcp-control.ts +16 -0
- package/backend/preview/browser/browser-navigation-tracker.ts +31 -3
- package/backend/preview/browser/browser-preview-service.ts +0 -34
- package/backend/preview/browser/browser-video-capture.ts +13 -1
- package/backend/preview/browser/scripts/audio-stream.ts +5 -0
- package/backend/preview/browser/types.ts +7 -6
- package/backend/snapshot/blob-store.ts +52 -72
- package/backend/snapshot/snapshot-service.ts +24 -0
- package/backend/terminal/stream-manager.ts +41 -2
- package/backend/ws/chat/stream.ts +14 -7
- package/backend/ws/engine/claude/accounts.ts +6 -8
- package/backend/ws/preview/browser/interact.ts +46 -50
- package/backend/ws/preview/browser/webcodecs.ts +24 -15
- package/backend/ws/projects/crud.ts +72 -7
- package/backend/ws/sessions/crud.ts +119 -2
- package/backend/ws/system/operations.ts +14 -39
- package/frontend/components/auth/SetupPage.svelte +1 -1
- package/frontend/components/chat/input/ChatInput.svelte +14 -1
- package/frontend/components/chat/message/MessageBubble.svelte +13 -0
- package/frontend/components/common/feedback/NotificationToast.svelte +26 -11
- package/frontend/components/common/form/FolderBrowser.svelte +17 -4
- package/frontend/components/common/overlay/Dialog.svelte +17 -15
- package/frontend/components/files/FileNode.svelte +16 -73
- package/frontend/components/git/CommitForm.svelte +1 -1
- package/frontend/components/history/HistoryModal.svelte +94 -19
- package/frontend/components/history/HistoryView.svelte +29 -36
- package/frontend/components/preview/browser/components/Canvas.svelte +119 -42
- package/frontend/components/preview/browser/components/Container.svelte +18 -3
- package/frontend/components/preview/browser/components/Toolbar.svelte +23 -21
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +13 -1
- package/frontend/components/preview/browser/core/stream-handler.svelte.ts +31 -7
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +5 -4
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +1 -54
- package/frontend/components/workspace/DesktopNavigator.svelte +57 -10
- package/frontend/components/workspace/MobileNavigator.svelte +57 -10
- package/frontend/components/workspace/WorkspaceLayout.svelte +0 -8
- package/frontend/services/chat/chat.service.ts +111 -16
- package/frontend/services/notification/global-stream-monitor.ts +5 -2
- package/frontend/services/notification/push.service.ts +2 -2
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +170 -46
- package/frontend/stores/core/app.svelte.ts +10 -2
- package/frontend/stores/core/sessions.svelte.ts +4 -1
- package/package.json +2 -2
|
@@ -93,12 +93,15 @@
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// Delete project
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
let deletingProject = $state(false);
|
|
97
|
+
|
|
98
|
+
async function confirmDeleteProject(mode: 'remove' | 'full') {
|
|
99
|
+
if (!projectToDelete || deletingProject) return;
|
|
98
100
|
const deleteId = projectToDelete.id!;
|
|
101
|
+
deletingProject = true;
|
|
99
102
|
|
|
100
103
|
try {
|
|
101
|
-
await ws.http('projects:delete', { id: deleteId });
|
|
104
|
+
await ws.http('projects:delete', { id: deleteId, mode });
|
|
102
105
|
removeProject(deleteId);
|
|
103
106
|
existingProjects = existingProjects.filter(p => p.id !== deleteId);
|
|
104
107
|
showDeleteDialog = false;
|
|
@@ -111,6 +114,8 @@
|
|
|
111
114
|
message: 'Failed to delete project',
|
|
112
115
|
duration: 5000
|
|
113
116
|
});
|
|
117
|
+
} finally {
|
|
118
|
+
deletingProject = false;
|
|
114
119
|
}
|
|
115
120
|
}
|
|
116
121
|
|
|
@@ -386,13 +391,55 @@
|
|
|
386
391
|
<Dialog
|
|
387
392
|
bind:isOpen={showDeleteDialog}
|
|
388
393
|
onClose={closeDeleteDialog}
|
|
389
|
-
type="
|
|
390
|
-
title="
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
394
|
+
type="warning"
|
|
395
|
+
title="Remove Project"
|
|
396
|
+
showCancel={false}
|
|
397
|
+
>
|
|
398
|
+
{#snippet children()}
|
|
399
|
+
<div class="flex items-start space-x-4">
|
|
400
|
+
<div class="bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-700/50 rounded-xl p-3 border">
|
|
401
|
+
<Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
|
402
|
+
</div>
|
|
403
|
+
<div class="flex-1">
|
|
404
|
+
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">Remove Project</h3>
|
|
405
|
+
<p class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
|
|
406
|
+
How would you like to remove <strong>"{projectToDelete?.name}"</strong>?
|
|
407
|
+
</p>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
<div class="flex flex-col gap-2 pt-2">
|
|
411
|
+
<button
|
|
412
|
+
onclick={() => confirmDeleteProject('remove')}
|
|
413
|
+
disabled={deletingProject}
|
|
414
|
+
class="w-full flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 text-left disabled:opacity-50"
|
|
415
|
+
>
|
|
416
|
+
<Icon name="lucide:eye-off" class="w-5 h-5 text-slate-500 shrink-0" />
|
|
417
|
+
<div class="flex-1 min-w-0">
|
|
418
|
+
<p class="text-sm font-semibold text-slate-900 dark:text-slate-100">Remove from list</p>
|
|
419
|
+
<p class="text-xs text-slate-500 dark:text-slate-400">Sessions will be restored when you re-add this project.</p>
|
|
420
|
+
</div>
|
|
421
|
+
</button>
|
|
422
|
+
<button
|
|
423
|
+
onclick={() => confirmDeleteProject('full')}
|
|
424
|
+
disabled={deletingProject}
|
|
425
|
+
class="w-full flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 text-left disabled:opacity-50"
|
|
426
|
+
>
|
|
427
|
+
<Icon name="lucide:trash-2" class="w-5 h-5 text-slate-500 shrink-0" />
|
|
428
|
+
<div class="flex-1 min-w-0">
|
|
429
|
+
<p class="text-sm font-semibold text-slate-900 dark:text-slate-100">Delete with all data</p>
|
|
430
|
+
<p class="text-xs text-slate-500 dark:text-slate-400">Delete all sessions, snapshots, and related data.</p>
|
|
431
|
+
</div>
|
|
432
|
+
</button>
|
|
433
|
+
<button
|
|
434
|
+
onclick={closeDeleteDialog}
|
|
435
|
+
class="w-full py-2 text-sm font-semibold text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
|
436
|
+
>
|
|
437
|
+
Cancel
|
|
438
|
+
</button>
|
|
439
|
+
<p class="text-xs text-slate-400 dark:text-slate-500 text-center">Your project folder on disk will not be affected.</p>
|
|
440
|
+
</div>
|
|
441
|
+
{/snippet}
|
|
442
|
+
</Dialog>
|
|
396
443
|
|
|
397
444
|
<!-- Tunnel Modal -->
|
|
398
445
|
<TunnelModal bind:isOpen={showTunnelModal} onClose={() => (showTunnelModal = false)} />
|
|
@@ -87,12 +87,15 @@
|
|
|
87
87
|
showDeleteDialog = true;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
let deletingProject = $state(false);
|
|
91
|
+
|
|
92
|
+
async function confirmDeleteProject(mode: 'remove' | 'full') {
|
|
93
|
+
if (!projectToDelete || deletingProject) return;
|
|
92
94
|
const deleteId = projectToDelete.id!;
|
|
95
|
+
deletingProject = true;
|
|
93
96
|
|
|
94
97
|
try {
|
|
95
|
-
await ws.http('projects:delete', { id: deleteId });
|
|
98
|
+
await ws.http('projects:delete', { id: deleteId, mode });
|
|
96
99
|
removeProject(deleteId);
|
|
97
100
|
showDeleteDialog = false;
|
|
98
101
|
projectToDelete = null;
|
|
@@ -104,6 +107,8 @@
|
|
|
104
107
|
message: 'Failed to delete project',
|
|
105
108
|
duration: 5000
|
|
106
109
|
});
|
|
110
|
+
} finally {
|
|
111
|
+
deletingProject = false;
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
|
|
@@ -368,13 +373,55 @@
|
|
|
368
373
|
<Dialog
|
|
369
374
|
bind:isOpen={showDeleteDialog}
|
|
370
375
|
onClose={closeDeleteDialog}
|
|
371
|
-
type="
|
|
372
|
-
title="
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
376
|
+
type="warning"
|
|
377
|
+
title="Remove Project"
|
|
378
|
+
showCancel={false}
|
|
379
|
+
>
|
|
380
|
+
{#snippet children()}
|
|
381
|
+
<div class="flex items-start space-x-4">
|
|
382
|
+
<div class="bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-700/50 rounded-xl p-3 border">
|
|
383
|
+
<Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
|
|
384
|
+
</div>
|
|
385
|
+
<div class="flex-1">
|
|
386
|
+
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">Remove Project</h3>
|
|
387
|
+
<p class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
|
|
388
|
+
How would you like to remove <strong>"{projectToDelete?.name}"</strong>?
|
|
389
|
+
</p>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
<div class="flex flex-col gap-2 pt-2">
|
|
393
|
+
<button
|
|
394
|
+
onclick={() => confirmDeleteProject('remove')}
|
|
395
|
+
disabled={deletingProject}
|
|
396
|
+
class="w-full flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 text-left disabled:opacity-50"
|
|
397
|
+
>
|
|
398
|
+
<Icon name="lucide:eye-off" class="w-5 h-5 text-slate-500 shrink-0" />
|
|
399
|
+
<div class="flex-1 min-w-0">
|
|
400
|
+
<p class="text-sm font-semibold text-slate-900 dark:text-slate-100">Remove from list</p>
|
|
401
|
+
<p class="text-xs text-slate-500 dark:text-slate-400">Sessions will be restored when you re-add this project.</p>
|
|
402
|
+
</div>
|
|
403
|
+
</button>
|
|
404
|
+
<button
|
|
405
|
+
onclick={() => confirmDeleteProject('full')}
|
|
406
|
+
disabled={deletingProject}
|
|
407
|
+
class="w-full flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 text-left disabled:opacity-50"
|
|
408
|
+
>
|
|
409
|
+
<Icon name="lucide:trash-2" class="w-5 h-5 text-slate-500 shrink-0" />
|
|
410
|
+
<div class="flex-1 min-w-0">
|
|
411
|
+
<p class="text-sm font-semibold text-slate-900 dark:text-slate-100">Delete with all data</p>
|
|
412
|
+
<p class="text-xs text-slate-500 dark:text-slate-400">Delete all sessions, snapshots, and related data.</p>
|
|
413
|
+
</div>
|
|
414
|
+
</button>
|
|
415
|
+
<button
|
|
416
|
+
onclick={closeDeleteDialog}
|
|
417
|
+
class="w-full py-2 text-sm font-semibold text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
|
418
|
+
>
|
|
419
|
+
Cancel
|
|
420
|
+
</button>
|
|
421
|
+
<p class="text-xs text-slate-400 dark:text-slate-500 text-center">Your project folder on disk will not be affected.</p>
|
|
422
|
+
</div>
|
|
423
|
+
{/snippet}
|
|
424
|
+
</Dialog>
|
|
378
425
|
|
|
379
426
|
<!-- Tunnel Modal -->
|
|
380
427
|
<TunnelModal bind:isOpen={showTunnelModal} onClose={() => (showTunnelModal = false)} />
|
|
@@ -139,14 +139,6 @@
|
|
|
139
139
|
<div
|
|
140
140
|
class="h-full w-full overflow-hidden {isMobile ? 'bg-white/90 dark:bg-slate-900/98' : 'bg-slate-50 dark:bg-slate-900/70'} text-slate-900 dark:text-slate-100 font-sans"
|
|
141
141
|
>
|
|
142
|
-
<!-- Skip link for accessibility -->
|
|
143
|
-
<a
|
|
144
|
-
href="#main-content"
|
|
145
|
-
class="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-4 focus:bg-violet-600 focus:text-white"
|
|
146
|
-
>
|
|
147
|
-
Skip to main content
|
|
148
|
-
</a>
|
|
149
|
-
|
|
150
142
|
{#if isMobile}
|
|
151
143
|
<!-- Mobile Layout -->
|
|
152
144
|
<div class="flex flex-col h-full w-full" in:fade={{ duration: 200 }}>
|
|
@@ -39,6 +39,7 @@ class ChatService {
|
|
|
39
39
|
private lastEventSeq = new Map<string, number>(); // Sequence-based deduplication
|
|
40
40
|
private cancelledProcessIds = new Set<string>(); // Track ALL cancelled streams to ignore late events
|
|
41
41
|
private reconnected: boolean = false; // Whether we've reconnected to an active stream
|
|
42
|
+
private cancelSafetyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
42
43
|
|
|
43
44
|
static loadingTexts: string[] = [
|
|
44
45
|
'thinking', 'processing', 'analyzing', 'calculating', 'computing',
|
|
@@ -170,6 +171,7 @@ class ChatService {
|
|
|
170
171
|
|
|
171
172
|
this.streamCompleted = true;
|
|
172
173
|
this.reconnected = false;
|
|
174
|
+
this.clearCancelSafetyTimer();
|
|
173
175
|
this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
|
|
174
176
|
|
|
175
177
|
// Mark any tool_use blocks that never got a tool_result
|
|
@@ -196,6 +198,7 @@ class ChatService {
|
|
|
196
198
|
this.streamCompleted = true;
|
|
197
199
|
this.reconnected = false;
|
|
198
200
|
this.activeProcessId = null;
|
|
201
|
+
this.clearCancelSafetyTimer();
|
|
199
202
|
// Don't clear isCancelling here — it causes a race with presence.
|
|
200
203
|
// The chat:cancelled WS event arrives before broadcastPresence() updates,
|
|
201
204
|
// so clearing isCancelling lets the presence $effect re-enable isLoading
|
|
@@ -479,15 +482,34 @@ class ChatService {
|
|
|
479
482
|
// and global flags — cancel sets isCancelling=true to prevent presence re-enabling
|
|
480
483
|
this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: true }, chatSessionId);
|
|
481
484
|
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
this.
|
|
485
|
+
// Convert stream_events to finalized assistant messages on cancel.
|
|
486
|
+
// This preserves partial reasoning/text that was visible to the user.
|
|
487
|
+
// Empty stream_events are removed. The backend saves partial text to DB
|
|
488
|
+
// independently, so on refresh the DB version takes over.
|
|
489
|
+
this.finalizeStreamEvents();
|
|
490
|
+
|
|
491
|
+
// Safety timeout: if backend events (chat:cancelled + presence update) don't
|
|
492
|
+
// arrive within 10 seconds, force-clear isCancelling to prevent infinite loader.
|
|
493
|
+
// This catches edge cases: WS disconnect during cancel, engine.cancel() timeout,
|
|
494
|
+
// or race conditions between presence update and chat:cancelled event ordering.
|
|
495
|
+
this.clearCancelSafetyTimer();
|
|
496
|
+
this.cancelSafetyTimer = setTimeout(() => {
|
|
497
|
+
this.cancelSafetyTimer = null;
|
|
498
|
+
if (appState.isCancelling) {
|
|
499
|
+
debug.warn('chat', 'Cancel safety timeout: force-clearing isCancelling after 10s');
|
|
500
|
+
this.setProcessState({ isCancelling: false, isLoading: false }, chatSessionId);
|
|
501
|
+
}
|
|
502
|
+
}, 10000);
|
|
503
|
+
}
|
|
487
504
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
505
|
+
/**
|
|
506
|
+
* Clear the cancel safety timer (called when cancel completes normally)
|
|
507
|
+
*/
|
|
508
|
+
private clearCancelSafetyTimer(): void {
|
|
509
|
+
if (this.cancelSafetyTimer) {
|
|
510
|
+
clearTimeout(this.cancelSafetyTimer);
|
|
511
|
+
this.cancelSafetyTimer = null;
|
|
512
|
+
}
|
|
491
513
|
}
|
|
492
514
|
|
|
493
515
|
/**
|
|
@@ -559,15 +581,32 @@ class ChatService {
|
|
|
559
581
|
}
|
|
560
582
|
// If no reasoning stream_event found, fall through to push at end
|
|
561
583
|
} else {
|
|
562
|
-
//
|
|
563
|
-
//
|
|
584
|
+
// Replace text stream_event IN PLACE to preserve message position
|
|
585
|
+
// (same approach as reasoning — prevents visual displacement)
|
|
564
586
|
for (let i = sessionState.messages.length - 1; i >= 0; i--) {
|
|
565
587
|
const msg = sessionState.messages[i] as any;
|
|
566
588
|
if (msg.type === 'stream_event' && !msg.metadata?.reasoning) {
|
|
567
|
-
|
|
568
|
-
|
|
589
|
+
const messageFormatter = {
|
|
590
|
+
...sdkMessage,
|
|
591
|
+
metadata: buildMetadataFromTransport(data)
|
|
592
|
+
};
|
|
593
|
+
sessionState.messages[i] = messageFormatter;
|
|
594
|
+
|
|
595
|
+
// Detect interactive tool_use blocks in the replaced message
|
|
596
|
+
if (sdkMessage.type === 'assistant' && sdkMessage.message?.content) {
|
|
597
|
+
const content = Array.isArray(sdkMessage.message.content) ? sdkMessage.message.content : [];
|
|
598
|
+
const hasInteractiveTool = content.some(
|
|
599
|
+
(item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name)
|
|
600
|
+
);
|
|
601
|
+
if (hasInteractiveTool) {
|
|
602
|
+
this.setProcessState({ isWaitingInput: true });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return; // Replaced in-place, skip push below
|
|
569
607
|
}
|
|
570
608
|
}
|
|
609
|
+
// No stream_event found, fall through to push at end
|
|
571
610
|
}
|
|
572
611
|
}
|
|
573
612
|
|
|
@@ -701,7 +740,7 @@ class ChatService {
|
|
|
701
740
|
const msg = sessionState.messages[i] as any;
|
|
702
741
|
if (msg.type === 'stream_event' && msg.metadata?.reasoning) {
|
|
703
742
|
msg.partialText = partialText || '';
|
|
704
|
-
|
|
743
|
+
return;
|
|
705
744
|
}
|
|
706
745
|
}
|
|
707
746
|
} else {
|
|
@@ -711,17 +750,42 @@ class ChatService {
|
|
|
711
750
|
const msg = sessionState.messages[i] as any;
|
|
712
751
|
if (msg.type === 'stream_event' && !msg.metadata?.reasoning) {
|
|
713
752
|
msg.partialText = partialText || '';
|
|
714
|
-
|
|
753
|
+
return;
|
|
715
754
|
}
|
|
716
755
|
}
|
|
717
756
|
}
|
|
757
|
+
|
|
758
|
+
// Fallback: no matching stream_event found (start event was missed).
|
|
759
|
+
// Create one now so text doesn't get lost.
|
|
760
|
+
const fallbackMessage = {
|
|
761
|
+
type: 'stream_event' as const,
|
|
762
|
+
processId: data.processId,
|
|
763
|
+
partialText: partialText || '',
|
|
764
|
+
metadata: buildMetadataFromTransport({
|
|
765
|
+
timestamp: data.timestamp,
|
|
766
|
+
...(isReasoning && { reasoning: true }),
|
|
767
|
+
})
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
if (isReasoning) {
|
|
771
|
+
const textStreamIdx = (sessionState.messages as any[]).findIndex(
|
|
772
|
+
(m: any) => m.type === 'stream_event' && !m.metadata?.reasoning
|
|
773
|
+
);
|
|
774
|
+
if (textStreamIdx >= 0) {
|
|
775
|
+
(sessionState.messages as any[]).splice(textStreamIdx, 0, fallbackMessage);
|
|
776
|
+
} else {
|
|
777
|
+
(sessionState.messages as any[]).push(fallbackMessage);
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
(sessionState.messages as any[]).push(fallbackMessage);
|
|
781
|
+
}
|
|
718
782
|
}
|
|
719
783
|
// Note: 'end' event is not needed - streaming message will be replaced by final message in handleMessageEvent
|
|
720
784
|
}
|
|
721
785
|
|
|
722
786
|
/**
|
|
723
787
|
* Remove all stream_event messages from the messages array.
|
|
724
|
-
* Called on
|
|
788
|
+
* Called on new message send to prevent stale streaming
|
|
725
789
|
* placeholders from causing wrong insertion positions.
|
|
726
790
|
*/
|
|
727
791
|
private cleanupStreamEvents(): void {
|
|
@@ -732,6 +796,36 @@ class ChatService {
|
|
|
732
796
|
}
|
|
733
797
|
}
|
|
734
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Convert stream_event messages with text to finalized assistant messages.
|
|
801
|
+
* Called on cancel to preserve partial reasoning/text that was visible.
|
|
802
|
+
* Empty stream_events (no text) are removed.
|
|
803
|
+
* The backend saves these to DB independently, so on refresh the DB version takes over.
|
|
804
|
+
*/
|
|
805
|
+
private finalizeStreamEvents(): void {
|
|
806
|
+
for (let i = sessionState.messages.length - 1; i >= 0; i--) {
|
|
807
|
+
const msg = sessionState.messages[i] as any;
|
|
808
|
+
if (msg.type !== 'stream_event') continue;
|
|
809
|
+
|
|
810
|
+
if (msg.partialText) {
|
|
811
|
+
const isReasoning = msg.metadata?.reasoning === true;
|
|
812
|
+
sessionState.messages[i] = {
|
|
813
|
+
type: 'assistant',
|
|
814
|
+
message: {
|
|
815
|
+
role: 'assistant',
|
|
816
|
+
content: [{ type: 'text', text: msg.partialText }]
|
|
817
|
+
},
|
|
818
|
+
metadata: {
|
|
819
|
+
...msg.metadata,
|
|
820
|
+
...(isReasoning && { reasoning: true }),
|
|
821
|
+
}
|
|
822
|
+
} as any;
|
|
823
|
+
} else {
|
|
824
|
+
sessionState.messages.splice(i, 1);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
735
829
|
/**
|
|
736
830
|
* Detect whether any interactive tool (e.g. AskUserQuestion) is pending in the current messages.
|
|
737
831
|
* Used after browser refresh / catchup to restore the isWaitingInput state.
|
|
@@ -752,10 +846,11 @@ class ChatService {
|
|
|
752
846
|
}
|
|
753
847
|
}
|
|
754
848
|
|
|
755
|
-
// Check if any interactive tool is unanswered
|
|
849
|
+
// Check if any interactive tool is unanswered (skip interrupted/cancelled messages)
|
|
756
850
|
for (const msg of sessionState.messages) {
|
|
757
851
|
const msgAny = msg as any;
|
|
758
852
|
if (msgAny.type !== 'assistant' || !msgAny.message?.content) continue;
|
|
853
|
+
if (msgAny.metadata?.interrupted) continue;
|
|
759
854
|
const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
|
|
760
855
|
const hasPendingInteractive = content.some(
|
|
761
856
|
(item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name) && item.id && !answeredToolIds.has(item.id)
|
|
@@ -35,13 +35,16 @@ class GlobalStreamMonitor {
|
|
|
35
35
|
|
|
36
36
|
// Stream finished — notify on completion
|
|
37
37
|
ws.on('chat:stream-finished', async (data) => {
|
|
38
|
-
const { projectId, status, chatSessionId } = data;
|
|
38
|
+
const { projectId, status, chatSessionId, reason } = data;
|
|
39
39
|
|
|
40
|
-
debug.log('notification', 'GlobalStreamMonitor: Stream finished', { projectId, status });
|
|
40
|
+
debug.log('notification', 'GlobalStreamMonitor: Stream finished', { projectId, status, reason });
|
|
41
41
|
|
|
42
42
|
// Clean up notified IDs for this session (stream is done)
|
|
43
43
|
this.clearSessionNotifications(chatSessionId);
|
|
44
44
|
|
|
45
|
+
// Skip notifications when stream was cancelled due to session deletion
|
|
46
|
+
if (reason === 'session-deleted') return;
|
|
47
|
+
|
|
45
48
|
// Play sound notification
|
|
46
49
|
try {
|
|
47
50
|
await soundNotification.play();
|