@myrialabs/clopen 0.1.8 → 0.1.9

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.
@@ -0,0 +1,32 @@
1
+ import type { DatabaseConnection } from '$shared/types/database/connection';
2
+ import { debug } from '$shared/utils/logger';
3
+
4
+ export const description = 'Create user_unread_sessions table for persisting per-user unread session state';
5
+
6
+ export const up = (db: DatabaseConnection): void => {
7
+ debug.log('migration', 'Creating user_unread_sessions table...');
8
+
9
+ db.exec(`
10
+ CREATE TABLE IF NOT EXISTS user_unread_sessions (
11
+ user_id TEXT NOT NULL,
12
+ session_id TEXT NOT NULL,
13
+ project_id TEXT NOT NULL,
14
+ marked_at TEXT NOT NULL,
15
+ PRIMARY KEY (user_id, session_id),
16
+ FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
17
+ )
18
+ `);
19
+
20
+ db.exec(`
21
+ CREATE INDEX IF NOT EXISTS idx_user_unread_sessions_user_project
22
+ ON user_unread_sessions(user_id, project_id)
23
+ `);
24
+
25
+ debug.log('migration', 'user_unread_sessions table created');
26
+ };
27
+
28
+ export const down = (db: DatabaseConnection): void => {
29
+ debug.log('migration', 'Dropping user_unread_sessions table...');
30
+ db.exec('DROP TABLE IF EXISTS user_unread_sessions');
31
+ debug.log('migration', 'user_unread_sessions table dropped');
32
+ };
@@ -21,6 +21,7 @@ import * as migration019 from './019_add_claude_account_to_sessions';
21
21
  import * as migration020 from './020_add_snapshot_tree_hash';
22
22
  import * as migration021 from './021_drop_prompt_templates_table';
23
23
  import * as migration022 from './022_add_snapshot_changes_column';
24
+ import * as migration023 from './023_create_user_unread_sessions_table';
24
25
 
25
26
  // Export all migrations in order
26
27
  export const migrations = [
@@ -155,6 +156,12 @@ export const migrations = [
155
156
  description: migration022.description,
156
157
  up: migration022.up,
157
158
  down: migration022.down
159
+ },
160
+ {
161
+ id: '023',
162
+ description: migration023.description,
163
+ up: migration023.up,
164
+ down: migration023.down
158
165
  }
159
166
  ];
160
167
 
@@ -267,5 +267,42 @@ export const sessionQueries = {
267
267
  SET head_message_id = ?
268
268
  WHERE session_id = ? AND branch_name = ?
269
269
  `).run(newHeadMessageId, sessionId, branchName);
270
+ },
271
+
272
+ // ==================== PER-USER UNREAD SESSION TRACKING ====================
273
+
274
+ /**
275
+ * Mark a session as unread for a specific user
276
+ */
277
+ markUnread(userId: string, sessionId: string, projectId: string): void {
278
+ const db = getDatabase();
279
+ const now = new Date().toISOString();
280
+ db.prepare(`
281
+ INSERT OR IGNORE INTO user_unread_sessions (user_id, session_id, project_id, marked_at)
282
+ VALUES (?, ?, ?, ?)
283
+ `).run(userId, sessionId, projectId, now);
284
+ },
285
+
286
+ /**
287
+ * Mark a session as read for a specific user
288
+ */
289
+ markRead(userId: string, sessionId: string): void {
290
+ const db = getDatabase();
291
+ db.prepare(`
292
+ DELETE FROM user_unread_sessions
293
+ WHERE user_id = ? AND session_id = ?
294
+ `).run(userId, sessionId);
295
+ },
296
+
297
+ /**
298
+ * Get all unread session IDs for a user within a project
299
+ * Returns array of { sessionId, projectId }
300
+ */
301
+ getUnreadSessions(userId: string, projectId: string): { session_id: string; project_id: string }[] {
302
+ const db = getDatabase();
303
+ return db.prepare(`
304
+ SELECT session_id, project_id FROM user_unread_sessions
305
+ WHERE user_id = ? AND project_id = ?
306
+ `).all(userId, projectId) as { session_id: string; project_id: string }[];
270
307
  }
271
308
  };
@@ -33,7 +33,11 @@ export const crudHandler = createRouter()
33
33
  started_at: t.String(),
34
34
  ended_at: t.Optional(t.String())
35
35
  })),
36
- currentSessionId: t.Optional(t.String())
36
+ currentSessionId: t.Optional(t.String()),
37
+ unreadSessionIds: t.Array(t.Object({
38
+ sessionId: t.String(),
39
+ projectId: t.String()
40
+ }))
37
41
  })
38
42
  }, async ({ conn }) => {
39
43
  const projectId = ws.getProjectId(conn);
@@ -43,6 +47,10 @@ export const crudHandler = createRouter()
43
47
  // Get the user's saved current session for this project
44
48
  const currentSessionId = projectQueries.getCurrentSessionId(userId, projectId);
45
49
 
50
+ // Get unread sessions for this user/project
51
+ const unreadRows = sessionQueries.getUnreadSessions(userId, projectId);
52
+ debug.log('session', `[unread] sessions:list — user=${userId}, project=${projectId}, unreadCount=${unreadRows.length}`, unreadRows);
53
+
46
54
  // Convert null to undefined for TypeScript optional fields
47
55
  return {
48
56
  sessions: sessions.map(session => ({
@@ -54,7 +62,8 @@ export const crudHandler = createRouter()
54
62
  current_head_message_id: session.current_head_message_id ?? undefined,
55
63
  ended_at: session.ended_at ?? undefined
56
64
  })),
57
- currentSessionId: currentSessionId ?? undefined
65
+ currentSessionId: currentSessionId ?? undefined,
66
+ unreadSessionIds: unreadRows.map(r => ({ sessionId: r.session_id, projectId: r.project_id }))
58
67
  };
59
68
  })
60
69
 
@@ -324,4 +333,27 @@ export const crudHandler = createRouter()
324
333
  const userId = ws.getUserId(conn);
325
334
  projectQueries.setCurrentSessionId(userId, projectId, data.sessionId);
326
335
  debug.log('session', `User ${userId} set current session to ${data.sessionId} in project ${projectId}`);
336
+ })
337
+
338
+ // Mark a session as read for the current user
339
+ .on('sessions:mark-read', {
340
+ data: t.Object({
341
+ sessionId: t.String()
342
+ })
343
+ }, async ({ data, conn }) => {
344
+ const userId = ws.getUserId(conn);
345
+ sessionQueries.markRead(userId, data.sessionId);
346
+ debug.log('session', `[unread] Marked session ${data.sessionId} as READ for user ${userId}`);
347
+ })
348
+
349
+ // Mark a session as unread for the current user
350
+ .on('sessions:mark-unread', {
351
+ data: t.Object({
352
+ sessionId: t.String(),
353
+ projectId: t.String()
354
+ })
355
+ }, async ({ data, conn }) => {
356
+ const userId = ws.getUserId(conn);
357
+ sessionQueries.markUnread(userId, data.sessionId, data.projectId);
358
+ debug.log('session', `[unread] Marked session ${data.sessionId} as UNREAD for user ${userId} in project ${data.projectId}`);
327
359
  });
@@ -108,7 +108,8 @@ export const crudHandler = createRouter()
108
108
  response: t.Object({
109
109
  currentProjectId: t.Union([t.String(), t.Null()]),
110
110
  lastView: t.Union([t.String(), t.Null()]),
111
- settings: t.Union([t.Any(), t.Null()])
111
+ settings: t.Union([t.Any(), t.Null()]),
112
+ unreadSessions: t.Union([t.Any(), t.Null()])
112
113
  })
113
114
  }, async ({ conn }) => {
114
115
  const userId = ws.getUserId(conn);
@@ -116,17 +117,20 @@ export const crudHandler = createRouter()
116
117
  const currentProjectId = getUserState(userId, 'currentProjectId') as string | null;
117
118
  const lastView = getUserState(userId, 'lastView') as string | null;
118
119
  const userSettings = getUserState(userId, 'settings');
120
+ const unreadSessions = getUserState(userId, 'unreadSessions');
119
121
 
120
122
  debug.log('user', `Restored state for ${userId}:`, {
121
123
  currentProjectId,
122
124
  lastView,
123
- hasSettings: !!userSettings
125
+ hasSettings: !!userSettings,
126
+ unreadSessionsCount: unreadSessions ? Object.keys(unreadSessions).length : 0
124
127
  });
125
128
 
126
129
  return {
127
130
  currentProjectId: currentProjectId ?? null,
128
131
  lastView: lastView ?? null,
129
- settings: userSettings ?? null
132
+ settings: userSettings ?? null,
133
+ unreadSessions: unreadSessions ?? null
130
134
  };
131
135
  })
132
136
 
@@ -143,7 +147,7 @@ export const crudHandler = createRouter()
143
147
  const userId = ws.getUserId(conn);
144
148
 
145
149
  // Validate allowed keys to prevent arbitrary data storage
146
- const allowedKeys = ['currentProjectId', 'lastView', 'settings'];
150
+ const allowedKeys = ['currentProjectId', 'lastView', 'settings', 'unreadSessions'];
147
151
  if (!allowedKeys.includes(data.key)) {
148
152
  throw new Error(`Invalid state key: ${data.key}. Allowed: ${allowedKeys.join(', ')}`);
149
153
  }
@@ -14,6 +14,25 @@
14
14
  const { commits, isLoading, hasMore, onLoadMore, onViewCommit }: Props = $props();
15
15
 
16
16
  let selectedHash = $state('');
17
+ let sentinelEl = $state<HTMLDivElement | null>(null);
18
+
19
+ // Infinite scroll: auto load more when sentinel is visible
20
+ $effect(() => {
21
+ const el = sentinelEl;
22
+ if (!el || !hasMore) return;
23
+
24
+ const observer = new IntersectionObserver(
25
+ (entries) => {
26
+ if (entries[0]?.isIntersecting && hasMore && !isLoading) {
27
+ onLoadMore();
28
+ }
29
+ },
30
+ { rootMargin: '100px' }
31
+ );
32
+
33
+ observer.observe(el);
34
+ return () => observer.disconnect();
35
+ });
17
36
 
18
37
  // ========================
19
38
  // Git Graph Computation
@@ -325,7 +344,7 @@
325
344
  <div class="flex-1 min-w-0 px-1.5 py-0.5 flex flex-col justify-center overflow-hidden">
326
345
  <!-- Line 1: Message + Date -->
327
346
  <div class="flex items-center gap-2">
328
- <p class="flex-1 min-w-0 text-sm text-slate-900 dark:text-slate-100 leading-tight truncate">
347
+ <p class="flex-1 min-w-0 text-sm text-slate-900 dark:text-slate-100 leading-tight truncate" title={commit.message}>
329
348
  {commit.message}
330
349
  </p>
331
350
  <span class="text-3xs text-slate-400 shrink-0">{formatDate(commit.date)}</span>
@@ -349,7 +368,7 @@
349
368
  <div class="flex items-center gap-1 mt-px overflow-hidden">
350
369
  {#each commit.refs.slice(0, MAX_VISIBLE_REFS) as ref}
351
370
  <span
352
- class="text-3xs px-1 py-px rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0 truncate max-w-28"
371
+ class="text-3xs px-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0 truncate max-w-28"
353
372
  title={ref}
354
373
  >
355
374
  {truncateRef(ref)}
@@ -369,17 +388,12 @@
369
388
  </div>
370
389
  {/each}
371
390
 
372
- <!-- Load more -->
391
+ <!-- Infinite scroll sentinel -->
373
392
  {#if hasMore}
374
- <div class="flex justify-center py-3">
375
- <button
376
- type="button"
377
- class="px-4 py-1.5 text-xs font-medium text-violet-600 bg-violet-500/10 rounded-md hover:bg-violet-500/20 transition-colors cursor-pointer border-none"
378
- onclick={onLoadMore}
379
- disabled={isLoading}
380
- >
381
- {isLoading ? 'Loading...' : 'Load More'}
382
- </button>
393
+ <div bind:this={sentinelEl} class="flex justify-center py-3">
394
+ {#if isLoading}
395
+ <div class="w-4 h-4 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
396
+ {/if}
383
397
  </div>
384
398
  {/if}
385
399
  </div>
@@ -214,26 +214,21 @@
214
214
  </script>
215
215
 
216
216
  <!-- Preview Toolbar -->
217
- <div class="relative px-3 py-2.5 bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
218
- <!-- Tabs bar -->
217
+ <div class="relative bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
218
+ <!-- Tabs bar (Git-style underline tabs) — separated with its own border-bottom -->
219
219
  {#if tabs.length > 0}
220
- <div class="flex items-center gap-1.5 overflow-x-auto mb-1.5">
221
- <!-- Tabs -->
220
+ <div class="relative flex items-center overflow-x-auto border-b border-slate-200 dark:border-slate-700">
222
221
  {#each tabs as tab}
223
- <div
224
- class="group relative flex items-center gap-2 pl-3 pr-2 py-1.5 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 min-w-0 max-w-xs cursor-pointer
225
- {tab.id === activeTabId
226
- ? 'bg-slate-100 dark:bg-slate-700'
227
- : 'bg-white dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700'}"
222
+ {@const isActive = tab.id === activeTabId}
223
+ <button
224
+ type="button"
225
+ class="group relative flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
226
+ {isActive
227
+ ? 'text-violet-600 dark:text-violet-400'
228
+ : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
228
229
  onclick={() => onSwitchTab(tab.id)}
229
230
  role="tab"
230
231
  tabindex="0"
231
- onkeydown={(e) => {
232
- if (e.key === 'Enter' || e.key === ' ') {
233
- e.preventDefault();
234
- onSwitchTab(tab.id);
235
- }
236
- }}
237
232
  >
238
233
  {#if tab.id === mcpControlledTabId}
239
234
  <Icon name="lucide:bot" class="w-3 h-3 flex-shrink-0 text-amber-500" />
@@ -242,81 +237,95 @@
242
237
  {:else}
243
238
  <Icon name="lucide:globe" class="w-3 h-3 flex-shrink-0" />
244
239
  {/if}
245
- <span class="text-xs font-medium truncate max-w-37.5" title={tab.url}>
240
+ <span class="truncate max-w-28" title={tab.url}>
246
241
  {tab.title || 'New Tab'}
247
242
  </span>
248
243
  {#if tab.id === mcpControlledTabId}
249
- <span title="MCP Controlled" class="flex">
250
- <Icon name="lucide:lock" class="w-3 h-3 flex-shrink-0 text-amber-500" />
244
+ <span title="MCP Controlled" class="flex"><Icon name="lucide:lock" class="w-3 h-3 flex-shrink-0 text-amber-500" /></span>
245
+ {/if}
246
+ <!-- Close button -->
247
+ {#if tab.id !== mcpControlledTabId}
248
+ <span
249
+ role="button"
250
+ tabindex="0"
251
+ onclick={(e) => {
252
+ e.stopPropagation();
253
+ onCloseTab(tab.id);
254
+ }}
255
+ onkeydown={(e) => {
256
+ if (e.key === 'Enter' || e.key === ' ') {
257
+ e.stopPropagation();
258
+ onCloseTab(tab.id);
259
+ }
260
+ }}
261
+ class="flex items-center justify-center w-4 h-4 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-all duration-200 flex-shrink-0"
262
+ title="Close tab"
263
+ >
264
+ <Icon name="lucide:x" class="w-2.5 h-2.5" />
251
265
  </span>
252
266
  {/if}
253
- <button
254
- onclick={(e) => {
255
- e.stopPropagation();
256
- onCloseTab(tab.id);
257
- }}
258
- class="flex hover:bg-slate-300 dark:hover:bg-slate-600 rounded p-0.5 transition-all duration-200 {tab.id === mcpControlledTabId ? 'hidden' : ''}"
259
- title="Close tab"
260
- disabled={tab.id === mcpControlledTabId}
261
- >
262
- <Icon name="lucide:x" class="w-3 h-3" />
263
- </button>
264
- </div>
267
+ {#if isActive}
268
+ <span class="absolute bottom-0 inset-x-0 h-px bg-violet-600 dark:bg-violet-400"></span>
269
+ {/if}
270
+ </button>
265
271
  {/each}
266
-
272
+
267
273
  <!-- New tab button -->
268
274
  <button
275
+ type="button"
269
276
  onclick={() => onNewTab()}
270
- class="flex items-center justify-center w-5 h-5 rounded-md hover:bg-slate-200 dark:hover:bg-slate-700 transition-all duration-200"
277
+ class="flex items-center justify-center w-6 h-6 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-all duration-200 flex-shrink-0 ml-1"
271
278
  title="Open new tab"
272
279
  >
273
280
  <Icon name="lucide:plus" class="w-3 h-3" />
274
281
  </button>
275
282
  </div>
276
283
  {/if}
277
-
278
- <!-- Main toolbar header -->
279
- <div class="px-1 py-0.5 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
280
- <div class="flex items-center justify-between gap-4">
281
- <!-- Left section: URL navigation -->
282
- <div class="flex items-center gap-3 flex-1 min-w-0">
283
- <!-- URL input with integrated controls -->
284
- <input
285
- type="text"
286
- bind:value={urlInput}
287
- onkeydown={handleUrlKeydown}
288
- oninput={handleUrlInput}
289
- onfocus={() => isUserTyping = true}
290
- onblur={() => isUserTyping = false}
291
- placeholder="Enter URL to preview..."
292
- class="flex-1 pl-3 py-2.5 text-sm bg-transparent border-0 focus:outline-none min-w-0 text-ellipsis"
293
- />
294
- <div class="flex items-center gap-1 px-2">
295
- {#if url}
296
- <button
297
- onclick={handleOpenInExternalBrowser}
298
- class="flex p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200"
299
- title="Open in external browser"
300
- >
301
- <Icon name="lucide:external-link" class="w-4 h-4" />
302
- </button>
284
+
285
+ <!-- Main toolbar header (URL bar) -->
286
+ <div class="px-2.5 py-1.5">
287
+ <div class="px-1 py-0.5 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
288
+ <div class="flex items-center justify-between gap-3">
289
+ <!-- Left section: URL navigation -->
290
+ <div class="flex items-center gap-2 flex-1 min-w-0">
291
+ <!-- URL input with integrated controls -->
292
+ <input
293
+ type="text"
294
+ bind:value={urlInput}
295
+ onkeydown={handleUrlKeydown}
296
+ oninput={handleUrlInput}
297
+ onfocus={() => isUserTyping = true}
298
+ onblur={() => isUserTyping = false}
299
+ placeholder="Enter URL to preview..."
300
+ class="flex-1 pl-3 py-2 text-sm bg-transparent border-0 focus:outline-none min-w-0 text-ellipsis"
301
+ />
302
+ <div class="flex items-center gap-1 px-1.5">
303
+ {#if url}
304
+ <button
305
+ onclick={handleOpenInExternalBrowser}
306
+ class="flex p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200"
307
+ title="Open in external browser"
308
+ >
309
+ <Icon name="lucide:external-link" class="w-4 h-4" />
310
+ </button>
311
+ <button
312
+ onclick={handleRefresh}
313
+ disabled={isLoading}
314
+ class="flex p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 disabled:opacity-50"
315
+ title="Refresh current page"
316
+ >
317
+ <Icon name={isLoading ? 'lucide:loader-circle' : 'lucide:refresh-cw'} class="w-4 h-4 transition-transform duration-200 {isLoading ? 'animate-spin' : 'hover:rotate-180'}" />
318
+ </button>
319
+ {/if}
303
320
  <button
304
- onclick={handleRefresh}
305
- disabled={isLoading}
306
- class="flex p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 disabled:opacity-50"
307
- title="Refresh current page"
321
+ onclick={handleGoClick}
322
+ disabled={!urlInput.trim() || isLoading}
323
+ class="ml-0.5 px-3.5 py-1 text-xs font-medium rounded-md bg-violet-500 hover:bg-violet-600 disabled:bg-slate-300 disabled:dark:bg-slate-600 text-white transition-all duration-200 disabled:opacity-50"
324
+ title="Navigate to URL"
308
325
  >
309
- <Icon name={isLoading ? 'lucide:loader-circle' : 'lucide:refresh-cw'} class="w-4 h-4 transition-transform duration-200 {isLoading ? 'animate-spin' : 'hover:rotate-180'}" />
326
+ Go
310
327
  </button>
311
- {/if}
312
- <button
313
- onclick={handleGoClick}
314
- disabled={!urlInput.trim() || isLoading}
315
- class="ml-1 px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 hover:bg-violet-600 disabled:bg-slate-300 disabled:dark:bg-slate-600 text-white transition-all duration-200 disabled:opacity-50"
316
- title="Navigate to URL"
317
- >
318
- Go
319
- </button>
328
+ </div>
320
329
  </div>
321
330
  </div>
322
331
  </div>
@@ -17,7 +17,6 @@
17
17
  import UserSettings from './user/UserSettings.svelte';
18
18
  import NotificationSettings from './notifications/NotificationSettings.svelte';
19
19
  import GeneralSettings from './general/GeneralSettings.svelte';
20
- import pkg from '../../../../package.json';
21
20
 
22
21
  // Responsive state
23
22
  let isMobileMenuOpen = $state(false);
@@ -179,13 +178,7 @@
179
178
  {/each}
180
179
  </nav>
181
180
 
182
- <footer class="p-4 border-t border-slate-200 dark:border-slate-800">
183
- <div class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-500">
184
- <Icon name="lucide:info" class="w-4 h-4" />
185
- <span>Clopen v{pkg.version}</span>
186
- </div>
187
- </footer>
188
- </aside>
181
+ </aside>
189
182
 
190
183
  <!-- Mobile Menu Overlay -->
191
184
  {#if isMobile && isMobileMenuOpen}
@@ -258,7 +258,7 @@
258
258
  aria-label="Terminal application">
259
259
 
260
260
  <!-- Terminal Header with Tabs -->
261
- <div class="flex-shrink-0 px-3 py-2.5 bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
261
+ <div class="flex-shrink-0 bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
262
262
  <!-- Terminal Tabs -->
263
263
  <TerminalTabs
264
264
  sessions={terminalStore.sessions}
@@ -31,52 +31,54 @@
31
31
  });
32
32
  </script>
33
33
 
34
- <!-- Compact Terminal Tabs -->
35
- <div class="flex items-center gap-1.5 overflow-x-auto flex-1">
34
+ <!-- Terminal Tabs (Git-style underline tabs) -->
35
+ <div class="relative flex items-center overflow-x-auto flex-1">
36
36
  {#each sessions as session (session.id)}
37
- <div
38
- class="group relative flex items-center gap-2 pl-3 pr-2 py-1.5 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 min-w-0 max-w-xs cursor-pointer
39
- {session.isActive
40
- ? 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100'
41
- : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700'}"
37
+ {@const isActive = session.isActive}
38
+ <button
39
+ type="button"
40
+ class="group relative flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
41
+ {isActive
42
+ ? 'text-violet-600 dark:text-violet-400'
43
+ : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
42
44
  onclick={() => onSwitchSession?.(session.id)}
43
45
  role="tab"
44
46
  tabindex="0"
45
- onkeydown={(e) => {
46
- if (e.key === 'Enter' || e.key === ' ') {
47
- e.preventDefault();
48
- onSwitchSession?.(session.id);
49
- }
50
- }}
51
47
  >
52
- <!-- Terminal icon -->
53
48
  <Icon name="lucide:terminal" class="w-3 h-3 flex-shrink-0" />
54
-
55
- <!-- Session name -->
56
- <span class="text-xs font-medium truncate max-w-37.5">
57
- {session.name}
58
- </span>
59
-
49
+ <span class="truncate max-w-28">{session.name}</span>
60
50
  <!-- Close button -->
61
- <button
51
+ <span
52
+ role="button"
53
+ tabindex="0"
62
54
  onclick={(e) => {
63
55
  e.stopPropagation();
64
56
  onCloseSession?.(session.id);
65
57
  }}
66
- class="flex hover:bg-slate-300 dark:hover:bg-slate-600 rounded p-0.5 transition-all duration-200 flex-shrink-0"
58
+ onkeydown={(e) => {
59
+ if (e.key === 'Enter' || e.key === ' ') {
60
+ e.stopPropagation();
61
+ onCloseSession?.(session.id);
62
+ }
63
+ }}
64
+ class="flex items-center justify-center w-4 h-4 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-all duration-200 flex-shrink-0"
67
65
  title="Close terminal"
68
66
  aria-label="Close terminal session"
69
67
  >
70
- <Icon name="lucide:x" class="w-3 h-3" />
71
- </button>
72
- </div>
68
+ <Icon name="lucide:x" class="w-2.5 h-2.5" />
69
+ </span>
70
+ {#if isActive}
71
+ <span class="absolute bottom-0 inset-x-0 h-px bg-violet-600 dark:bg-violet-400"></span>
72
+ {/if}
73
+ </button>
73
74
  {/each}
74
75
 
75
76
  <!-- New terminal button -->
76
77
  {#if onNewSession}
77
78
  <button
79
+ type="button"
78
80
  onclick={onNewSession}
79
- class="flex items-center justify-center w-5 h-5 rounded-md hover:bg-slate-200 dark:hover:bg-slate-700 transition-all duration-200 flex-shrink-0"
81
+ class="flex items-center justify-center w-6 h-6 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-all duration-200 flex-shrink-0 ml-1"
80
82
  title="New terminal"
81
83
  aria-label="New terminal session"
82
84
  >
@@ -8,7 +8,7 @@
8
8
  workspaceState,
9
9
  initializeWorkspace,
10
10
  } from '$frontend/lib/stores/ui/workspace.svelte';
11
- import { appState, setAppLoading, setAppInitialized, restoreLastView } from '$frontend/lib/stores/core/app.svelte';
11
+ import { appState, setAppLoading, setAppInitialized, restoreLastView, restoreUnreadSessions } from '$frontend/lib/stores/core/app.svelte';
12
12
  import { projectState } from '$frontend/lib/stores/core/projects.svelte';
13
13
  import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
14
14
 
@@ -89,7 +89,7 @@
89
89
 
90
90
  // Step 3: Restore user state from server
91
91
  setProgress(30, 'Restoring state...');
92
- let serverState: { currentProjectId: string | null; lastView: string | null; settings: any } | null = null;
92
+ let serverState: { currentProjectId: string | null; lastView: string | null; settings: any; unreadSessions: any } | null = null;
93
93
  try {
94
94
  serverState = await ws.http('user:restore-state', {});
95
95
  debug.log('workspace', 'Server state restored:', serverState);
@@ -103,6 +103,7 @@
103
103
  applyServerSettings(serverState.settings);
104
104
  }
105
105
  restoreLastView(serverState?.lastView);
106
+ restoreUnreadSessions(serverState?.unreadSessions);
106
107
  initPresence();
107
108
 
108
109
  // Step 5: Load projects (with server-restored currentProjectId)
@@ -139,26 +139,72 @@ export function clearSessionProcessState(sessionId: string): void {
139
139
  // UNREAD SESSION MANAGEMENT
140
140
  // ========================================
141
141
 
142
+ /**
143
+ * Persist the current unread sessions Map to the server via user:save-state.
144
+ * Uses the same proven infrastructure as currentProjectId/lastView persistence.
145
+ * Debounced: only the last call within 500ms actually persists.
146
+ */
147
+ let saveUnreadTimeout: ReturnType<typeof setTimeout> | null = null;
148
+
149
+ function persistUnreadSessions(): void {
150
+ if (saveUnreadTimeout) clearTimeout(saveUnreadTimeout);
151
+ saveUnreadTimeout = setTimeout(() => {
152
+ const serialized = Object.fromEntries(appState.unreadSessions);
153
+ debug.log('session', '[unread] Persisting to server:', serialized);
154
+ ws.http('user:save-state', { key: 'unreadSessions', value: serialized }).catch(err => {
155
+ debug.error('session', '[unread] Error persisting unread sessions:', err);
156
+ });
157
+ }, 500);
158
+ }
159
+
142
160
  /**
143
161
  * Mark a session as unread (has new activity the user hasn't seen).
162
+ * Persists to backend so the state survives browser refresh.
144
163
  */
145
164
  export function markSessionUnread(sessionId: string, projectId: string): void {
146
165
  const next = new Map(appState.unreadSessions);
147
166
  next.set(sessionId, projectId);
148
167
  appState.unreadSessions = next;
168
+
169
+ // Persist to backend via user:save-state (proven infrastructure)
170
+ debug.log('session', `[unread] markSessionUnread: sessionId=${sessionId}, projectId=${projectId}`);
171
+ ws.emit('sessions:mark-unread', { sessionId, projectId });
172
+ persistUnreadSessions();
149
173
  }
150
174
 
151
175
  /**
152
176
  * Mark a session as read (user has viewed it).
177
+ * Persists to backend so the state survives browser refresh.
153
178
  */
154
179
  export function markSessionRead(sessionId: string): void {
155
180
  if (appState.unreadSessions.has(sessionId)) {
156
181
  const next = new Map(appState.unreadSessions);
157
182
  next.delete(sessionId);
158
183
  appState.unreadSessions = next;
184
+
185
+ // Persist to backend via user:save-state (proven infrastructure)
186
+ debug.log('session', `[unread] markSessionRead: sessionId=${sessionId}`);
187
+ ws.emit('sessions:mark-read', { sessionId });
188
+ persistUnreadSessions();
159
189
  }
160
190
  }
161
191
 
192
+ /**
193
+ * Restore unread sessions from server state (called during initialization).
194
+ */
195
+ export function restoreUnreadSessions(saved: Record<string, string> | null): void {
196
+ if (!saved || typeof saved !== 'object') return;
197
+
198
+ const next = new Map(appState.unreadSessions);
199
+ for (const [sessionId, projectId] of Object.entries(saved)) {
200
+ if (typeof sessionId === 'string' && typeof projectId === 'string') {
201
+ next.set(sessionId, projectId);
202
+ }
203
+ }
204
+ appState.unreadSessions = next;
205
+ debug.log('session', '[unread] Restored from server:', Object.fromEntries(appState.unreadSessions));
206
+ }
207
+
162
208
  /**
163
209
  * Check if a session is unread.
164
210
  */
@@ -13,7 +13,7 @@ import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
13
13
  import ws from '$frontend/lib/utils/ws';
14
14
  import { projectState } from './projects.svelte';
15
15
  import { setupEditModeListener, restoreEditMode } from '$frontend/lib/stores/ui/edit-mode.svelte';
16
- import { markSessionUnread, markSessionRead } from '$frontend/lib/stores/core/app.svelte';
16
+ import { markSessionUnread, markSessionRead, appState } from '$frontend/lib/stores/core/app.svelte';
17
17
  import { debug } from '$shared/utils/logger';
18
18
 
19
19
  interface SessionState {
@@ -236,9 +236,20 @@ export async function loadSessions() {
236
236
  const response = await ws.http('sessions:list');
237
237
 
238
238
  if (response) {
239
- const { sessions, currentSessionId } = response;
239
+ const { sessions, currentSessionId, unreadSessionIds } = response;
240
240
  sessionState.sessions = sessions;
241
241
 
242
+ // Restore unread session state from backend
243
+ debug.log('session', '[unread] loadSessions received unreadSessionIds:', unreadSessionIds);
244
+ if (unreadSessionIds && Array.isArray(unreadSessionIds) && unreadSessionIds.length > 0) {
245
+ const next = new Map(appState.unreadSessions);
246
+ for (const { sessionId, projectId } of unreadSessionIds) {
247
+ next.set(sessionId, projectId);
248
+ }
249
+ appState.unreadSessions = next;
250
+ debug.log('session', '[unread] Restored unread sessions:', Array.from(appState.unreadSessions.entries()));
251
+ }
252
+
242
253
  // Auto-restore: find the active session for the current project
243
254
  if (!sessionState.currentSession) {
244
255
  const currentProject = projectState.currentProject;
@@ -310,7 +321,7 @@ export async function reloadSessionsForProject(): Promise<string | null> {
310
321
  try {
311
322
  const response = await ws.http('sessions:list');
312
323
  if (response) {
313
- const { sessions, currentSessionId } = response;
324
+ const { sessions, currentSessionId, unreadSessionIds } = response;
314
325
  // Merge: keep sessions from other projects, replace sessions for current project
315
326
  const currentProjectId = projectState.currentProject?.id;
316
327
  if (currentProjectId) {
@@ -321,6 +332,16 @@ export async function reloadSessionsForProject(): Promise<string | null> {
321
332
  } else {
322
333
  sessionState.sessions = sessions;
323
334
  }
335
+
336
+ // Restore unread session state from backend
337
+ if (unreadSessionIds && Array.isArray(unreadSessionIds)) {
338
+ const next = new Map(appState.unreadSessions);
339
+ for (const { sessionId, projectId } of unreadSessionIds) {
340
+ next.set(sessionId, projectId);
341
+ }
342
+ appState.unreadSessions = next;
343
+ }
344
+
324
345
  return currentSessionId || null;
325
346
  }
326
347
  } catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",