@myrialabs/clopen 0.1.2 → 0.1.4
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/CONTRIBUTING.md +40 -355
- package/README.md +46 -113
- package/backend/lib/engine/adapters/opencode/message-converter.ts +53 -2
- package/backend/lib/engine/adapters/opencode/stream.ts +89 -5
- package/backend/lib/mcp/config.ts +7 -3
- package/backend/lib/mcp/servers/helper.ts +25 -14
- package/backend/lib/mcp/servers/index.ts +7 -2
- package/backend/lib/project/status-manager.ts +221 -181
- package/frontend/lib/components/chat/ChatInterface.svelte +7 -0
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +16 -9
- 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/settings/engines/AIEnginesSettings.svelte +1 -1
- 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 +623 -505
- package/frontend/lib/components/workspace/ViewMenu.svelte +9 -25
- package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +29 -4
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
- 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
- package/scripts/pre-publish-check.sh +0 -142
- package/scripts/setup-hooks.sh +0 -134
- package/scripts/validate-branch-name.sh +0 -47
- package/scripts/validate-commit-msg.sh +0 -42
|
@@ -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'}
|
|
@@ -146,9 +146,10 @@
|
|
|
146
146
|
return;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
//
|
|
149
|
+
// Reset frontend state without killing the backend stream
|
|
150
|
+
// The old session's stream continues running in the background
|
|
150
151
|
if (appState.isLoading) {
|
|
151
|
-
chatService.
|
|
152
|
+
chatService.resetForSessionSwitch();
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
// Clear server input state and prevent stale restore on ChatInput remount
|
|
@@ -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();
|
|
@@ -1,159 +1,160 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Project Status Service
|
|
3
|
-
* Tracks active streams and user presence for projects via WebSocket
|
|
4
|
-
* Fully realtime - no cache, no polling
|
|
5
|
-
*
|
|
6
|
-
* Single event: `projects:presence-updated` contains full state
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { getOrCreateAnonymousUser, type AnonymousUser } from '$shared/utils/anonymous-user';
|
|
10
|
-
import ws from '$frontend/lib/utils/ws';
|
|
11
|
-
import { debug } from '$shared/utils/logger';
|
|
12
|
-
|
|
13
|
-
export interface ProjectStatus {
|
|
14
|
-
projectId: string;
|
|
15
|
-
hasActiveStreams: boolean;
|
|
16
|
-
activeStreamCount: number;
|
|
17
|
-
activeUsers: { userId: string; userName: string }[];
|
|
18
|
-
streams: {
|
|
19
|
-
streamId: string;
|
|
20
|
-
chatSessionId: string;
|
|
21
|
-
status: string;
|
|
22
|
-
startedAt: string;
|
|
23
|
-
messagesCount: number;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
private
|
|
33
|
-
private
|
|
34
|
-
private
|
|
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
|
-
this.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
ws.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
this.unsubscribe
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
this.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Project Status Service
|
|
3
|
+
* Tracks active streams and user presence for projects via WebSocket
|
|
4
|
+
* Fully realtime - no cache, no polling
|
|
5
|
+
*
|
|
6
|
+
* Single event: `projects:presence-updated` contains full state
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getOrCreateAnonymousUser, type AnonymousUser } from '$shared/utils/anonymous-user';
|
|
10
|
+
import ws from '$frontend/lib/utils/ws';
|
|
11
|
+
import { debug } from '$shared/utils/logger';
|
|
12
|
+
|
|
13
|
+
export interface ProjectStatus {
|
|
14
|
+
projectId: string;
|
|
15
|
+
hasActiveStreams: boolean;
|
|
16
|
+
activeStreamCount: number;
|
|
17
|
+
activeUsers: { userId: string; userName: string }[];
|
|
18
|
+
streams: {
|
|
19
|
+
streamId: string;
|
|
20
|
+
chatSessionId: string;
|
|
21
|
+
status: string;
|
|
22
|
+
startedAt: string;
|
|
23
|
+
messagesCount: number;
|
|
24
|
+
isWaitingInput?: boolean;
|
|
25
|
+
}[];
|
|
26
|
+
/** Per-chat-session user presence: { chatSessionId → users[] } */
|
|
27
|
+
chatSessionUsers?: Record<string, { userId: string; userName: string }[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class ProjectStatusService {
|
|
31
|
+
private currentUser: AnonymousUser | null = null;
|
|
32
|
+
private currentProjectId: string | null = null;
|
|
33
|
+
private initialized = false;
|
|
34
|
+
private statusUpdateCallbacks: Set<(statuses: ProjectStatus[]) => void> = new Set();
|
|
35
|
+
private unsubscribe: (() => void) | null = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Initialize the service - sets up WS listener
|
|
39
|
+
* Called automatically on first use
|
|
40
|
+
*/
|
|
41
|
+
async initialize(): Promise<void> {
|
|
42
|
+
if (typeof window === 'undefined' || this.initialized) return;
|
|
43
|
+
|
|
44
|
+
this.currentUser = await getOrCreateAnonymousUser();
|
|
45
|
+
debug.log('project', 'Initialized with user:', this.currentUser?.name);
|
|
46
|
+
|
|
47
|
+
this.unsubscribe = ws.on('projects:presence-updated', (data) => {
|
|
48
|
+
try {
|
|
49
|
+
if (data.type === 'presence-updated' && data.data) {
|
|
50
|
+
const statuses = Array.isArray(data.data) ? data.data : [data.data];
|
|
51
|
+
this.statusUpdateCallbacks.forEach(callback => {
|
|
52
|
+
try {
|
|
53
|
+
callback(statuses);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
debug.error('project', 'Error in status callback:', error);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
debug.error('project', 'Error handling presence update:', error);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.initialized = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private async ensureInitialized(): Promise<void> {
|
|
68
|
+
if (!this.initialized) {
|
|
69
|
+
await this.initialize();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Subscribe to real-time presence updates
|
|
75
|
+
* Auto-initializes WS listener if not done yet
|
|
76
|
+
*/
|
|
77
|
+
onStatusUpdate(callback: (statuses: ProjectStatus[]) => void): () => void {
|
|
78
|
+
this.statusUpdateCallbacks.add(callback);
|
|
79
|
+
this.ensureInitialized();
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
this.statusUpdateCallbacks.delete(callback);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Join a project - start presence tracking
|
|
88
|
+
*/
|
|
89
|
+
async startTracking(projectId: string): Promise<void> {
|
|
90
|
+
if (typeof window === 'undefined') return;
|
|
91
|
+
|
|
92
|
+
await this.ensureInitialized();
|
|
93
|
+
|
|
94
|
+
if (this.currentProjectId && this.currentProjectId !== projectId) {
|
|
95
|
+
await this.stopTracking();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.currentProjectId = projectId;
|
|
99
|
+
|
|
100
|
+
if (this.currentUser) {
|
|
101
|
+
ws.emit('projects:join', {
|
|
102
|
+
userName: this.currentUser.name
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
|
107
|
+
window.addEventListener('beforeunload', this.handleBeforeUnload);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Leave a project - stop presence tracking
|
|
112
|
+
*/
|
|
113
|
+
async stopTracking(): Promise<void> {
|
|
114
|
+
if (this.currentProjectId && this.currentUser) {
|
|
115
|
+
// Send projectId explicitly because ws.setProject() may have already changed context
|
|
116
|
+
ws.emit('projects:leave', {
|
|
117
|
+
projectId: this.currentProjectId
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof window !== 'undefined') {
|
|
122
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
|
123
|
+
window.removeEventListener('beforeunload', this.handleBeforeUnload);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
this.currentProjectId = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
cleanup(): void {
|
|
130
|
+
if (this.unsubscribe) {
|
|
131
|
+
this.unsubscribe();
|
|
132
|
+
this.unsubscribe = null;
|
|
133
|
+
}
|
|
134
|
+
this.statusUpdateCallbacks.clear();
|
|
135
|
+
this.initialized = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private handleVisibilityChange = () => {
|
|
139
|
+
if (!document.hidden && this.currentProjectId && this.currentUser) {
|
|
140
|
+
ws.http('projects:update-presence', {
|
|
141
|
+
userName: this.currentUser.name,
|
|
142
|
+
action: 'update'
|
|
143
|
+
}).catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
private handleBeforeUnload = () => {
|
|
148
|
+
if (this.currentProjectId && this.currentUser) {
|
|
149
|
+
ws.emit('projects:leave', {
|
|
150
|
+
projectId: this.currentProjectId
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
getCurrentUser(): AnonymousUser | null {
|
|
156
|
+
return this.currentUser;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const projectStatusService = new ProjectStatusService();
|