@myrialabs/clopen 0.1.3 โ†’ 0.1.5

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 (49) hide show
  1. package/CONTRIBUTING.md +40 -355
  2. package/README.md +46 -113
  3. package/backend/lib/chat/stream-manager.ts +8 -0
  4. package/backend/lib/database/migrations/022_add_snapshot_changes_column.ts +35 -0
  5. package/backend/lib/database/migrations/index.ts +7 -0
  6. package/backend/lib/database/queries/snapshot-queries.ts +7 -4
  7. package/backend/lib/files/file-watcher.ts +34 -0
  8. package/backend/lib/mcp/config.ts +7 -3
  9. package/backend/lib/mcp/servers/helper.ts +25 -14
  10. package/backend/lib/mcp/servers/index.ts +7 -2
  11. package/backend/lib/project/status-manager.ts +6 -4
  12. package/backend/lib/snapshot/snapshot-service.ts +471 -316
  13. package/backend/lib/terminal/pty-session-manager.ts +1 -32
  14. package/backend/ws/chat/stream.ts +45 -2
  15. package/backend/ws/snapshot/restore.ts +77 -67
  16. package/frontend/lib/components/chat/ChatInterface.svelte +21 -14
  17. package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
  18. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
  19. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +24 -12
  20. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
  21. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
  22. package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
  23. package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
  24. package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
  25. package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
  26. package/frontend/lib/components/git/DiffViewer.svelte +16 -2
  27. package/frontend/lib/components/history/HistoryModal.svelte +3 -4
  28. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
  29. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +1 -1
  30. package/frontend/lib/components/terminal/Terminal.svelte +1 -7
  31. package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
  32. package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
  33. package/frontend/lib/components/workspace/PanelHeader.svelte +623 -616
  34. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  35. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
  36. package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
  37. package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
  38. package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
  39. package/frontend/lib/stores/core/presence.svelte.ts +63 -1
  40. package/frontend/lib/stores/features/settings.svelte.ts +9 -1
  41. package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
  42. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
  43. package/package.json +1 -1
  44. package/shared/types/database/schema.ts +18 -0
  45. package/shared/types/stores/settings.ts +2 -0
  46. package/scripts/pre-publish-check.sh +0 -142
  47. package/scripts/setup-hooks.sh +0 -134
  48. package/scripts/validate-branch-name.sh +0 -47
  49. package/scripts/validate-commit-msg.sh +0 -42
@@ -28,11 +28,9 @@ export interface PtySession {
28
28
 
29
29
  class PtySessionManager {
30
30
  private sessions = new Map<string, PtySession>();
31
- private cleanupInterval: Timer | null = null;
32
31
 
33
32
  constructor() {
34
- // Start cleanup interval (remove sessions inactive for >1 hour)
35
- this.startCleanupInterval();
33
+ // Sessions stay alive indefinitely until user closes the terminal tab
36
34
  }
37
35
 
38
36
  /**
@@ -323,41 +321,12 @@ class PtySessionManager {
323
321
  return Array.from(this.sessions.values());
324
322
  }
325
323
 
326
- /**
327
- * Start cleanup interval
328
- */
329
- private startCleanupInterval() {
330
- // Run every 15 minutes
331
- this.cleanupInterval = setInterval(() => {
332
- this.cleanupInactiveSessions();
333
- }, 15 * 60 * 1000);
334
- }
335
-
336
- /**
337
- * Cleanup inactive sessions (>1 hour no activity)
338
- */
339
- private cleanupInactiveSessions() {
340
- const now = new Date();
341
- const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
342
-
343
- for (const [sessionId, session] of this.sessions.entries()) {
344
- if (session.lastActivityAt < oneHourAgo) {
345
- debug.log('terminal', `๐Ÿงน Cleaning up inactive session: ${sessionId}`);
346
- this.killSession(sessionId);
347
- }
348
- }
349
- }
350
-
351
324
  /**
352
325
  * Cleanup all sessions (on shutdown)
353
326
  */
354
327
  dispose() {
355
328
  debug.log('terminal', '๐Ÿงน Disposing all PTY sessions');
356
329
 
357
- if (this.cleanupInterval) {
358
- clearInterval(this.cleanupInterval);
359
- }
360
-
361
330
  for (const sessionId of this.sessions.keys()) {
362
331
  this.killSession(sessionId, 'SIGKILL');
363
332
  }
@@ -158,7 +158,7 @@ export const streamHandler = createRouter()
158
158
  broadcastPresence().catch(() => {});
159
159
  break;
160
160
 
161
- case 'message':
161
+ case 'message': {
162
162
  ws.emit.chatSession(chatSessionId, 'chat:message', {
163
163
  processId: event.processId,
164
164
  message: event.data.message,
@@ -171,7 +171,26 @@ export const streamHandler = createRouter()
171
171
  engine: event.data.engine,
172
172
  seq: event.seq
173
173
  });
174
+ // Broadcast presence when waiting-input state may change
175
+ // (AskUserQuestion tool_use arrives or tool_result clears it)
176
+ const msgContent = Array.isArray(event.data.message?.message?.content) ? event.data.message.message.content : [];
177
+ const askToolUse = msgContent.find((item: any) =>
178
+ item.type === 'tool_use' && item.name === 'AskUserQuestion'
179
+ );
180
+ if (askToolUse || msgContent.some((item: any) => item.type === 'tool_result')) {
181
+ broadcastPresence().catch(() => {});
182
+ }
183
+ // Notify all project members when AskUserQuestion arrives (sound + push)
184
+ if (askToolUse && projectId) {
185
+ ws.emit.projectMembers(projectId, 'chat:waiting-input', {
186
+ projectId,
187
+ chatSessionId,
188
+ toolUseId: askToolUse.id,
189
+ timestamp: event.data.timestamp || new Date().toISOString()
190
+ });
191
+ }
174
192
  break;
193
+ }
175
194
 
176
195
  case 'partial':
177
196
  ws.emit.chatSession(chatSessionId, 'chat:partial', {
@@ -281,7 +300,7 @@ export const streamHandler = createRouter()
281
300
  });
282
301
  break;
283
302
 
284
- case 'message':
303
+ case 'message': {
285
304
  ws.emit.chatSession(chatSessionId, 'chat:message', {
286
305
  processId: event.processId,
287
306
  message: event.data.message,
@@ -294,7 +313,24 @@ export const streamHandler = createRouter()
294
313
  engine: event.data.engine,
295
314
  seq: event.seq
296
315
  });
316
+ // Broadcast presence when waiting-input state may change
317
+ const reconnMsgContent = Array.isArray(event.data.message?.message?.content) ? event.data.message.message.content : [];
318
+ const reconnAskToolUse = reconnMsgContent.find((item: any) =>
319
+ item.type === 'tool_use' && item.name === 'AskUserQuestion'
320
+ );
321
+ if (reconnAskToolUse || reconnMsgContent.some((item: any) => item.type === 'tool_result')) {
322
+ broadcastPresence().catch(() => {});
323
+ }
324
+ if (reconnAskToolUse && projectId) {
325
+ ws.emit.projectMembers(projectId, 'chat:waiting-input', {
326
+ projectId,
327
+ chatSessionId,
328
+ toolUseId: reconnAskToolUse.id,
329
+ timestamp: event.data.timestamp || new Date().toISOString()
330
+ });
331
+ }
297
332
  break;
333
+ }
298
334
 
299
335
  case 'partial':
300
336
  ws.emit.chatSession(chatSessionId, 'chat:partial', {
@@ -746,4 +782,11 @@ export const streamHandler = createRouter()
746
782
  chatSessionId: t.String(),
747
783
  status: t.Union([t.Literal('completed'), t.Literal('error'), t.Literal('cancelled')]),
748
784
  timestamp: t.String()
785
+ }))
786
+
787
+ .emit('chat:waiting-input', t.Object({
788
+ projectId: t.String(),
789
+ chatSessionId: t.String(),
790
+ toolUseId: t.String(),
791
+ timestamp: t.String()
749
792
  }));
@@ -1,14 +1,14 @@
1
1
  /**
2
- * Snapshot Restore Handler (Unified - replaces undo.ts and redo.ts)
2
+ * Snapshot Restore Handler (v2 - Session-Scoped with Conflict Detection)
3
3
  *
4
- * Single restore operation that moves HEAD to any checkpoint.
5
- * Works identically regardless of whether the target is on the
6
- * current path, a branch, or an orphaned node.
4
+ * Two-phase restore:
5
+ * 1. Check conflicts: detect files modified by other sessions
6
+ * 2. Execute restore: undo session-scoped changes, respecting conflict resolutions
7
7
  */
8
8
 
9
9
  import { t } from 'elysia';
10
10
  import { createRouter } from '$shared/utils/ws-server';
11
- import { messageQueries, sessionQueries, projectQueries, snapshotQueries, checkpointQueries } from '../../lib/database/queries';
11
+ import { messageQueries, sessionQueries, projectQueries, checkpointQueries } from '../../lib/database/queries';
12
12
  import { snapshotService } from '../../lib/snapshot/snapshot-service';
13
13
  import { debug } from '$shared/utils/logger';
14
14
  import {
@@ -19,20 +19,69 @@ import {
19
19
  import { ws } from '$backend/lib/utils/ws';
20
20
 
21
21
  export const restoreHandler = createRouter()
22
- .http('snapshot:restore', {
22
+ /**
23
+ * Phase 1: Check for conflicts before restore.
24
+ * Frontend calls this first, and if conflicts exist, shows a modal.
25
+ */
26
+ .http('snapshot:check-conflicts', {
23
27
  data: t.Object({
24
28
  messageId: t.String(),
25
29
  sessionId: t.String()
26
30
  }),
31
+ response: t.Object({
32
+ hasConflicts: t.Boolean(),
33
+ conflicts: t.Array(t.Object({
34
+ filepath: t.String(),
35
+ modifiedBySessionId: t.String(),
36
+ modifiedBySnapshotId: t.String(),
37
+ modifiedAt: t.String(),
38
+ restoreContent: t.Optional(t.String()),
39
+ currentContent: t.Optional(t.String())
40
+ })),
41
+ checkpointsToUndo: t.Array(t.String())
42
+ })
43
+ }, async ({ data }) => {
44
+ const { messageId, sessionId } = data;
45
+
46
+ debug.log('snapshot', `Checking restore conflicts for checkpoint ${messageId} in session ${sessionId}`);
47
+
48
+ // Resolve project path for reading file contents
49
+ let projectPath: string | undefined;
50
+ const session = sessionQueries.getById(sessionId);
51
+ if (session) {
52
+ const project = projectQueries.getById(session.project_id);
53
+ if (project) projectPath = project.path;
54
+ }
55
+
56
+ const result = await snapshotService.checkRestoreConflicts(sessionId, messageId, projectPath);
57
+
58
+ debug.log('snapshot', `Conflict check: ${result.conflicts.length} conflicts, ${result.checkpointsToUndo.length} checkpoints to undo`);
59
+
60
+ return result;
61
+ })
62
+
63
+ /**
64
+ * Phase 2: Execute restore with optional conflict resolutions.
65
+ */
66
+ .http('snapshot:restore', {
67
+ data: t.Object({
68
+ messageId: t.String(),
69
+ sessionId: t.String(),
70
+ conflictResolutions: t.Optional(t.Record(t.String(), t.Union([
71
+ t.Literal('restore'),
72
+ t.Literal('keep')
73
+ ])))
74
+ }),
27
75
  response: t.Object({
28
76
  restoredTo: t.Object({
29
77
  messageId: t.String(),
30
78
  timestamp: t.String()
31
79
  }),
32
- filesRestored: t.Optional(t.Number())
80
+ filesRestored: t.Optional(t.Number()),
81
+ filesSkipped: t.Optional(t.Number())
33
82
  })
34
- }, async ({ data, conn }) => {
35
- const { messageId, sessionId } = data;
83
+ }, async ({ data }) => {
84
+ const { messageId, sessionId, conflictResolutions } = data;
36
85
 
37
86
  debug.log('snapshot', 'RESTORE - Moving HEAD to checkpoint');
38
87
  debug.log('snapshot', `Target checkpoint: ${messageId}`);
@@ -54,36 +103,13 @@ export const restoreHandler = createRouter()
54
103
 
55
104
  // 4. Find session end (last message of checkpoint's session)
56
105
  const sessionEnd = findSessionEnd(checkpointMessage, allMessages);
57
- const isSameAsCheckpoint = sessionEnd.id === messageId;
58
- debug.log('snapshot', `Session end: ${sessionEnd.id} (checkpoint: ${messageId}, sameAsCheckpoint: ${isSameAsCheckpoint})`);
59
-
60
- if (isSameAsCheckpoint) {
61
- debug.warn('snapshot', 'โš ๏ธ Session end is the SAME as checkpoint message! This means no assistant children were found.');
62
- debug.warn('snapshot', `Checkpoint parent_message_id: ${checkpointMessage.parent_message_id}`);
63
- // List all direct children of this checkpoint to debug
64
- const directChildren = allMessages.filter(m => m.parent_message_id === messageId);
65
- debug.warn('snapshot', `Direct children of checkpoint: ${directChildren.length}`);
66
- for (const child of directChildren) {
67
- try {
68
- const sdk = JSON.parse(child.sdk_message);
69
- debug.warn('snapshot', ` child=${child.id.slice(0, 8)} type=${sdk.type} ts=${child.timestamp}`);
70
- } catch {
71
- debug.warn('snapshot', ` child=${child.id.slice(0, 8)} (parse error)`);
72
- }
73
- }
74
- }
75
-
76
- // If session end is already HEAD, nothing to do (but still restore files)
77
- if (sessionEnd.id === currentHead) {
78
- debug.log('snapshot', 'Already at this checkpoint HEAD');
79
- }
106
+ debug.log('snapshot', `Session end: ${sessionEnd.id}`);
80
107
 
81
108
  // 5. Update HEAD to session end
82
109
  sessionQueries.updateHead(sessionId, sessionEnd.id);
83
110
  debug.log('snapshot', `HEAD updated to: ${sessionEnd.id}`);
84
111
 
85
112
  // 5b. Update latest_sdk_session_id so resume works correctly
86
- // Walk backward from sessionEnd to find the last assistant message with session_id
87
113
  {
88
114
  let walkId: string | null = sessionEnd.id;
89
115
  let foundSdkSessionId: string | null = null;
@@ -107,8 +133,6 @@ export const restoreHandler = createRouter()
107
133
  if (foundSdkSessionId) {
108
134
  sessionQueries.updateLatestSdkSessionId(sessionId, foundSdkSessionId);
109
135
  debug.log('snapshot', `latest_sdk_session_id updated to: ${foundSdkSessionId}`);
110
- } else {
111
- debug.warn('snapshot', 'Could not find SDK session_id for resume - resume may not work correctly');
112
136
  }
113
137
  }
114
138
 
@@ -118,41 +142,26 @@ export const restoreHandler = createRouter()
118
142
  checkpointQueries.updateActiveChildrenAlongPath(sessionId, checkpointPath);
119
143
  }
120
144
 
121
- // 7. Restore file system state from snapshot
122
- // Walk backward from session end to checkpoint to find the best (most recent) snapshot
145
+ // 7. Restore file system state using session-scoped restore
123
146
  let filesRestored = 0;
124
- const msgMap = new Map(allMessages.map(m => [m.id, m]));
125
-
126
- let snapshot = null;
127
- let walkId: string | null = sessionEnd.id;
128
- while (walkId) {
129
- const s = snapshotQueries.getByMessageId(walkId);
130
- if (s) {
131
- snapshot = s;
132
- break;
133
- }
134
- // Don't walk past the checkpoint message
135
- if (walkId === messageId) break;
136
- const walkMsg = msgMap.get(walkId);
137
- if (!walkMsg) break;
138
- walkId = walkMsg.parent_message_id || null;
139
- }
140
-
141
- debug.log('snapshot', `Snapshot found: ${snapshot ? `${snapshot.id} (for message ${snapshot.message_id})` : 'none'}`);
142
-
143
- if (snapshot) {
144
- const session = sessionQueries.getById(sessionId);
145
- if (session) {
146
- const project = projectQueries.getById(session.project_id);
147
- if (project) {
148
- await snapshotService.restoreSnapshot(project.path, snapshot);
149
- debug.log('snapshot', 'Files restored from snapshot');
150
- filesRestored = 1;
151
- }
147
+ let filesSkipped = 0;
148
+
149
+ const session = sessionQueries.getById(sessionId);
150
+ if (session) {
151
+ const project = projectQueries.getById(session.project_id);
152
+ if (project) {
153
+ const result = await snapshotService.restoreSessionScoped(
154
+ project.path,
155
+ sessionId,
156
+ messageId,
157
+ conflictResolutions
158
+ );
159
+ filesRestored = result.restoredFiles;
160
+ filesSkipped = result.skippedFiles;
152
161
  }
153
162
  }
154
163
 
155
- // 8. Broadcast messages-changed to users in the chat session
164
+ // 8. Broadcast messages-changed
156
165
  try {
157
166
  ws.emit.chatSession(sessionId, 'chat:messages-changed', {
158
167
  sessionId,
@@ -168,6 +177,7 @@ export const restoreHandler = createRouter()
168
177
  messageId: sessionEnd.id,
169
178
  timestamp: sessionEnd.timestamp
170
179
  },
171
- filesRestored
180
+ filesRestored,
181
+ filesSkipped
172
182
  };
173
183
  });
@@ -12,10 +12,11 @@
12
12
  <script lang="ts">
13
13
  import { sessionState, setCurrentSession, createNewChatSession, clearMessages, loadMessagesForSession } from '$frontend/lib/stores/core/sessions.svelte';
14
14
  import { projectState } from '$frontend/lib/stores/core/projects.svelte';
15
- import { appState, getSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
16
- import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
15
+ import { appState } from '$frontend/lib/stores/core/app.svelte';
16
+ import { presenceState, isSessionWaitingInput } from '$frontend/lib/stores/core/presence.svelte';
17
17
  import { userStore } from '$frontend/lib/stores/features/user.svelte';
18
18
  import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
19
+ import { chatService } from '$frontend/lib/services/chat/chat.service';
19
20
  import { onMount } from 'svelte';
20
21
  import { fade } from 'svelte/transition';
21
22
  import ChatMessages from './message/ChatMessages.svelte';
@@ -214,6 +215,12 @@
214
215
  return;
215
216
  }
216
217
 
218
+ // Reset frontend state without killing the backend stream
219
+ // The old session's stream continues running in the background
220
+ if (appState.isLoading) {
221
+ chatService.resetForSessionSwitch();
222
+ }
223
+
217
224
  // Clear messages for the local view
218
225
  clearMessages();
219
226
 
@@ -287,7 +294,7 @@
287
294
  >
288
295
  <div class="flex-1 min-w-0">
289
296
  <div class="flex items-center gap-1.5">
290
- <span class="w-1.5 h-1.5 rounded-full shrink-0 {isStreaming ? 'bg-emerald-500 animate-pulse' : isCurrent ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'}"></span>
297
+ <span class="w-1.5 h-1.5 rounded-full shrink-0 {isStreaming ? (isSessionWaitingInput(session.id, projectState.currentProject?.id) ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500 animate-pulse') : isCurrent ? 'bg-green-500' : 'bg-slate-300 dark:bg-slate-600'}"></span>
291
298
  <span class="text-sm font-medium truncate">{getSessionShortTitle(session)}</span>
292
299
  </div>
293
300
  <p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
@@ -310,18 +317,18 @@
310
317
  </div>
311
318
  {/if}
312
319
  {#if isStreaming}
313
- {#if getSessionProcessState(session.id).isWaitingInput}
314
- <span class="shrink-0 flex items-center gap-1 px-1.5 py-0.5 text-3xs font-medium rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
315
- <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
316
- Input
317
- </span>
318
- {:else}
319
- <span class="shrink-0 flex items-center gap-1 px-1.5 py-0.5 text-3xs font-medium rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
320
- <span class="w-1.5 h-1.5 rounded-full bg-violet-500 animate-pulse"></span>
321
- AI
322
- </span>
320
+ {#if isSessionWaitingInput(session.id, projectState.currentProject?.id)}
321
+ <span class="shrink-0 flex items-center gap-1 px-1.5 py-0.5 text-3xs font-medium rounded-full bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
322
+ <span class="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse"></span>
323
+ Input
324
+ </span>
325
+ {:else}
326
+ <span class="shrink-0 flex items-center gap-1 px-1.5 py-0.5 text-3xs font-medium rounded-full bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
327
+ <span class="w-1.5 h-1.5 rounded-full bg-violet-500 animate-pulse"></span>
328
+ AI
329
+ </span>
330
+ {/if}
323
331
  {/if}
324
- {/if}
325
332
  </button>
326
333
  {/each}
327
334
  </div>
@@ -421,12 +421,12 @@
421
421
  <!-- Edit Mode Indicator -->
422
422
  <EditModeIndicator onCancel={handleCancelEdit} />
423
423
 
424
- <div class="relative">
424
+ <div class="flex items-end">
425
425
  <textarea
426
426
  bind:this={textareaElement}
427
427
  bind:value={messageText}
428
428
  placeholder={chatPlaceholder}
429
- class="flex w-full p-4 pr-24 border-0 bg-transparent resize-none focus:outline-none text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 text-base leading-relaxed disabled:opacity-50 disabled:cursor-not-allowed"
429
+ class="flex-1 w-full px-4 pt-2 pb-4 border-0 bg-transparent resize-none focus:outline-none text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 text-base leading-relaxed disabled:opacity-50 disabled:cursor-not-allowed"
430
430
  rows="1"
431
431
  style="max-height: 22.5rem; overflow-y: hidden;"
432
432
  disabled={isInputDisabled}
@@ -37,7 +37,7 @@
37
37
  );
38
38
  </script>
39
39
 
40
- <div class="absolute bottom-2 right-2 flex items-center gap-1.5">
40
+ <div class="flex items-center gap-1.5 flex-shrink-0 pr-2 pb-2">
41
41
  {#if !isLoading}
42
42
  <!-- Attach file button -->
43
43
  <button
@@ -3,6 +3,7 @@
3
3
  import { settings } from '$frontend/lib/stores/features/settings.svelte';
4
4
  import { modelStore } from '$frontend/lib/stores/features/models.svelte';
5
5
  import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
6
+ import { appState } from '$frontend/lib/stores/core/app.svelte';
6
7
  import { userStore } from '$frontend/lib/stores/features/user.svelte';
7
8
  import { chatModelState, initChatModel, restoreChatModelFromSession } from '$frontend/lib/stores/ui/chat-model.svelte';
8
9
  import { ENGINES } from '$shared/constants/engines';
@@ -146,10 +147,15 @@
146
147
  });
147
148
 
148
149
  // Initialize model picker based on session state:
149
- // - New session (no messages): apply Settings defaults
150
- // - Existing session (has messages): restore from session's persisted engine/model
151
- // Reads are done outside untrack (tracked), writes inside untrack (not tracked)
152
- // to prevent UpdatedAtError from circular chatModelState read-write.
150
+ // - Session with persisted engine/model: restore from session (highest priority)
151
+ // - New session (no messages, no persisted engine/model): apply Settings defaults
152
+ // - Legacy session without engine/model: fall back to Settings defaults
153
+ //
154
+ // IMPORTANT: Session engine/model is checked BEFORE hasStartedChat because
155
+ // when switching sessions, messages load asynchronously AFTER the session is set.
156
+ // If we checked hasStartedChat first, there's a window where messages haven't
157
+ // loaded yet (hasStartedChat=false) โ†’ defaults would be applied, overriding the
158
+ // session's actual engine/model selection.
153
159
  $effect(() => {
154
160
  const session = sessionState.currentSession;
155
161
  const _sessionId = session?.id;
@@ -162,12 +168,14 @@
162
168
  const sessionAccountId = session?.claude_account_id;
163
169
 
164
170
  untrack(() => {
165
- if (!started) {
166
- // New session (no messages): apply Settings defaults
167
- initChatModel(sEngine, sModel, sMemory || {});
168
- } else if (sessionEngine && sessionModel) {
169
- // Existing session with persisted engine/model: restore
171
+ if (sessionEngine && sessionModel) {
172
+ // Session has persisted engine/model: always restore from session.
173
+ // This works for both existing sessions (has messages) and sessions
174
+ // where messages are still loading asynchronously.
170
175
  restoreChatModelFromSession(sessionEngine, sessionModel, sessionAccountId);
176
+ } else if (!started) {
177
+ // New session (no messages, no persisted engine/model): apply Settings defaults
178
+ initChatModel(sEngine, sModel, sMemory || {});
171
179
  } else {
172
180
  // Existing session without engine/model (pre-migration or not yet set):
173
181
  // fall back to Settings defaults
@@ -388,14 +396,16 @@
388
396
  }
389
397
  </script>
390
398
 
391
- <div class="flex items-center gap-1.5 px-4 pt-2 pb-0.5 -mb-2">
399
+ <div class="flex items-center gap-1.5 px-4 pt-2 pb-0.5">
392
400
  <button
393
401
  bind:this={triggerButton}
394
402
  type="button"
395
403
  class="flex items-center gap-1.5 px-2 py-1 text-xs rounded-lg transition-all duration-150
396
404
  bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700
397
- text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700"
405
+ text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700
406
+ disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-slate-100 dark:disabled:hover:bg-slate-800"
398
407
  onclick={toggleDropdown}
408
+ disabled={appState.isLoading}
399
409
  >
400
410
  {#if currentEngine}
401
411
  <div class="flex dark:hidden items-center justify-center w-3.5 h-3.5 [&>svg]:w-full [&>svg]:h-full">{@html currentEngine.icon.light}</div>
@@ -413,8 +423,10 @@
413
423
  type="button"
414
424
  class="flex items-center gap-1.5 px-2 py-1 text-xs rounded-lg transition-all duration-150
415
425
  bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700
416
- text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700"
426
+ text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700
427
+ disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-slate-100 dark:disabled:hover:bg-slate-800"
417
428
  onclick={toggleAccountDropdown}
429
+ disabled={appState.isLoading}
418
430
  >
419
431
  <Icon name="lucide:user" class="w-3.5 h-3.5" />
420
432
  <span class="font-medium max-w-24 truncate">{currentAccount?.name || 'Account'}</span>
@@ -1,5 +1,7 @@
1
1
  export function useTextareaResize() {
2
2
  // Auto-resize textarea with proper single-line reset
3
+ const MAX_HEIGHT_PX = 22.5 * 16; // 360px = 22.5rem
4
+
3
5
  function adjustTextareaHeight(
4
6
  textareaElement: HTMLTextAreaElement | undefined,
5
7
  messageText: string
@@ -12,6 +14,7 @@ export function useTextareaResize() {
12
14
  if (!messageText || !messageText.trim()) {
13
15
  // Force single line height
14
16
  textareaElement.style.height = 'auto';
17
+ textareaElement.style.overflowY = 'hidden';
15
18
  return;
16
19
  }
17
20
 
@@ -22,8 +25,15 @@ export function useTextareaResize() {
22
25
  const paddingBottom = parseInt(getComputedStyle(textareaElement).paddingBottom) || 0;
23
26
  const minHeight = lineHeight + paddingTop + paddingBottom;
24
27
 
25
- // Set height to content height, but at least minimum height
26
- textareaElement.style.height = Math.max(minHeight, scrollHeight) / 16 + 'rem';
28
+ const newHeight = Math.max(minHeight, scrollHeight);
29
+
30
+ if (newHeight >= MAX_HEIGHT_PX) {
31
+ textareaElement.style.height = '22.5rem';
32
+ textareaElement.style.overflowY = 'auto';
33
+ } else {
34
+ textareaElement.style.height = newHeight / 16 + 'rem';
35
+ textareaElement.style.overflowY = 'hidden';
36
+ }
27
37
  }
28
38
  }
29
39
 
@@ -2,7 +2,6 @@
2
2
  import type { AskUserQuestionToolInput } from '$shared/types/messaging';
3
3
  import Icon from '$frontend/lib/components/common/Icon.svelte';
4
4
  import ws from '$frontend/lib/utils/ws';
5
- import { soundNotification, pushNotification } from '$frontend/lib/services/notification';
6
5
  import { currentSessionId } from '$frontend/lib/stores/core/sessions.svelte';
7
6
  import { appState, updateSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
8
7
  import { debug } from '$shared/utils/logger';
@@ -99,13 +98,9 @@
99
98
  otherActive = initialOther;
100
99
  });
101
100
 
102
- // Play notification sound when this tool appears and is not yet answered/errored/interrupted
103
- $effect(() => {
104
- if (!hasResult && !hasSubmitted && !isInterrupted) {
105
- soundNotification.play().catch(() => {});
106
- pushNotification.sendChatComplete('Claude is asking you a question. Please respond.').catch(() => {});
107
- }
108
- });
101
+ // Sound/push notifications for AskUserQuestion are handled globally by
102
+ // GlobalStreamMonitor (via chat:waiting-input event) โ€” works cross-session,
103
+ // plays once per tool_use, and does not replay when returning to session.
109
104
 
110
105
  function toggleSelection(questionIdx: number, label: string, isMultiSelect: boolean) {
111
106
  const current = selections[questionIdx] || new Set();