@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.
Files changed (63) hide show
  1. package/CONTRIBUTING.md +40 -355
  2. package/README.md +46 -113
  3. package/backend/lib/engine/adapters/opencode/message-converter.ts +53 -2
  4. package/backend/lib/engine/adapters/opencode/stream.ts +89 -5
  5. package/backend/lib/mcp/config.ts +7 -3
  6. package/backend/lib/mcp/servers/helper.ts +25 -14
  7. package/backend/lib/mcp/servers/index.ts +7 -2
  8. package/backend/lib/project/status-manager.ts +221 -181
  9. package/frontend/lib/components/chat/ChatInterface.svelte +7 -0
  10. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +16 -9
  11. package/frontend/lib/components/chat/message/ChatMessages.svelte +16 -4
  12. package/frontend/lib/components/chat/tools/AgentTool.svelte +12 -11
  13. package/frontend/lib/components/chat/tools/BashOutputTool.svelte +3 -3
  14. package/frontend/lib/components/chat/tools/BashTool.svelte +4 -4
  15. package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +3 -1
  16. package/frontend/lib/components/chat/tools/EditTool.svelte +6 -6
  17. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +1 -1
  18. package/frontend/lib/components/chat/tools/GlobTool.svelte +12 -12
  19. package/frontend/lib/components/chat/tools/GrepTool.svelte +5 -5
  20. package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +1 -1
  21. package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +6 -6
  22. package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +2 -2
  23. package/frontend/lib/components/chat/tools/ReadTool.svelte +4 -4
  24. package/frontend/lib/components/chat/tools/TaskStopTool.svelte +1 -1
  25. package/frontend/lib/components/chat/tools/TaskTool.svelte +1 -1
  26. package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +4 -4
  27. package/frontend/lib/components/chat/tools/WebSearchTool.svelte +1 -1
  28. package/frontend/lib/components/chat/tools/WriteTool.svelte +3 -3
  29. package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +3 -3
  30. package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +2 -2
  31. package/frontend/lib/components/chat/tools/components/FileHeader.svelte +1 -1
  32. package/frontend/lib/components/chat/tools/components/InfoLine.svelte +2 -2
  33. package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +1 -1
  34. package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +5 -5
  35. package/frontend/lib/components/common/Button.svelte +1 -1
  36. package/frontend/lib/components/common/Card.svelte +3 -3
  37. package/frontend/lib/components/common/Input.svelte +3 -3
  38. package/frontend/lib/components/common/LoadingSpinner.svelte +1 -1
  39. package/frontend/lib/components/common/Select.svelte +6 -6
  40. package/frontend/lib/components/common/Textarea.svelte +3 -3
  41. package/frontend/lib/components/files/FileViewer.svelte +1 -1
  42. package/frontend/lib/components/git/ChangesSection.svelte +2 -4
  43. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +9 -29
  44. package/frontend/lib/components/preview/browser/components/Container.svelte +17 -0
  45. package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +2 -2
  46. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +0 -6
  47. package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +15 -15
  48. package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +2 -2
  49. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +1 -1
  50. package/frontend/lib/components/workspace/DesktopNavigator.svelte +380 -383
  51. package/frontend/lib/components/workspace/MobileNavigator.svelte +391 -395
  52. package/frontend/lib/components/workspace/PanelHeader.svelte +623 -505
  53. package/frontend/lib/components/workspace/ViewMenu.svelte +9 -25
  54. package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +29 -4
  55. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  56. package/frontend/lib/services/notification/global-stream-monitor.ts +77 -86
  57. package/frontend/lib/services/project/status.service.ts +160 -159
  58. package/frontend/lib/stores/ui/workspace.svelte.ts +326 -283
  59. package/package.json +1 -1
  60. package/scripts/pre-publish-check.sh +0 -142
  61. package/scripts/setup-hooks.sh +0 -134
  62. package/scripts/validate-branch-name.sh +0 -47
  63. package/scripts/validate-commit-msg.sh +0 -42
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { fade, scale } from 'svelte/transition';
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, 5)
29
+ presets: builtInPresets.slice(0, 1)
31
30
  },
32
31
  {
33
32
  name: 'Two Panels',
34
- presets: builtInPresets.slice(5, 13)
33
+ presets: builtInPresets.slice(1, 4)
35
34
  },
36
35
  {
37
36
  name: 'Three Panels',
38
- presets: builtInPresets.slice(13, 22)
37
+ presets: builtInPresets.slice(4, 8)
39
38
  },
40
39
  {
41
40
  name: 'Four Panels',
42
- presets: builtInPresets.slice(22, 26)
41
+ presets: builtInPresets.slice(8, 11)
43
42
  },
44
43
  {
45
44
  name: 'Five Panels',
46
- presets: builtInPresets.slice(26, 28)
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 {workspaceState.activePresetId ===
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
- <div class="flex justify-between">
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 { SplitNode } from '$frontend/lib/stores/ui/workspace.svelte';
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-slate-100 dark:bg-slate-900/50 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg"
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-sm text-slate-400 dark:text-slate-500">Empty Panel</span>
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
- // Cancel active stream if running
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.cancelRequest();
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 { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
17
- import { debug } from '$shared/utils/logger';
18
- import ws from '$frontend/lib/utils/ws';
19
-
20
- class GlobalStreamMonitor {
21
- private initialized = false;
22
-
23
- /**
24
- * Initialize the monitor - subscribes to the WS event.
25
- * Safe to call multiple times (idempotent).
26
- *
27
- * Only triggers sound/push when the finished stream belongs to the
28
- * user's current project AND current chat session.
29
- */
30
- initialize(): void {
31
- if (this.initialized) return;
32
- this.initialized = true;
33
-
34
- debug.log('notification', 'GlobalStreamMonitor: Initializing WS listener');
35
-
36
- ws.on('chat:stream-finished', async (data) => {
37
- const { projectId, chatSessionId, status, timestamp } = data;
38
- const isActiveProject = projectState.currentProject?.id === projectId;
39
- const isActiveSession = sessionState.currentSession?.id === chatSessionId;
40
-
41
- debug.log('notification', 'GlobalStreamMonitor: Stream finished', {
42
- projectId,
43
- chatSessionId,
44
- status,
45
- timestamp,
46
- isActiveProject,
47
- isActiveSession
48
- });
49
-
50
- // Only notify if the stream is for the user's current project AND session
51
- if (!isActiveProject || !isActiveSession) return;
52
-
53
- // Play sound notification
54
- try {
55
- await soundNotification.play();
56
- } catch (error) {
57
- debug.error('notification', 'Error playing sound notification:', error);
58
- }
59
-
60
- // Send push notification with project context
61
- try {
62
- const projectName = projectState.projects.find(p => p.id === projectId)?.name || 'Unknown';
63
-
64
- if (status === 'completed') {
65
- await pushNotification.sendChatComplete(`Chat response ready in "${projectName}"`);
66
- } else if (status === 'error') {
67
- await pushNotification.sendChatError(`Chat error in "${projectName}"`);
68
- } else if (status === 'cancelled') {
69
- await pushNotification.sendChatComplete(`Chat interrupted in "${projectName}"`);
70
- }
71
- } catch (error) {
72
- debug.error('notification', 'Error sending push notification:', error);
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
- /** Per-chat-session user presence: { chatSessionId → users[] } */
26
- chatSessionUsers?: Record<string, { userId: string; userName: string }[]>;
27
- }
28
-
29
- class ProjectStatusService {
30
- private currentUser: AnonymousUser | null = null;
31
- private currentProjectId: string | null = null;
32
- private initialized = false;
33
- private statusUpdateCallbacks: Set<(statuses: ProjectStatus[]) => void> = new Set();
34
- private unsubscribe: (() => void) | null = null;
35
-
36
- /**
37
- * Initialize the service - sets up WS listener
38
- * Called automatically on first use
39
- */
40
- async initialize(): Promise<void> {
41
- if (typeof window === 'undefined' || this.initialized) return;
42
-
43
- this.currentUser = await getOrCreateAnonymousUser();
44
- debug.log('project', 'Initialized with user:', this.currentUser?.name);
45
-
46
- this.unsubscribe = ws.on('projects:presence-updated', (data) => {
47
- try {
48
- if (data.type === 'presence-updated' && data.data) {
49
- const statuses = Array.isArray(data.data) ? data.data : [data.data];
50
- this.statusUpdateCallbacks.forEach(callback => {
51
- try {
52
- callback(statuses);
53
- } catch (error) {
54
- debug.error('project', 'Error in status callback:', error);
55
- }
56
- });
57
- }
58
- } catch (error) {
59
- debug.error('project', 'Error handling presence update:', error);
60
- }
61
- });
62
-
63
- this.initialized = true;
64
- }
65
-
66
- private async ensureInitialized(): Promise<void> {
67
- if (!this.initialized) {
68
- await this.initialize();
69
- }
70
- }
71
-
72
- /**
73
- * Subscribe to real-time presence updates
74
- * Auto-initializes WS listener if not done yet
75
- */
76
- onStatusUpdate(callback: (statuses: ProjectStatus[]) => void): () => void {
77
- this.statusUpdateCallbacks.add(callback);
78
- this.ensureInitialized();
79
-
80
- return () => {
81
- this.statusUpdateCallbacks.delete(callback);
82
- };
83
- }
84
-
85
- /**
86
- * Join a project - start presence tracking
87
- */
88
- async startTracking(projectId: string): Promise<void> {
89
- if (typeof window === 'undefined') return;
90
-
91
- await this.ensureInitialized();
92
-
93
- if (this.currentProjectId && this.currentProjectId !== projectId) {
94
- await this.stopTracking();
95
- }
96
-
97
- this.currentProjectId = projectId;
98
-
99
- if (this.currentUser) {
100
- ws.emit('projects:join', {
101
- userName: this.currentUser.name
102
- });
103
- }
104
-
105
- document.addEventListener('visibilitychange', this.handleVisibilityChange);
106
- window.addEventListener('beforeunload', this.handleBeforeUnload);
107
- }
108
-
109
- /**
110
- * Leave a project - stop presence tracking
111
- */
112
- async stopTracking(): Promise<void> {
113
- if (this.currentProjectId && this.currentUser) {
114
- // Send projectId explicitly because ws.setProject() may have already changed context
115
- ws.emit('projects:leave', {
116
- projectId: this.currentProjectId
117
- });
118
- }
119
-
120
- if (typeof window !== 'undefined') {
121
- document.removeEventListener('visibilitychange', this.handleVisibilityChange);
122
- window.removeEventListener('beforeunload', this.handleBeforeUnload);
123
- }
124
-
125
- this.currentProjectId = null;
126
- }
127
-
128
- cleanup(): void {
129
- if (this.unsubscribe) {
130
- this.unsubscribe();
131
- this.unsubscribe = null;
132
- }
133
- this.statusUpdateCallbacks.clear();
134
- this.initialized = false;
135
- }
136
-
137
- private handleVisibilityChange = () => {
138
- if (!document.hidden && this.currentProjectId && this.currentUser) {
139
- ws.http('projects:update-presence', {
140
- userName: this.currentUser.name,
141
- action: 'update'
142
- }).catch(() => {});
143
- }
144
- };
145
-
146
- private handleBeforeUnload = () => {
147
- if (this.currentProjectId && this.currentUser) {
148
- ws.emit('projects:leave', {
149
- projectId: this.currentProjectId
150
- });
151
- }
152
- };
153
-
154
- getCurrentUser(): AnonymousUser | null {
155
- return this.currentUser;
156
- }
157
- }
158
-
159
- export const projectStatusService = new ProjectStatusService();
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();