@myrialabs/clopen 0.2.5 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/backend/chat/stream-manager.ts +136 -10
- package/backend/database/queries/session-queries.ts +9 -0
- package/backend/engine/adapters/claude/error-handler.ts +7 -2
- package/backend/engine/adapters/claude/stream.ts +16 -7
- package/backend/index.ts +25 -3
- package/backend/mcp/servers/browser-automation/browser.ts +23 -6
- package/backend/preview/browser/browser-mcp-control.ts +32 -16
- package/backend/preview/browser/browser-pool.ts +3 -1
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-tab-manager.ts +1 -1
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/audio-stream.ts +11 -0
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/chat/stream.ts +1 -1
- package/backend/ws/preview/browser/tab-info.ts +5 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/frontend/components/chat/input/ChatInput.svelte +6 -4
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
- package/frontend/components/chat/message/MessageBubble.svelte +22 -1
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
- package/frontend/components/files/FileViewer.svelte +13 -2
- package/frontend/components/history/HistoryModal.svelte +1 -1
- package/frontend/components/preview/browser/BrowserPreview.svelte +15 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +432 -69
- package/frontend/components/preview/browser/components/Container.svelte +23 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +27 -4
- package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +36 -3
- package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/PanelHeader.svelte +15 -0
- package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
- package/frontend/components/workspace/panels/GitPanel.svelte +27 -13
- package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
- package/frontend/services/chat/chat.service.ts +9 -8
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +43 -138
- package/frontend/stores/core/app.svelte.ts +4 -3
- package/frontend/stores/core/presence.svelte.ts +3 -2
- package/frontend/stores/core/sessions.svelte.ts +2 -0
- package/frontend/stores/ui/notification.svelte.ts +4 -1
- package/package.json +1 -1
|
@@ -115,17 +115,16 @@ export class BrowserWebCodecsService {
|
|
|
115
115
|
private lastAudioBytesReceived = 0;
|
|
116
116
|
private lastStatsTime = 0;
|
|
117
117
|
|
|
118
|
-
// ICE servers
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
122
|
-
];
|
|
118
|
+
// ICE servers - empty for local connections (both peers on same machine)
|
|
119
|
+
// STUN servers are unnecessary for localhost and add 100-500ms ICE gathering latency
|
|
120
|
+
private readonly iceServers: RTCIceServer[] = [];
|
|
123
121
|
|
|
124
122
|
// Callbacks
|
|
125
123
|
private onConnectionChange: ((connected: boolean) => void) | null = null;
|
|
126
124
|
private onConnectionFailed: (() => void) | null = null;
|
|
127
125
|
private onNavigationReconnect: (() => void) | null = null; // Fast reconnection after navigation
|
|
128
126
|
private onReconnectingStart: (() => void) | null = null; // Signals reconnecting state started (for UI)
|
|
127
|
+
private onFirstFrame: (() => void) | null = null; // Fires immediately when first frame is decoded
|
|
129
128
|
private onError: ((error: Error) => void) | null = null;
|
|
130
129
|
private onStats: ((stats: BrowserWebCodecsStreamStats) => void) | null = null;
|
|
131
130
|
private onCursorChange: ((cursor: string) => void) | null = null;
|
|
@@ -165,7 +164,7 @@ export class BrowserWebCodecsService {
|
|
|
165
164
|
* Start WebCodecs streaming for a preview session
|
|
166
165
|
*/
|
|
167
166
|
async startStreaming(sessionId: string, canvas: HTMLCanvasElement): Promise<boolean> {
|
|
168
|
-
debug.log('webcodecs', `
|
|
167
|
+
debug.log('webcodecs', `[DIAG] startStreaming called: sessionId=${sessionId}, isConnected=${this.isConnected}, existingSessionId=${this.sessionId}`);
|
|
169
168
|
|
|
170
169
|
if (!BrowserWebCodecsService.isSupported()) {
|
|
171
170
|
debug.error('webcodecs', 'Not supported in this browser');
|
|
@@ -183,7 +182,11 @@ export class BrowserWebCodecsService {
|
|
|
183
182
|
this.audioContext = new AudioContext({ sampleRate: 48000 });
|
|
184
183
|
}
|
|
185
184
|
if (this.audioContext.state === 'suspended') {
|
|
186
|
-
await
|
|
185
|
+
// Fire-and-forget: don't await — after page refresh (no user gesture),
|
|
186
|
+
// resume() returns a promise that NEVER resolves until user interacts.
|
|
187
|
+
// Awaiting it would block streaming indefinitely. Audio will resume
|
|
188
|
+
// automatically on first user interaction via the safety net in playAudioFrame.
|
|
189
|
+
this.audioContext.resume().catch(() => {});
|
|
187
190
|
}
|
|
188
191
|
|
|
189
192
|
// Clean up any existing connection
|
|
@@ -215,7 +218,9 @@ export class BrowserWebCodecsService {
|
|
|
215
218
|
this.setupEventListeners();
|
|
216
219
|
|
|
217
220
|
// Request server to start streaming and get offer
|
|
221
|
+
debug.log('webcodecs', `[DIAG] Sending preview:browser-stream-start for session: ${sessionId}`);
|
|
218
222
|
const response = await ws.http('preview:browser-stream-start', {}, 30000);
|
|
223
|
+
debug.log('webcodecs', `[DIAG] preview:browser-stream-start response: success=${response.success}, hasOffer=${!!response.offer}, message=${response.message}`);
|
|
219
224
|
|
|
220
225
|
if (!response.success) {
|
|
221
226
|
throw new Error(response.message || 'Failed to start streaming');
|
|
@@ -226,12 +231,15 @@ export class BrowserWebCodecsService {
|
|
|
226
231
|
|
|
227
232
|
// Set remote description (offer)
|
|
228
233
|
if (response.offer) {
|
|
234
|
+
debug.log('webcodecs', `[DIAG] Using offer from stream-start response`);
|
|
229
235
|
await this.handleOffer({
|
|
230
236
|
type: response.offer.type as RTCSdpType,
|
|
231
237
|
sdp: response.offer.sdp
|
|
232
238
|
});
|
|
233
239
|
} else {
|
|
240
|
+
debug.log('webcodecs', `[DIAG] No offer in stream-start response, fetching via stream-offer`);
|
|
234
241
|
const offerResponse = await ws.http('preview:browser-stream-offer', {}, 10000);
|
|
242
|
+
debug.log('webcodecs', `[DIAG] preview:browser-stream-offer response: hasOffer=${!!offerResponse.offer}`);
|
|
235
243
|
if (offerResponse.offer) {
|
|
236
244
|
await this.handleOffer({
|
|
237
245
|
type: offerResponse.offer.type as RTCSdpType,
|
|
@@ -242,7 +250,7 @@ export class BrowserWebCodecsService {
|
|
|
242
250
|
}
|
|
243
251
|
}
|
|
244
252
|
|
|
245
|
-
debug.log('webcodecs', 'Streaming setup complete');
|
|
253
|
+
debug.log('webcodecs', '[DIAG] Streaming setup complete, waiting for ICE/DataChannel');
|
|
246
254
|
return true;
|
|
247
255
|
} catch (error) {
|
|
248
256
|
debug.error('webcodecs', 'Failed to start streaming:', error);
|
|
@@ -596,9 +604,9 @@ export class BrowserWebCodecsService {
|
|
|
596
604
|
this.audioContext = new AudioContext({ sampleRate: 48000 });
|
|
597
605
|
}
|
|
598
606
|
|
|
599
|
-
// Resume if suspended
|
|
607
|
+
// Resume if suspended — fire-and-forget, same reason as in startStreaming
|
|
600
608
|
if (this.audioContext.state === 'suspended') {
|
|
601
|
-
|
|
609
|
+
this.audioContext.resume().catch(() => {});
|
|
602
610
|
}
|
|
603
611
|
|
|
604
612
|
debug.log('webcodecs', `AudioContext initialized (state: ${this.audioContext.state})`);
|
|
@@ -641,6 +649,11 @@ export class BrowserWebCodecsService {
|
|
|
641
649
|
this.firstFrameTimestamp = frame.timestamp;
|
|
642
650
|
debug.log('webcodecs', 'First video frame rendered');
|
|
643
651
|
|
|
652
|
+
// Notify immediately so UI can hide loading overlay without polling delay
|
|
653
|
+
if (this.onFirstFrame) {
|
|
654
|
+
this.onFirstFrame();
|
|
655
|
+
}
|
|
656
|
+
|
|
644
657
|
// Reset navigation state - frames are flowing, navigation is complete
|
|
645
658
|
if (this.isNavigating) {
|
|
646
659
|
debug.log('webcodecs', 'Navigation complete - frames received, resetting navigation state');
|
|
@@ -744,33 +757,28 @@ export class BrowserWebCodecsService {
|
|
|
744
757
|
}
|
|
745
758
|
|
|
746
759
|
/**
|
|
747
|
-
* Play audio frame
|
|
748
|
-
*
|
|
749
|
-
* The key insight: Audio and video timestamps from the server use the same
|
|
750
|
-
* performance.now() origin. However, audio may start LATER than video if the
|
|
751
|
-
* page has no audio initially (silence is skipped).
|
|
760
|
+
* Play audio frame using simple back-to-back scheduling.
|
|
752
761
|
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
762
|
+
* Frames are scheduled immediately one after another. When a gap occurs
|
|
763
|
+
* (e.g. silence was skipped server-side and audio resumes), the schedule
|
|
764
|
+
* is reset with a 50ms lookahead so playback starts cleanly without
|
|
765
|
+
* audible pops or stutters from scheduling in the past.
|
|
756
766
|
*/
|
|
757
767
|
private playAudioFrame(audioData: AudioData): void {
|
|
758
768
|
if (!this.audioContext) return;
|
|
759
769
|
|
|
760
|
-
// Safety net: resume AudioContext if
|
|
770
|
+
// Safety net: resume AudioContext if suspended
|
|
761
771
|
if (this.audioContext.state === 'suspended') {
|
|
762
772
|
this.audioContext.resume().catch(() => {});
|
|
763
773
|
}
|
|
764
774
|
|
|
765
775
|
try {
|
|
766
|
-
// Create AudioBuffer
|
|
767
776
|
const buffer = this.audioContext.createBuffer(
|
|
768
777
|
audioData.numberOfChannels,
|
|
769
778
|
audioData.numberOfFrames,
|
|
770
779
|
audioData.sampleRate
|
|
771
780
|
);
|
|
772
781
|
|
|
773
|
-
// Copy audio data to buffer
|
|
774
782
|
for (let channel = 0; channel < audioData.numberOfChannels; channel++) {
|
|
775
783
|
const options = {
|
|
776
784
|
planeIndex: channel,
|
|
@@ -778,134 +786,27 @@ export class BrowserWebCodecsService {
|
|
|
778
786
|
frameCount: audioData.numberOfFrames,
|
|
779
787
|
format: 'f32-planar' as AudioSampleFormat
|
|
780
788
|
};
|
|
781
|
-
|
|
782
|
-
const
|
|
783
|
-
const tempBuffer = new ArrayBuffer(requiredSize);
|
|
784
|
-
const tempFloat32 = new Float32Array(tempBuffer);
|
|
789
|
+
// allocationSize() returns bytes — wrap in ArrayBuffer so Float32Array length is correct
|
|
790
|
+
const tempFloat32 = new Float32Array(new ArrayBuffer(audioData.allocationSize(options)));
|
|
785
791
|
audioData.copyTo(tempFloat32, options);
|
|
786
|
-
|
|
787
|
-
const channelData = buffer.getChannelData(channel);
|
|
788
|
-
channelData.set(tempFloat32);
|
|
792
|
+
buffer.getChannelData(channel).set(tempFloat32);
|
|
789
793
|
}
|
|
790
794
|
|
|
791
795
|
const currentTime = this.audioContext.currentTime;
|
|
792
|
-
const audioTimestamp = audioData.timestamp; // microseconds
|
|
793
|
-
const bufferDuration = buffer.duration;
|
|
794
|
-
const now = performance.now();
|
|
795
|
-
|
|
796
|
-
// Wait for video to establish sync before playing audio
|
|
797
|
-
if (!this.syncEstablished || this.lastVideoTimestamp === 0) {
|
|
798
|
-
// No video yet - skip this audio frame to prevent desync
|
|
799
|
-
return;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
// Phase 1: Calibration - collect samples to determine stable offset
|
|
803
|
-
if (this.audioCalibrationSamples < this.CALIBRATION_SAMPLES) {
|
|
804
|
-
// Calculate the offset between audio timestamp and video timeline
|
|
805
|
-
// audioVideoOffset > 0 means audio is AHEAD of video in stream time
|
|
806
|
-
// audioVideoOffset < 0 means audio is BEHIND video in stream time
|
|
807
|
-
const audioVideoOffset = (audioTimestamp - this.lastVideoTimestamp) / 1000000; // seconds
|
|
808
|
-
|
|
809
|
-
// Also account for the time elapsed since video was rendered
|
|
810
|
-
const timeSinceVideoRender = (now - this.lastVideoRealTime) / 1000; // seconds
|
|
811
|
-
|
|
812
|
-
// Expected audio position relative to current video position
|
|
813
|
-
// If audio and video are in sync, audio should play at:
|
|
814
|
-
// currentTime + audioVideoOffset - timeSinceVideoRender
|
|
815
|
-
const expectedOffset = audioVideoOffset - timeSinceVideoRender;
|
|
816
|
-
|
|
817
|
-
this.audioOffsetAccumulator += expectedOffset;
|
|
818
|
-
this.audioCalibrationSamples++;
|
|
819
|
-
|
|
820
|
-
if (this.audioCalibrationSamples === this.CALIBRATION_SAMPLES) {
|
|
821
|
-
// Calibration complete - calculate average offset
|
|
822
|
-
this.calibratedAudioOffset = this.audioOffsetAccumulator / this.CALIBRATION_SAMPLES;
|
|
823
|
-
|
|
824
|
-
// Clamp the offset to reasonable bounds (-500ms to +500ms)
|
|
825
|
-
// Beyond this, something is wrong and we should just play immediately
|
|
826
|
-
if (this.calibratedAudioOffset < -0.5) {
|
|
827
|
-
debug.warn('webcodecs', `Audio calibration: offset ${(this.calibratedAudioOffset * 1000).toFixed(0)}ms too negative, clamping to -500ms`);
|
|
828
|
-
this.calibratedAudioOffset = -0.5;
|
|
829
|
-
} else if (this.calibratedAudioOffset > 0.5) {
|
|
830
|
-
debug.warn('webcodecs', `Audio calibration: offset ${(this.calibratedAudioOffset * 1000).toFixed(0)}ms too positive, clamping to +500ms`);
|
|
831
|
-
this.calibratedAudioOffset = 0.5;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// Initialize nextAudioPlayTime based on calibrated offset
|
|
835
|
-
// Add small buffer (30ms) for smooth playback
|
|
836
|
-
this.nextAudioPlayTime = currentTime + Math.max(0.03, this.calibratedAudioOffset + 0.03);
|
|
837
|
-
this.audioSyncInitialized = true;
|
|
838
|
-
|
|
839
|
-
debug.log('webcodecs', `Audio calibration complete: offset=${(this.calibratedAudioOffset * 1000).toFixed(1)}ms, startTime=${(this.nextAudioPlayTime - currentTime).toFixed(3)}s from now`);
|
|
840
|
-
} else {
|
|
841
|
-
// Still calibrating - skip this audio frame
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
796
|
|
|
846
|
-
//
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
if (targetPlayTime < currentTime - 0.01) {
|
|
851
|
-
// We're behind by more than 10ms, need to catch up
|
|
852
|
-
targetPlayTime = currentTime + 0.02; // Small buffer to recover
|
|
853
|
-
debug.warn('webcodecs', 'Audio buffer underrun, resetting playback');
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// Periodic drift check (every ~500ms worth of audio, ~25 frames)
|
|
857
|
-
if (this.stats.audioFramesDecoded % 25 === 0) {
|
|
858
|
-
// Recalculate where audio SHOULD be based on current video position
|
|
859
|
-
const audioVideoOffset = (audioTimestamp - this.lastVideoTimestamp) / 1000000;
|
|
860
|
-
const timeSinceVideoRender = (now - this.lastVideoRealTime) / 1000;
|
|
861
|
-
const expectedOffset = audioVideoOffset - timeSinceVideoRender;
|
|
862
|
-
|
|
863
|
-
// Where audio should play relative to currentTime
|
|
864
|
-
const idealTime = currentTime + expectedOffset + 0.03; // +30ms buffer
|
|
865
|
-
|
|
866
|
-
const drift = targetPlayTime - idealTime;
|
|
867
|
-
|
|
868
|
-
// Apply correction based on drift magnitude
|
|
869
|
-
if (Math.abs(drift) > 0.2) {
|
|
870
|
-
// Large drift (>200ms) - aggressive correction (80%)
|
|
871
|
-
targetPlayTime -= drift * 0.8;
|
|
872
|
-
debug.warn('webcodecs', `Large audio drift: ${(drift * 1000).toFixed(0)}ms, aggressive correction`);
|
|
873
|
-
} else if (Math.abs(drift) > 0.05) {
|
|
874
|
-
// Medium drift (50-200ms) - moderate correction (40%)
|
|
875
|
-
targetPlayTime -= drift * 0.4;
|
|
876
|
-
}
|
|
877
|
-
// Small drift (<50ms) - no correction, continuous playback handles it
|
|
797
|
+
// When the scheduler has fallen behind (gap due to silence or decode delay),
|
|
798
|
+
// reset with a 50ms lookahead so the next chunk starts cleanly.
|
|
799
|
+
if (this.nextAudioPlayTime < currentTime) {
|
|
800
|
+
this.nextAudioPlayTime = currentTime + 0.05;
|
|
878
801
|
}
|
|
879
802
|
|
|
880
|
-
// Ensure we don't schedule in the past
|
|
881
|
-
if (targetPlayTime < currentTime + 0.005) {
|
|
882
|
-
targetPlayTime = currentTime + 0.005;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// Schedule this buffer
|
|
886
803
|
const source = this.audioContext.createBufferSource();
|
|
887
804
|
source.buffer = buffer;
|
|
888
805
|
source.connect(this.audioContext.destination);
|
|
889
|
-
source.start(
|
|
890
|
-
|
|
891
|
-
// Track scheduled buffer
|
|
892
|
-
this.audioBufferQueue.push({ buffer, scheduledTime: targetPlayTime });
|
|
806
|
+
source.start(this.nextAudioPlayTime);
|
|
893
807
|
|
|
894
|
-
//
|
|
895
|
-
this.nextAudioPlayTime
|
|
896
|
-
|
|
897
|
-
// Limit queue size to prevent memory buildup
|
|
898
|
-
if (this.audioBufferQueue.length > this.maxAudioQueueSize) {
|
|
899
|
-
this.audioBufferQueue.shift();
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// Cleanup old scheduled buffers
|
|
903
|
-
source.onended = () => {
|
|
904
|
-
const index = this.audioBufferQueue.findIndex(item => item.buffer === buffer);
|
|
905
|
-
if (index !== -1) {
|
|
906
|
-
this.audioBufferQueue.splice(index, 1);
|
|
907
|
-
}
|
|
908
|
-
};
|
|
808
|
+
// Back-to-back: next chunk plays immediately after this one ends
|
|
809
|
+
this.nextAudioPlayTime += buffer.duration;
|
|
909
810
|
} catch (error) {
|
|
910
811
|
debug.warn('webcodecs', 'Audio playback error:', error);
|
|
911
812
|
}
|
|
@@ -1477,6 +1378,10 @@ export class BrowserWebCodecsService {
|
|
|
1477
1378
|
this.onReconnectingStart = handler;
|
|
1478
1379
|
}
|
|
1479
1380
|
|
|
1381
|
+
setFirstFrameHandler(handler: () => void): void {
|
|
1382
|
+
this.onFirstFrame = handler;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1480
1385
|
setErrorHandler(handler: (error: Error) => void): void {
|
|
1481
1386
|
this.onError = handler;
|
|
1482
1387
|
}
|
|
@@ -214,10 +214,11 @@ export function isSessionUnread(sessionId: string): boolean {
|
|
|
214
214
|
|
|
215
215
|
/**
|
|
216
216
|
* Check if a project has any unread sessions.
|
|
217
|
+
* Optionally exclude a specific session (e.g. the currently viewed one).
|
|
217
218
|
*/
|
|
218
|
-
export function hasUnreadSessionsForProject(projectId: string): boolean {
|
|
219
|
-
for (const pId of appState.unreadSessions.
|
|
220
|
-
if (pId === projectId) return true;
|
|
219
|
+
export function hasUnreadSessionsForProject(projectId: string, excludeSessionId?: string): boolean {
|
|
220
|
+
for (const [sId, pId] of appState.unreadSessions.entries()) {
|
|
221
|
+
if (pId === projectId && sId !== excludeSessionId) return true;
|
|
221
222
|
}
|
|
222
223
|
return false;
|
|
223
224
|
}
|
|
@@ -119,8 +119,9 @@ export function getProjectStatusColor(projectId: string): string {
|
|
|
119
119
|
return 'bg-amber-500';
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Check for unread sessions in this project
|
|
123
|
-
|
|
122
|
+
// Check for unread sessions in this project, excluding the currently viewed session
|
|
123
|
+
const currentSessionId = sessionState.currentSession?.id;
|
|
124
|
+
if (hasUnreadSessionsForProject(projectId, currentSessionId)) return 'bg-blue-500';
|
|
124
125
|
|
|
125
126
|
return 'bg-slate-500/30';
|
|
126
127
|
}
|
|
@@ -295,6 +295,8 @@ export async function loadSessions() {
|
|
|
295
295
|
// wiping out any stream_event injected by catchup.
|
|
296
296
|
await loadMessagesForSession(targetSession.id);
|
|
297
297
|
sessionState.currentSession = targetSession;
|
|
298
|
+
// Clear unread status — user is actively viewing this session
|
|
299
|
+
markSessionRead(targetSession.id);
|
|
298
300
|
// Join chat session room so we receive session-scoped events
|
|
299
301
|
// (stream, input sync, edit mode, model sync).
|
|
300
302
|
// Critical after refresh — without it, connection misses all events.
|
|
@@ -6,6 +6,9 @@ export const notificationStore = $state({
|
|
|
6
6
|
maxNotifications: 5
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
+
// Monotonic counter for unique notification IDs
|
|
10
|
+
let _notificationCounter = 0;
|
|
11
|
+
|
|
9
12
|
// Derived values as functions (cannot export derived state from modules)
|
|
10
13
|
export function hasNotifications() {
|
|
11
14
|
return notificationStore.notifications.length > 0;
|
|
@@ -18,7 +21,7 @@ export function notificationCount() {
|
|
|
18
21
|
// Notification management functions
|
|
19
22
|
export function addNotification(notification: Omit<ToastNotification, 'id'>) {
|
|
20
23
|
const newNotification: ToastNotification = {
|
|
21
|
-
id: Date.now()
|
|
24
|
+
id: `${Date.now()}-${++_notificationCounter}`,
|
|
22
25
|
...notification,
|
|
23
26
|
duration: notification.duration || 5000 // Default 5 seconds
|
|
24
27
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myrialabs/clopen",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
5
|
"author": "Myria Labs",
|
|
6
6
|
"license": "MIT",
|