@myrialabs/clopen 0.1.9 → 0.2.0

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 (94) hide show
  1. package/README.md +23 -1
  2. package/backend/index.ts +25 -1
  3. package/backend/lib/auth/auth-service.ts +484 -0
  4. package/backend/lib/auth/index.ts +4 -0
  5. package/backend/lib/auth/permissions.ts +63 -0
  6. package/backend/lib/auth/rate-limiter.ts +145 -0
  7. package/backend/lib/auth/tokens.ts +53 -0
  8. package/backend/lib/chat/stream-manager.ts +4 -1
  9. package/backend/lib/database/migrations/024_create_users_table.ts +29 -0
  10. package/backend/lib/database/migrations/025_create_auth_sessions_table.ts +38 -0
  11. package/backend/lib/database/migrations/026_create_invite_tokens_table.ts +31 -0
  12. package/backend/lib/database/migrations/index.ts +21 -0
  13. package/backend/lib/database/queries/auth-queries.ts +201 -0
  14. package/backend/lib/database/queries/index.ts +2 -1
  15. package/backend/lib/database/queries/session-queries.ts +13 -0
  16. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  17. package/backend/lib/engine/adapters/opencode/server.ts +9 -1
  18. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  19. package/backend/lib/mcp/config.ts +13 -18
  20. package/backend/lib/mcp/index.ts +9 -0
  21. package/backend/lib/mcp/remote-server.ts +132 -0
  22. package/backend/lib/mcp/servers/helper.ts +49 -3
  23. package/backend/lib/mcp/servers/index.ts +3 -2
  24. package/backend/lib/preview/browser/browser-audio-capture.ts +20 -3
  25. package/backend/lib/preview/browser/browser-navigation-tracker.ts +3 -0
  26. package/backend/lib/preview/browser/browser-pool.ts +73 -176
  27. package/backend/lib/preview/browser/browser-preview-service.ts +3 -2
  28. package/backend/lib/preview/browser/browser-tab-manager.ts +261 -23
  29. package/backend/lib/preview/browser/browser-video-capture.ts +36 -1
  30. package/backend/lib/snapshot/helpers.ts +22 -49
  31. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  32. package/backend/lib/utils/ws.ts +65 -1
  33. package/backend/ws/auth/index.ts +17 -0
  34. package/backend/ws/auth/invites.ts +84 -0
  35. package/backend/ws/auth/login.ts +269 -0
  36. package/backend/ws/auth/status.ts +41 -0
  37. package/backend/ws/auth/users.ts +32 -0
  38. package/backend/ws/chat/stream.ts +13 -0
  39. package/backend/ws/engine/claude/accounts.ts +3 -1
  40. package/backend/ws/engine/utils.ts +38 -6
  41. package/backend/ws/index.ts +4 -4
  42. package/backend/ws/preview/browser/interact.ts +27 -5
  43. package/backend/ws/snapshot/restore.ts +111 -12
  44. package/backend/ws/snapshot/timeline.ts +56 -29
  45. package/bin/clopen.ts +56 -1
  46. package/bun.lock +113 -51
  47. package/frontend/App.svelte +47 -29
  48. package/frontend/lib/components/auth/InvitePage.svelte +215 -0
  49. package/frontend/lib/components/auth/LoginPage.svelte +129 -0
  50. package/frontend/lib/components/auth/SetupPage.svelte +1022 -0
  51. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  52. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  53. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  54. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  55. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  56. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  57. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  58. package/frontend/lib/components/common/FolderBrowser.svelte +9 -9
  59. package/frontend/lib/components/common/UpdateBanner.svelte +2 -2
  60. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  61. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  62. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  63. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +1 -1
  64. package/frontend/lib/components/preview/browser/core/mcp-handlers.svelte.ts +12 -4
  65. package/frontend/lib/components/settings/SettingsModal.svelte +50 -15
  66. package/frontend/lib/components/settings/SettingsView.svelte +21 -7
  67. package/frontend/lib/components/settings/account/AccountSettings.svelte +5 -0
  68. package/frontend/lib/components/settings/admin/InviteManagement.svelte +239 -0
  69. package/frontend/lib/components/settings/admin/UserManagement.svelte +127 -0
  70. package/frontend/lib/components/settings/general/AdvancedSettings.svelte +10 -4
  71. package/frontend/lib/components/settings/general/AuthModeSettings.svelte +229 -0
  72. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  73. package/frontend/lib/components/settings/general/UpdateSettings.svelte +5 -5
  74. package/frontend/lib/components/settings/security/SecuritySettings.svelte +10 -0
  75. package/frontend/lib/components/settings/system/SystemSettings.svelte +10 -0
  76. package/frontend/lib/components/settings/user/UserSettings.svelte +147 -74
  77. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  78. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +5 -10
  79. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  80. package/frontend/lib/services/preview/browser/browser-webcodecs.service.ts +31 -8
  81. package/frontend/lib/stores/core/sessions.svelte.ts +15 -1
  82. package/frontend/lib/stores/features/auth.svelte.ts +296 -0
  83. package/frontend/lib/stores/features/settings.svelte.ts +53 -9
  84. package/frontend/lib/stores/features/user.svelte.ts +26 -68
  85. package/frontend/lib/stores/ui/settings-modal.svelte.ts +42 -21
  86. package/frontend/lib/stores/ui/update.svelte.ts +2 -14
  87. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  88. package/package.json +8 -6
  89. package/shared/types/stores/settings.ts +16 -2
  90. package/shared/utils/logger.ts +1 -0
  91. package/shared/utils/ws-client.ts +30 -13
  92. package/shared/utils/ws-server.ts +42 -4
  93. package/backend/lib/mcp/stdio-server.ts +0 -103
  94. package/backend/ws/mcp/index.ts +0 -61
@@ -1,10 +1,10 @@
1
1
  <script lang="ts">
2
- import { settings, updateSettings } from '$frontend/lib/stores/features/settings.svelte';
2
+ import { systemSettings, updateSystemSettings } from '$frontend/lib/stores/features/settings.svelte';
3
3
  import { updateState, checkForUpdate, runUpdate } from '$frontend/lib/stores/ui/update.svelte';
4
4
  import Icon from '../../common/Icon.svelte';
5
5
 
6
6
  function toggleAutoUpdate() {
7
- updateSettings({ autoUpdate: !settings.autoUpdate });
7
+ updateSystemSettings({ autoUpdate: !systemSettings.autoUpdate });
8
8
  }
9
9
 
10
10
  function handleCheckNow() {
@@ -108,14 +108,14 @@
108
108
  <button
109
109
  type="button"
110
110
  role="switch"
111
- aria-checked={settings.autoUpdate}
111
+ aria-checked={systemSettings.autoUpdate}
112
112
  onclick={toggleAutoUpdate}
113
113
  class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500/30
114
- {settings.autoUpdate ? 'bg-violet-600' : 'bg-slate-300 dark:bg-slate-600'}"
114
+ {systemSettings.autoUpdate ? 'bg-violet-600' : 'bg-slate-300 dark:bg-slate-600'}"
115
115
  >
116
116
  <span
117
117
  class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out
118
- {settings.autoUpdate ? 'translate-x-5' : 'translate-x-0'}"
118
+ {systemSettings.autoUpdate ? 'translate-x-5' : 'translate-x-0'}"
119
119
  ></span>
120
120
  </button>
121
121
  </div>
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import AuthModeSettings from '../general/AuthModeSettings.svelte';
3
+ import AdvancedSettings from '../general/AdvancedSettings.svelte';
4
+ </script>
5
+
6
+ <AuthModeSettings />
7
+
8
+ <div class="mt-6">
9
+ <AdvancedSettings />
10
+ </div>
@@ -0,0 +1,10 @@
1
+ <script lang="ts">
2
+ import UpdateSettings from '../general/UpdateSettings.svelte';
3
+ import DataManagementSettings from '../general/DataManagementSettings.svelte';
4
+ </script>
5
+
6
+ <UpdateSettings />
7
+
8
+ <div class="mt-6">
9
+ <DataManagementSettings />
10
+ </div>
@@ -1,121 +1,127 @@
1
1
  <script lang="ts">
2
- import { userStore } from '$frontend/lib/stores/features/user.svelte';
2
+ import { authStore } from '$frontend/lib/stores/features/auth.svelte';
3
+ import { systemSettings } from '$frontend/lib/stores/features/settings.svelte';
3
4
  import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
4
5
  import Icon from '../../common/Icon.svelte';
6
+ import Dialog from '../../common/Dialog.svelte';
5
7
  import { debug } from '$shared/utils/logger';
6
8
 
9
+ const isNoAuth = $derived(systemSettings.authMode === 'none');
10
+
7
11
  // State
8
12
  let userNameInput = $state('');
9
13
  let isEditing = $state(false);
10
14
  let isSaving = $state(false);
11
15
 
16
+ // PAT state
17
+ let showPAT = $state(false);
18
+ let currentPAT = $state('');
19
+ let isRegeneratingPAT = $state(false);
20
+ let showRegenerateConfirm = $state(false);
21
+
22
+ const user = $derived(authStore.currentUser);
23
+
12
24
  // Update input when user changes
13
25
  $effect(() => {
14
- if (userStore.currentUser?.name) {
15
- userNameInput = userStore.currentUser.name;
26
+ if (user?.name) {
27
+ userNameInput = user.name;
16
28
  }
17
29
  });
18
30
 
19
- // Handle save user name
20
31
  async function saveUserName() {
21
32
  if (!userNameInput.trim()) {
22
- addNotification({
23
- type: 'error',
24
- title: 'Validation Error',
25
- message: 'Name cannot be empty'
26
- });
33
+ addNotification({ type: 'error', title: 'Validation Error', message: 'Name cannot be empty' });
27
34
  return;
28
35
  }
29
36
 
30
37
  isSaving = true;
31
-
32
38
  try {
33
- const success = await userStore.updateName(userNameInput.trim());
34
-
35
- if (success) {
36
- isEditing = false;
37
- addNotification({
38
- type: 'success',
39
- title: 'Updated',
40
- message: 'Display name updated successfully'
41
- });
42
- } else {
43
- addNotification({
44
- type: 'error',
45
- title: 'Error',
46
- message: 'Failed to update user name'
47
- });
48
- }
39
+ await authStore.updateName(userNameInput.trim());
40
+ isEditing = false;
41
+ addNotification({ type: 'success', title: 'Updated', message: 'Display name updated' });
49
42
  } catch (error) {
50
43
  debug.error('settings', 'Error updating user name:', error);
51
- addNotification({
52
- type: 'error',
53
- title: 'Error',
54
- message: 'An error occurred while updating user name'
55
- });
44
+ addNotification({ type: 'error', title: 'Error', message: 'Failed to update display name' });
56
45
  } finally {
57
46
  isSaving = false;
58
47
  }
59
48
  }
60
49
 
61
- // Handle cancel edit
62
50
  function cancelEdit() {
63
- userNameInput = userStore.currentUser?.name || '';
51
+ userNameInput = user?.name || '';
64
52
  isEditing = false;
65
53
  }
66
54
 
67
- // Handle start edit
68
55
  function startEdit() {
69
56
  isEditing = true;
70
57
  }
58
+
59
+ async function regeneratePAT() {
60
+ isRegeneratingPAT = true;
61
+ try {
62
+ const pat = await authStore.regeneratePAT();
63
+ currentPAT = pat;
64
+ showPAT = true;
65
+ showRegenerateConfirm = false;
66
+ addNotification({ type: 'success', title: 'Regenerated', message: 'Personal Access Token has been regenerated' });
67
+ } catch (error) {
68
+ debug.error('settings', 'Error regenerating PAT:', error);
69
+ addNotification({ type: 'error', title: 'Error', message: 'Failed to regenerate token' });
70
+ } finally {
71
+ isRegeneratingPAT = false;
72
+ }
73
+ }
74
+
75
+ function copyPAT() {
76
+ navigator.clipboard.writeText(currentPAT);
77
+ addNotification({ type: 'success', title: 'Copied', message: 'Token copied to clipboard' });
78
+ }
79
+
80
+ async function handleLogout() {
81
+ await authStore.logout();
82
+ }
71
83
  </script>
72
84
 
73
85
  <div class="py-1">
74
86
  <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">User Profile</h3>
75
87
  <p class="text-sm text-slate-600 dark:text-slate-500 mb-5">
76
- Manage your identity and display preferences
88
+ Manage your identity and access
77
89
  </p>
78
90
 
79
- {#if !userStore.currentUser}
91
+ {#if !user}
80
92
  <div class="flex items-center justify-center gap-3 py-10 text-slate-600 dark:text-slate-500 text-sm">
81
- <div
82
- class="w-5 h-5 border-2 border-violet-500/20 border-t-violet-600 rounded-full animate-spin"
83
- ></div>
93
+ <div class="w-5 h-5 border-2 border-violet-500/20 border-t-violet-600 rounded-full animate-spin"></div>
84
94
  <span>Loading user settings...</span>
85
95
  </div>
86
96
  {:else}
87
97
  <div class="flex flex-col gap-4">
88
98
  <!-- Current User Card -->
89
- <div
90
- class="flex items-center gap-3.5 p-4.5 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/10 dark:to-purple-500/8 border border-violet-500/20 rounded-xl"
91
- >
99
+ <div class="flex items-center gap-3.5 p-4.5 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/10 dark:to-purple-500/8 border border-violet-500/20 rounded-xl">
92
100
  <div
93
101
  class="flex items-center justify-center w-12 h-12 rounded-xl text-lg font-bold text-white shrink-0"
94
- style="background-color: {userStore.currentUser?.color || '#7c3aed'}"
102
+ style="background-color: {user.color || '#7c3aed'}"
95
103
  >
96
- {userStore.currentUser?.avatar || 'U'}
104
+ {user.avatar || 'U'}
97
105
  </div>
98
106
  <div class="flex-1 min-w-0">
99
107
  <div class="text-base font-semibold text-slate-900 dark:text-slate-100 mb-0.5">
100
- {userStore.currentUser?.name || 'Anonymous User'}
108
+ {user.name}
109
+ </div>
110
+ <div class="text-xs text-slate-600 dark:text-slate-500">
111
+ {user.role === 'admin' ? 'Administrator' : 'Member'}
101
112
  </div>
102
- <div class="text-xs text-slate-600 dark:text-slate-500">Anonymous user identity</div>
103
113
  </div>
104
- <div
105
- class="flex items-center gap-1.5 py-1.5 px-3 bg-emerald-500/15 rounded-full text-xs font-medium text-emerald-500"
106
- >
107
- <span class="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse"></span>
108
- <span>Active</span>
114
+ <div class="flex items-center gap-2">
115
+ <span class="inline-flex items-center gap-1.5 py-1.5 px-3 bg-{user.role === 'admin' ? 'violet' : 'emerald'}-500/15 rounded-full text-xs font-medium text-{user.role === 'admin' ? 'violet' : 'emerald'}-500">
116
+ <Icon name="lucide:{user.role === 'admin' ? 'shield' : 'user'}" class="w-3 h-3" />
117
+ {user.role === 'admin' ? 'Admin' : 'Member'}
118
+ </span>
109
119
  </div>
110
120
  </div>
111
121
 
112
122
  <!-- Edit Display Name -->
113
- <div
114
- class="p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl"
115
- >
116
- <div
117
- class="flex items-center gap-2 text-sm font-semibold text-slate-500 mb-3"
118
- >
123
+ <div class="p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl">
124
+ <div class="flex items-center gap-2 text-sm font-semibold text-slate-500 mb-3">
119
125
  <Icon name="lucide:pencil" class="w-4 h-4 opacity-70" />
120
126
  <span>Display Name</span>
121
127
  </div>
@@ -136,9 +142,7 @@
136
142
  disabled={!userNameInput.trim() || isSaving}
137
143
  >
138
144
  {#if isSaving}
139
- <div
140
- class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"
141
- ></div>
145
+ <div class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
142
146
  Saving...
143
147
  {:else}
144
148
  <Icon name="lucide:check" class="w-4 h-4" />
@@ -158,7 +162,7 @@
158
162
  {:else}
159
163
  <div class="flex items-center justify-between gap-3">
160
164
  <div class="text-sm text-slate-900 dark:text-slate-100">
161
- {userStore.currentUser?.name || 'Not set'}
165
+ {user.name}
162
166
  </div>
163
167
  <button
164
168
  type="button"
@@ -172,26 +176,95 @@
172
176
  {/if}
173
177
  </div>
174
178
 
179
+ {#if !isNoAuth}
180
+ <!-- Personal Access Token -->
181
+ <div class="p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl">
182
+ <div class="flex items-center gap-2 text-sm font-semibold text-slate-500 mb-3">
183
+ <Icon name="lucide:key-round" class="w-4 h-4 opacity-70" />
184
+ <span>Personal Access Token</span>
185
+ </div>
186
+ <p class="text-xs text-slate-600 dark:text-slate-500 mb-3">
187
+ Use this token to log in from other devices. Keep it secret.
188
+ </p>
189
+
190
+ {#if showPAT && currentPAT}
191
+ <div class="flex flex-col gap-2 mb-3">
192
+ <div class="flex items-center gap-2">
193
+ <code class="flex-1 py-2.5 px-3.5 bg-slate-50 dark:bg-slate-900/80 border border-emerald-500/30 rounded-lg font-mono text-xs text-slate-700 dark:text-slate-300 break-all">
194
+ {currentPAT}
195
+ </code>
196
+ <button
197
+ type="button"
198
+ onclick={copyPAT}
199
+ class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 transition-all"
200
+ title="Copy token"
201
+ >
202
+ <Icon name="lucide:copy" class="w-4 h-4" />
203
+ </button>
204
+ </div>
205
+ <div class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
206
+ <Icon name="lucide:triangle-alert" class="w-3.5 h-3.5" />
207
+ <span>This token is shown only once. Copy and store it securely.</span>
208
+ </div>
209
+ </div>
210
+ {/if}
211
+
212
+ <button
213
+ type="button"
214
+ onclick={() => { showRegenerateConfirm = true; }}
215
+ disabled={isRegeneratingPAT}
216
+ class="inline-flex items-center gap-1.5 py-2 px-3.5 bg-amber-500/10 border border-amber-500/20 rounded-lg text-amber-600 dark:text-amber-400 text-xs font-semibold cursor-pointer transition-all duration-150 hover:bg-amber-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
217
+ >
218
+ {#if isRegeneratingPAT}
219
+ <div class="w-3.5 h-3.5 border-2 border-amber-600/30 border-t-amber-600 rounded-full animate-spin"></div>
220
+ Regenerating...
221
+ {:else}
222
+ <Icon name="lucide:refresh-cw" class="w-3.5 h-3.5" />
223
+ Regenerate Token
224
+ {/if}
225
+ </button>
226
+ </div>
227
+ {/if}
228
+
175
229
  <!-- User ID -->
176
- <div
177
- class="p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl"
178
- >
179
- <div
180
- class="flex items-center gap-2 text-sm font-semibold text-slate-500 mb-3"
181
- >
230
+ <div class="p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl">
231
+ <div class="flex items-center gap-2 text-sm font-semibold text-slate-500 mb-3">
182
232
  <Icon name="lucide:fingerprint" class="w-4 h-4 opacity-70" />
183
233
  <span>User ID</span>
184
234
  </div>
185
235
  <div class="flex flex-col gap-1.5">
186
- <code
187
- class="py-2.5 px-3.5 bg-slate-50 dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-lg font-mono text-xs text-slate-500 break-all"
188
- >{userStore.currentUser?.id || 'Not available'}</code
189
- >
190
- <span class="text-xs text-slate-600 dark:text-slate-500"
191
- >Unique identifier for this session</span
192
- >
236
+ <code class="py-2.5 px-3.5 bg-slate-50 dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 rounded-lg font-mono text-xs text-slate-500 break-all">
237
+ {user.id}
238
+ </code>
193
239
  </div>
194
240
  </div>
241
+
242
+ {#if !isNoAuth}
243
+ <!-- Logout -->
244
+ <div class="pt-2">
245
+ <button
246
+ type="button"
247
+ onclick={handleLogout}
248
+ class="inline-flex items-center gap-1.5 py-2.5 px-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-600 dark:text-red-400 text-sm font-semibold cursor-pointer transition-all duration-150 hover:bg-red-500/20 hover:border-red-600/40"
249
+ >
250
+ <Icon name="lucide:log-out" class="w-4 h-4" />
251
+ Sign Out
252
+ </button>
253
+ </div>
254
+ {/if}
195
255
  </div>
196
256
  {/if}
197
257
  </div>
258
+
259
+ <!-- Regenerate PAT Confirmation Dialog -->
260
+ <Dialog
261
+ bind:isOpen={showRegenerateConfirm}
262
+ onClose={() => { showRegenerateConfirm = false; }}
263
+ title="Regenerate Access Token"
264
+ type="warning"
265
+ message="This will invalidate your current Personal Access Token. Any devices using the old token will need to log in again with the new one. Continue?"
266
+ confirmText="Regenerate"
267
+ cancelText="Cancel"
268
+ showCancel={true}
269
+ onConfirm={regeneratePAT}
270
+ />
@@ -277,7 +277,7 @@
277
277
  >
278
278
  <Icon name="lucide:history" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
279
279
  </button>
280
- {#if sessionState.messages.length > 0}
280
+ {#if sessionState.messages.length > 0 || sessionState.hasMessageHistory}
281
281
  <button
282
282
  type="button"
283
283
  class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
@@ -26,8 +26,7 @@
26
26
  import { initializeProjects } from '$frontend/lib/stores/core/projects.svelte';
27
27
  import { initializeSessions } from '$frontend/lib/stores/core/sessions.svelte';
28
28
  import { initializeNotifications } from '$frontend/lib/stores/ui/notification.svelte';
29
- import { applyServerSettings } from '$frontend/lib/stores/features/settings.svelte';
30
- import { userStore } from '$frontend/lib/stores/features/user.svelte';
29
+ import { applyServerSettings, loadSystemSettings } from '$frontend/lib/stores/features/settings.svelte';
31
30
  import { initPresence } from '$frontend/lib/stores/core/presence.svelte';
32
31
  import ws from '$frontend/lib/utils/ws';
33
32
  import { debug } from '$shared/utils/logger';
@@ -78,14 +77,9 @@
78
77
  initializeNotifications();
79
78
  initializeWorkspace();
80
79
 
81
- // Step 2: Initialize user + wait for WebSocket in parallel
82
- // userStore.initialize() reads localStorage (fast) and sets WS context locally.
83
- // waitUntilConnected() waits for WS to connect and sync any pending context.
80
+ // Step 2: WebSocket is already connected (auth completed before this mounts)
84
81
  setProgress(20, 'Connecting...');
85
- await Promise.all([
86
- userStore.initialize(),
87
- ws.waitUntilConnected(10000)
88
- ]);
82
+ await ws.waitUntilConnected(10000);
89
83
 
90
84
  // Step 3: Restore user state from server
91
85
  setProgress(30, 'Restoring state...');
@@ -97,13 +91,14 @@
97
91
  debug.warn('workspace', 'Failed to restore server state, using defaults:', err);
98
92
  }
99
93
 
100
- // Step 4: Apply restored state + setup presence (sync operations)
94
+ // Step 4: Apply restored state + load system settings + setup presence
101
95
  setProgress(40);
102
96
  if (serverState?.settings) {
103
97
  applyServerSettings(serverState.settings);
104
98
  }
105
99
  restoreLastView(serverState?.lastView);
106
100
  restoreUnreadSessions(serverState?.unreadSessions);
101
+ await loadSystemSettings();
107
102
  initPresence();
108
103
 
109
104
  // Step 5: Load projects (with server-restored currentProjectId)
@@ -24,10 +24,11 @@
24
24
 
25
25
  const { showMobileHeader = false }: Props = $props();
26
26
 
27
- // Welcome state - don't show during restoration
27
+ // Welcome state - don't show during restoration or when session has history (restored to initial)
28
28
  const isWelcomeState = $derived(
29
29
  sessionState.messages.length === 0 &&
30
- !appState.isRestoring
30
+ !appState.isRestoring &&
31
+ !sessionState.hasMessageHistory
31
32
  );
32
33
 
33
34
  // Check if we should show input (not during restoration)
@@ -175,6 +175,17 @@ export class BrowserWebCodecsService {
175
175
  return false;
176
176
  }
177
177
 
178
+ // Pre-initialize AudioContext NOW, during user gesture context.
179
+ // Browsers require a user gesture to start AudioContext — creating it
180
+ // later (e.g. when first audio chunk arrives) results in a permanently
181
+ // suspended context that never plays audio.
182
+ if (!this.audioContext || this.audioContext.state === 'closed') {
183
+ this.audioContext = new AudioContext({ sampleRate: 48000 });
184
+ }
185
+ if (this.audioContext.state === 'suspended') {
186
+ await this.audioContext.resume().catch(() => {});
187
+ }
188
+
178
189
  // Clean up any existing connection
179
190
  if (this.peerConnection || this.isConnected || this.sessionId) {
180
191
  debug.log('webcodecs', 'Cleaning up previous connection');
@@ -546,7 +557,7 @@ export class BrowserWebCodecsService {
546
557
  private async initAudioDecoder(): Promise<void> {
547
558
  this.audioCodecConfig = {
548
559
  codec: 'opus',
549
- sampleRate: 44100,
560
+ sampleRate: 48000,
550
561
  numberOfChannels: 2
551
562
  };
552
563
 
@@ -580,8 +591,17 @@ export class BrowserWebCodecsService {
580
591
  */
581
592
  private async initAudioContext(): Promise<void> {
582
593
  try {
583
- this.audioContext = new AudioContext({ sampleRate: 44100 });
584
- debug.log('webcodecs', 'AudioContext initialized');
594
+ // Reuse AudioContext created in startStreaming (user gesture context)
595
+ if (!this.audioContext || this.audioContext.state === 'closed') {
596
+ this.audioContext = new AudioContext({ sampleRate: 48000 });
597
+ }
598
+
599
+ // Resume if suspended (may happen without user gesture)
600
+ if (this.audioContext.state === 'suspended') {
601
+ await this.audioContext.resume();
602
+ }
603
+
604
+ debug.log('webcodecs', `AudioContext initialized (state: ${this.audioContext.state})`);
585
605
  } catch (error) {
586
606
  debug.error('webcodecs', 'AudioContext init error:', error);
587
607
  }
@@ -737,6 +757,11 @@ export class BrowserWebCodecsService {
737
757
  private playAudioFrame(audioData: AudioData): void {
738
758
  if (!this.audioContext) return;
739
759
 
760
+ // Safety net: resume AudioContext if it somehow got suspended
761
+ if (this.audioContext.state === 'suspended') {
762
+ this.audioContext.resume().catch(() => {});
763
+ }
764
+
740
765
  try {
741
766
  // Create AudioBuffer
742
767
  const buffer = this.audioContext.createBuffer(
@@ -1151,11 +1176,9 @@ export class BrowserWebCodecsService {
1151
1176
  this.audioDecoder = null;
1152
1177
  }
1153
1178
 
1154
- // Close audio context
1155
- if (this.audioContext && this.audioContext.state !== 'closed') {
1156
- await this.audioContext.close().catch(() => {});
1157
- this.audioContext = null;
1158
- }
1179
+ // Keep AudioContext alive across reconnections — it was created during
1180
+ // user gesture in startStreaming() and closing it means we can't resume
1181
+ // without another user gesture. Just reset playback state below.
1159
1182
 
1160
1183
  // Close data channel
1161
1184
  if (this.dataChannel) {
@@ -22,6 +22,8 @@ interface SessionState {
22
22
  messages: SDKMessageFormatter[];
23
23
  isLoading: boolean;
24
24
  error: string | null;
25
+ /** True if the current session has message history (even if HEAD is null after restore to initial) */
26
+ hasMessageHistory: boolean;
25
27
  }
26
28
 
27
29
  // Session state using Svelte 5 runes
@@ -30,7 +32,8 @@ export const sessionState = $state<SessionState>({
30
32
  currentSession: null,
31
33
  messages: [],
32
34
  isLoading: false,
33
- error: null
35
+ error: null,
36
+ hasMessageHistory: false
34
37
  });
35
38
 
36
39
  // ========================================
@@ -206,6 +209,7 @@ export function updateMessages(messages: SDKMessageFormatter[]) {
206
209
 
207
210
  export function clearMessages() {
208
211
  sessionState.messages = [];
212
+ sessionState.hasMessageHistory = false;
209
213
  }
210
214
 
211
215
  export async function loadMessagesForSession(sessionId: string) {
@@ -215,12 +219,22 @@ export async function loadMessagesForSession(sessionId: string) {
215
219
  if (response && Array.isArray(response)) {
216
220
  // Messages from server already have correct SDKMessageFormatter shape with metadata
217
221
  sessionState.messages = response as SDKMessageFormatter[];
222
+
223
+ if (response.length > 0) {
224
+ sessionState.hasMessageHistory = true;
225
+ } else {
226
+ // HEAD might be null (restored to initial) — check if session has any messages at all
227
+ const allResponse = await ws.http('messages:list', { session_id: sessionId, include_all: true });
228
+ sessionState.hasMessageHistory = Array.isArray(allResponse) && allResponse.length > 0;
229
+ }
218
230
  } else {
219
231
  sessionState.messages = [];
232
+ sessionState.hasMessageHistory = false;
220
233
  }
221
234
  } catch (error) {
222
235
  debug.error('session', 'Error loading messages:', error);
223
236
  sessionState.messages = [];
237
+ sessionState.hasMessageHistory = false;
224
238
  }
225
239
  }
226
240