@myrialabs/clopen 0.1.2 → 0.1.3
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/lib/engine/adapters/opencode/message-converter.ts +53 -2
- package/backend/lib/engine/adapters/opencode/stream.ts +89 -5
- package/backend/lib/project/status-manager.ts +221 -181
- package/frontend/lib/components/chat/message/ChatMessages.svelte +16 -4
- package/frontend/lib/components/chat/tools/AgentTool.svelte +12 -11
- package/frontend/lib/components/chat/tools/BashOutputTool.svelte +3 -3
- package/frontend/lib/components/chat/tools/BashTool.svelte +4 -4
- package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +3 -1
- package/frontend/lib/components/chat/tools/EditTool.svelte +6 -6
- package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +1 -1
- package/frontend/lib/components/chat/tools/GlobTool.svelte +12 -12
- package/frontend/lib/components/chat/tools/GrepTool.svelte +5 -5
- package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +1 -1
- package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +6 -6
- package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +2 -2
- package/frontend/lib/components/chat/tools/ReadTool.svelte +4 -4
- package/frontend/lib/components/chat/tools/TaskStopTool.svelte +1 -1
- package/frontend/lib/components/chat/tools/TaskTool.svelte +1 -1
- package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +4 -4
- package/frontend/lib/components/chat/tools/WebSearchTool.svelte +1 -1
- package/frontend/lib/components/chat/tools/WriteTool.svelte +3 -3
- package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +3 -3
- package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +2 -2
- package/frontend/lib/components/chat/tools/components/FileHeader.svelte +1 -1
- package/frontend/lib/components/chat/tools/components/InfoLine.svelte +2 -2
- package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +1 -1
- package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +5 -5
- package/frontend/lib/components/common/Button.svelte +1 -1
- package/frontend/lib/components/common/Card.svelte +3 -3
- package/frontend/lib/components/common/Input.svelte +3 -3
- package/frontend/lib/components/common/LoadingSpinner.svelte +1 -1
- package/frontend/lib/components/common/Select.svelte +6 -6
- package/frontend/lib/components/common/Textarea.svelte +3 -3
- package/frontend/lib/components/files/FileViewer.svelte +1 -1
- package/frontend/lib/components/git/ChangesSection.svelte +2 -4
- package/frontend/lib/components/preview/browser/BrowserPreview.svelte +9 -29
- package/frontend/lib/components/preview/browser/components/Container.svelte +17 -0
- package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +2 -2
- package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +0 -6
- package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +15 -15
- package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +2 -2
- package/frontend/lib/components/workspace/DesktopNavigator.svelte +380 -383
- package/frontend/lib/components/workspace/MobileNavigator.svelte +391 -395
- package/frontend/lib/components/workspace/PanelHeader.svelte +115 -4
- package/frontend/lib/components/workspace/ViewMenu.svelte +9 -25
- package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +29 -4
- package/frontend/lib/services/notification/global-stream-monitor.ts +77 -86
- package/frontend/lib/services/project/status.service.ts +160 -159
- package/frontend/lib/stores/ui/workspace.svelte.ts +326 -283
- package/package.json +1 -1
|
@@ -7,9 +7,18 @@
|
|
|
7
7
|
import { projectState } from '$frontend/lib/stores/core/projects.svelte';
|
|
8
8
|
import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
|
|
9
9
|
import { userStore } from '$frontend/lib/stores/features/user.svelte';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
workspaceState,
|
|
12
|
+
type PanelId,
|
|
13
|
+
swapPanel,
|
|
14
|
+
splitPanel,
|
|
15
|
+
closePanel,
|
|
16
|
+
canClosePanel,
|
|
17
|
+
PANEL_OPTIONS
|
|
18
|
+
} from '$frontend/lib/stores/ui/workspace.svelte';
|
|
11
19
|
import type { IconName } from '$shared/types/ui/icons';
|
|
12
20
|
import type { DeviceSize } from '$frontend/lib/constants/preview';
|
|
21
|
+
|
|
13
22
|
import { DEVICE_VIEWPORTS } from '$frontend/lib/constants/preview';
|
|
14
23
|
|
|
15
24
|
interface Props {
|
|
@@ -72,6 +81,33 @@
|
|
|
72
81
|
}
|
|
73
82
|
});
|
|
74
83
|
|
|
84
|
+
// Panel actions menu state
|
|
85
|
+
let showActionsMenu = $state(false);
|
|
86
|
+
|
|
87
|
+
function toggleActionsMenu(e: MouseEvent) {
|
|
88
|
+
e.stopPropagation();
|
|
89
|
+
showActionsMenu = !showActionsMenu;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function closeActionsMenu() {
|
|
93
|
+
showActionsMenu = false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handleSwap(newPanelId: PanelId) {
|
|
97
|
+
swapPanel(panelId, newPanelId);
|
|
98
|
+
closeActionsMenu();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handleSplit(direction: 'vertical' | 'horizontal') {
|
|
102
|
+
splitPanel(panelId, direction);
|
|
103
|
+
closeActionsMenu();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleClose() {
|
|
107
|
+
closePanel(panelId);
|
|
108
|
+
closeActionsMenu();
|
|
109
|
+
}
|
|
110
|
+
|
|
75
111
|
// Preview panel device dropdown state
|
|
76
112
|
let showDeviceDropdown = $state(false);
|
|
77
113
|
|
|
@@ -116,9 +152,83 @@
|
|
|
116
152
|
? 'h-11 pb-2 px-4 bg-white/90 dark:bg-slate-900/98 border-b border-slate-200 dark:border-slate-800'
|
|
117
153
|
: 'py-2.5 px-3.5 bg-slate-100 dark:bg-slate-800/80 border-b border-slate-200 dark:border-slate-800'}"
|
|
118
154
|
>
|
|
119
|
-
<div class="flex items-center
|
|
155
|
+
<div class="flex items-center text-sm font-medium text-slate-900 dark:text-slate-100">
|
|
156
|
+
<!-- Panel layout actions (⋮ menu) -->
|
|
157
|
+
{#if !isMobile}
|
|
158
|
+
<div class="relative -ml-0.5 mr-2 border-slate-200 dark:border-slate-700">
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
class="flex items-center justify-center w-6 h-6 bg-transparent border-none rounded-md text-slate-400 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-700 dark:hover:text-slate-200"
|
|
162
|
+
onclick={toggleActionsMenu}
|
|
163
|
+
title="Panel actions"
|
|
164
|
+
>
|
|
165
|
+
<Icon name="lucide:ellipsis-vertical" class="w-3.5 h-3.5" />
|
|
166
|
+
</button>
|
|
167
|
+
|
|
168
|
+
{#if showActionsMenu}
|
|
169
|
+
<div class="fixed inset-0 z-40" onclick={closeActionsMenu}></div>
|
|
170
|
+
<div class="absolute top-full left-0 mt-1 z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg overflow-hidden min-w-44 py-1">
|
|
171
|
+
<!-- Switch to section -->
|
|
172
|
+
<div class="px-3 py-1.5 text-3xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
|
173
|
+
Switch to
|
|
174
|
+
</div>
|
|
175
|
+
{#each PANEL_OPTIONS as option}
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10
|
|
179
|
+
{option.id === panelId ? 'text-violet-600 dark:text-violet-400 font-medium' : 'text-slate-700 dark:text-slate-300'}"
|
|
180
|
+
onclick={() => handleSwap(option.id)}
|
|
181
|
+
disabled={option.id === panelId}
|
|
182
|
+
>
|
|
183
|
+
<Icon name={option.icon} class="w-3.5 h-3.5" />
|
|
184
|
+
<span class="flex-1">{option.title}</span>
|
|
185
|
+
{#if option.id === panelId}
|
|
186
|
+
<Icon name="lucide:check" class="w-3 h-3 text-violet-600 dark:text-violet-400" />
|
|
187
|
+
{/if}
|
|
188
|
+
</button>
|
|
189
|
+
{/each}
|
|
190
|
+
|
|
191
|
+
<!-- Divider -->
|
|
192
|
+
<div class="my-1 border-t border-slate-200 dark:border-slate-700"></div>
|
|
193
|
+
|
|
194
|
+
<!-- Split actions -->
|
|
195
|
+
<button
|
|
196
|
+
type="button"
|
|
197
|
+
class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 text-slate-700 dark:text-slate-300"
|
|
198
|
+
onclick={() => handleSplit('vertical')}
|
|
199
|
+
>
|
|
200
|
+
<Icon name="lucide:columns-2" class="w-3.5 h-3.5" />
|
|
201
|
+
<span>Split Right</span>
|
|
202
|
+
</button>
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 text-slate-700 dark:text-slate-300"
|
|
206
|
+
onclick={() => handleSplit('horizontal')}
|
|
207
|
+
>
|
|
208
|
+
<Icon name="lucide:rows-2" class="w-3.5 h-3.5" />
|
|
209
|
+
<span>Split Down</span>
|
|
210
|
+
</button>
|
|
211
|
+
|
|
212
|
+
<!-- Divider -->
|
|
213
|
+
<div class="my-1 border-t border-slate-200 dark:border-slate-700"></div>
|
|
214
|
+
|
|
215
|
+
<!-- Close -->
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-red-500/10 text-red-600 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
219
|
+
onclick={handleClose}
|
|
220
|
+
disabled={!canClosePanel()}
|
|
221
|
+
title={!canClosePanel() ? 'Cannot close last panel' : 'Close this panel'}
|
|
222
|
+
>
|
|
223
|
+
<Icon name="lucide:x" class="w-3.5 h-3.5" />
|
|
224
|
+
<span>Close Panel</span>
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
{/if}
|
|
228
|
+
</div>
|
|
229
|
+
{/if}
|
|
120
230
|
<Icon name={iconName} class="w-4 h-4 text-violet-600" />
|
|
121
|
-
<span>{panel?.title ?? 'Panel'}</span>
|
|
231
|
+
<span class="ml-2.5">{panel?.title ?? 'Panel'}</span>
|
|
122
232
|
</div>
|
|
123
233
|
|
|
124
234
|
<div class="flex items-center">
|
|
@@ -443,7 +553,7 @@
|
|
|
443
553
|
<Icon name="lucide:globe" class="w-3.5 h-3.5 shrink-0" />
|
|
444
554
|
<div class="flex-1 min-w-0">
|
|
445
555
|
<div>{remote.name}</div>
|
|
446
|
-
<div class="text-
|
|
556
|
+
<div class="text-3xs text-slate-400 truncate font-mono">{remote.fetchUrl}</div>
|
|
447
557
|
</div>
|
|
448
558
|
{#if remote.name === remoteName}
|
|
449
559
|
<Icon name="lucide:check" class="w-3.5 h-3.5 text-violet-600 shrink-0" />
|
|
@@ -501,5 +611,6 @@
|
|
|
501
611
|
</button>
|
|
502
612
|
{/if}
|
|
503
613
|
</div>
|
|
614
|
+
|
|
504
615
|
</div>
|
|
505
616
|
</header>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { scale } from 'svelte/transition';
|
|
3
3
|
import { cubicOut } from 'svelte/easing';
|
|
4
4
|
import Icon from '$frontend/lib/components/common/Icon.svelte';
|
|
5
5
|
import LayoutPreview from '$frontend/lib/components/settings/appearance/LayoutPreview.svelte';
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
applyLayoutPreset,
|
|
10
10
|
type LayoutPreset
|
|
11
11
|
} from '$frontend/lib/stores/ui/workspace.svelte';
|
|
12
|
-
import { settings } from '$frontend/lib/stores/features/settings.svelte';
|
|
13
12
|
import { clickOutside } from '$frontend/lib/utils/click-outside';
|
|
14
13
|
import type { IconName } from '$shared/types/ui/icons';
|
|
15
14
|
|
|
@@ -27,36 +26,29 @@
|
|
|
27
26
|
const presetCategories = [
|
|
28
27
|
{
|
|
29
28
|
name: 'Single Panel',
|
|
30
|
-
presets: builtInPresets.slice(0,
|
|
29
|
+
presets: builtInPresets.slice(0, 1)
|
|
31
30
|
},
|
|
32
31
|
{
|
|
33
32
|
name: 'Two Panels',
|
|
34
|
-
presets: builtInPresets.slice(
|
|
33
|
+
presets: builtInPresets.slice(1, 4)
|
|
35
34
|
},
|
|
36
35
|
{
|
|
37
36
|
name: 'Three Panels',
|
|
38
|
-
presets: builtInPresets.slice(
|
|
37
|
+
presets: builtInPresets.slice(4, 8)
|
|
39
38
|
},
|
|
40
39
|
{
|
|
41
40
|
name: 'Four Panels',
|
|
42
|
-
presets: builtInPresets.slice(
|
|
41
|
+
presets: builtInPresets.slice(8, 11)
|
|
43
42
|
},
|
|
44
43
|
{
|
|
45
44
|
name: 'Five Panels',
|
|
46
|
-
presets: builtInPresets.slice(
|
|
45
|
+
presets: builtInPresets.slice(11, 12)
|
|
47
46
|
}
|
|
48
47
|
];
|
|
49
48
|
|
|
50
49
|
// Filter visible presets and categories
|
|
51
50
|
const visibleCategories = $derived(
|
|
52
|
-
presetCategories
|
|
53
|
-
.map((category) => ({
|
|
54
|
-
...category,
|
|
55
|
-
presets: category.presets.filter(
|
|
56
|
-
(preset) => settings.layoutPresetVisibility[preset.id] !== false
|
|
57
|
-
)
|
|
58
|
-
}))
|
|
59
|
-
.filter((category) => category.presets.length > 0)
|
|
51
|
+
presetCategories.filter((category) => category.presets.length > 0)
|
|
60
52
|
);
|
|
61
53
|
|
|
62
54
|
function toggleMenu() {
|
|
@@ -129,19 +121,11 @@
|
|
|
129
121
|
{#each category.presets as preset}
|
|
130
122
|
<button
|
|
131
123
|
type="button"
|
|
132
|
-
class="flex items-center p-2.5 bg-transparent border border-slate-200 dark:border-slate-800 rounded-lg text-slate-700 dark:text-slate-300 text-sm text-left cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:border-violet-500/20
|
|
133
|
-
preset.id
|
|
134
|
-
? 'bg-violet-500/5 border-violet-500/30'
|
|
135
|
-
: ''}"
|
|
124
|
+
class="flex items-center p-2.5 bg-transparent border border-slate-200 dark:border-slate-800 rounded-lg text-slate-700 dark:text-slate-300 text-sm text-left cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:border-violet-500/20"
|
|
136
125
|
onclick={() => handleApplyPreset(preset)}
|
|
137
126
|
>
|
|
138
127
|
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
|
139
|
-
<
|
|
140
|
-
<span class="font-medium text-xs">{preset.name}</span>
|
|
141
|
-
{#if workspaceState.activePresetId === preset.id}
|
|
142
|
-
<Icon name="lucide:check" class="w-3.5 h-3.5 text-violet-600 dark:text-violet-400 shrink-0" />
|
|
143
|
-
{/if}
|
|
144
|
-
</div>
|
|
128
|
+
<span class="font-medium text-xs">{preset.name}</span>
|
|
145
129
|
<!-- Visual Preview -->
|
|
146
130
|
<div class="w-full">
|
|
147
131
|
<LayoutPreview layout={preset.layout} size="small" />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type
|
|
2
|
+
import { type SplitNode, setPanelAtPath, closePanelAtPath, PANEL_OPTIONS } from '$frontend/lib/stores/ui/workspace.svelte';
|
|
3
|
+
import Icon from '$frontend/lib/components/common/Icon.svelte';
|
|
3
4
|
import PanelContainer from '../../PanelContainer.svelte';
|
|
4
5
|
import Container from './Container.svelte';
|
|
5
6
|
|
|
@@ -18,11 +19,35 @@
|
|
|
18
19
|
<PanelContainer panelId={node.panelId} />
|
|
19
20
|
</div>
|
|
20
21
|
{:else}
|
|
21
|
-
<!-- Empty slot -->
|
|
22
|
+
<!-- Empty slot: Panel picker -->
|
|
22
23
|
<div
|
|
23
|
-
class="split-pane-empty flex items-center justify-center h-full w-full bg-
|
|
24
|
+
class="split-pane-empty flex flex-col items-center justify-center gap-4 h-full w-full bg-white/90 dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden"
|
|
24
25
|
>
|
|
25
|
-
<span class="text-
|
|
26
|
+
<span class="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Choose Panel</span>
|
|
27
|
+
<div class="grid grid-cols-3 gap-2">
|
|
28
|
+
{#each PANEL_OPTIONS as option}
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
class="flex flex-col items-center justify-center gap-2 w-26 h-18 bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl text-xs font-medium text-slate-600 dark:text-slate-300 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:border-violet-400 hover:text-violet-600 dark:hover:text-violet-400"
|
|
32
|
+
onclick={() => setPanelAtPath(path, option.id)}
|
|
33
|
+
>
|
|
34
|
+
<Icon name={option.icon} class="w-5 h-5" />
|
|
35
|
+
<span>{option.title}</span>
|
|
36
|
+
</button>
|
|
37
|
+
{/each}
|
|
38
|
+
<!-- Close / Cancel button -->
|
|
39
|
+
{#if path.length > 0}
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
class="flex flex-col items-center justify-center gap-2 w-26 h-18 bg-transparent border border-dashed border-slate-300 dark:border-slate-700 rounded-xl text-xs font-medium text-slate-400 dark:text-slate-500 cursor-pointer transition-all duration-150 hover:border-red-400 hover:text-red-500 dark:hover:text-red-400"
|
|
43
|
+
onclick={() => closePanelAtPath(path)}
|
|
44
|
+
title="Remove this panel slot"
|
|
45
|
+
>
|
|
46
|
+
<Icon name="lucide:x" class="w-5 h-5" />
|
|
47
|
+
<span>Cancel</span>
|
|
48
|
+
</button>
|
|
49
|
+
{/if}
|
|
50
|
+
</div>
|
|
26
51
|
</div>
|
|
27
52
|
{/if}
|
|
28
53
|
{:else if node.type === 'split'}
|
|
@@ -1,86 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Global Stream Monitor Service
|
|
3
|
-
*
|
|
4
|
-
* Single source of truth for chat stream notifications (sound + push).
|
|
5
|
-
*
|
|
6
|
-
* Listens for chat:stream-finished events from the backend and triggers
|
|
7
|
-
* notifications for ALL projects — both active and non-active.
|
|
8
|
-
*
|
|
9
|
-
* The backend uses ws.emit.projectMembers() to send this event to all
|
|
10
|
-
* users who have been associated with the project, even if they switched
|
|
11
|
-
* to a different project.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { soundNotification, pushNotification } from '$frontend/lib/services/notification';
|
|
15
|
-
import { projectState } from '$frontend/lib/stores/core/projects.svelte';
|
|
16
|
-
import {
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* Clear state (for cleanup/testing)
|
|
79
|
-
*/
|
|
80
|
-
clear(): void {
|
|
81
|
-
debug.log('notification', 'GlobalStreamMonitor: Clearing state');
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Export singleton instance
|
|
86
|
-
export const globalStreamMonitor = new GlobalStreamMonitor();
|
|
1
|
+
/**
|
|
2
|
+
* Global Stream Monitor Service
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for chat stream notifications (sound + push).
|
|
5
|
+
*
|
|
6
|
+
* Listens for chat:stream-finished events from the backend and triggers
|
|
7
|
+
* notifications for ALL projects — both active and non-active.
|
|
8
|
+
*
|
|
9
|
+
* The backend uses ws.emit.projectMembers() to send this event to all
|
|
10
|
+
* users who have been associated with the project, even if they switched
|
|
11
|
+
* to a different project.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { soundNotification, pushNotification } from '$frontend/lib/services/notification';
|
|
15
|
+
import { projectState } from '$frontend/lib/stores/core/projects.svelte';
|
|
16
|
+
import { debug } from '$shared/utils/logger';
|
|
17
|
+
import ws from '$frontend/lib/utils/ws';
|
|
18
|
+
|
|
19
|
+
class GlobalStreamMonitor {
|
|
20
|
+
private initialized = false;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initialize the monitor - subscribes to the WS event.
|
|
24
|
+
* Safe to call multiple times (idempotent).
|
|
25
|
+
*
|
|
26
|
+
* Triggers sound/push for ALL projects the user is a member of,
|
|
27
|
+
* not just the active one — background streams deserve notifications too.
|
|
28
|
+
*/
|
|
29
|
+
initialize(): void {
|
|
30
|
+
if (this.initialized) return;
|
|
31
|
+
this.initialized = true;
|
|
32
|
+
|
|
33
|
+
debug.log('notification', 'GlobalStreamMonitor: Initializing WS listener');
|
|
34
|
+
|
|
35
|
+
ws.on('chat:stream-finished', async (data) => {
|
|
36
|
+
const { projectId, status, timestamp } = data;
|
|
37
|
+
|
|
38
|
+
debug.log('notification', 'GlobalStreamMonitor: Stream finished', {
|
|
39
|
+
projectId,
|
|
40
|
+
status,
|
|
41
|
+
timestamp
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Play sound notification
|
|
45
|
+
try {
|
|
46
|
+
await soundNotification.play();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
debug.error('notification', 'Error playing sound notification:', error);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Send push notification with project context
|
|
52
|
+
try {
|
|
53
|
+
const projectName = projectState.projects.find(p => p.id === projectId)?.name || 'Unknown';
|
|
54
|
+
|
|
55
|
+
if (status === 'completed') {
|
|
56
|
+
await pushNotification.sendChatComplete(`Chat response ready in "${projectName}"`);
|
|
57
|
+
} else if (status === 'error') {
|
|
58
|
+
await pushNotification.sendChatError(`Chat error in "${projectName}"`);
|
|
59
|
+
} else if (status === 'cancelled') {
|
|
60
|
+
await pushNotification.sendChatComplete(`Chat interrupted in "${projectName}"`);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
debug.error('notification', 'Error sending push notification:', error);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Clear state (for cleanup/testing)
|
|
70
|
+
*/
|
|
71
|
+
clear(): void {
|
|
72
|
+
debug.log('notification', 'GlobalStreamMonitor: Clearing state');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Export singleton instance
|
|
77
|
+
export const globalStreamMonitor = new GlobalStreamMonitor();
|