@myrialabs/clopen 0.1.4 → 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 (37) hide show
  1. package/backend/lib/chat/stream-manager.ts +8 -0
  2. package/backend/lib/database/migrations/022_add_snapshot_changes_column.ts +35 -0
  3. package/backend/lib/database/migrations/index.ts +7 -0
  4. package/backend/lib/database/queries/snapshot-queries.ts +7 -4
  5. package/backend/lib/files/file-watcher.ts +34 -0
  6. package/backend/lib/project/status-manager.ts +6 -4
  7. package/backend/lib/snapshot/snapshot-service.ts +471 -316
  8. package/backend/lib/terminal/pty-session-manager.ts +1 -32
  9. package/backend/ws/chat/stream.ts +45 -2
  10. package/backend/ws/snapshot/restore.ts +77 -67
  11. package/frontend/lib/components/chat/ChatInterface.svelte +14 -14
  12. package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
  13. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
  14. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +8 -3
  15. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
  16. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
  17. package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
  18. package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
  19. package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
  20. package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
  21. package/frontend/lib/components/git/DiffViewer.svelte +16 -2
  22. package/frontend/lib/components/history/HistoryModal.svelte +3 -4
  23. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
  24. package/frontend/lib/components/terminal/Terminal.svelte +1 -7
  25. package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
  26. package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
  27. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
  28. package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
  29. package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
  30. package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
  31. package/frontend/lib/stores/core/presence.svelte.ts +63 -1
  32. package/frontend/lib/stores/features/settings.svelte.ts +9 -1
  33. package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
  34. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
  35. package/package.json +1 -1
  36. package/shared/types/database/schema.ts +18 -0
  37. package/shared/types/stores/settings.ts +2 -0
@@ -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,8 +12,8 @@
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
19
  import { chatService } from '$frontend/lib/services/chat/chat.service';
@@ -294,7 +294,7 @@
294
294
  >
295
295
  <div class="flex-1 min-w-0">
296
296
  <div class="flex items-center gap-1.5">
297
- <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>
298
298
  <span class="text-sm font-medium truncate">{getSessionShortTitle(session)}</span>
299
299
  </div>
300
300
  <p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
@@ -317,18 +317,18 @@
317
317
  </div>
318
318
  {/if}
319
319
  {#if isStreaming}
320
- {#if getSessionProcessState(session.id).isWaitingInput}
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>
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}
330
331
  {/if}
331
- {/if}
332
332
  </button>
333
333
  {/each}
334
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';
@@ -395,14 +396,16 @@
395
396
  }
396
397
  </script>
397
398
 
398
- <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">
399
400
  <button
400
401
  bind:this={triggerButton}
401
402
  type="button"
402
403
  class="flex items-center gap-1.5 px-2 py-1 text-xs rounded-lg transition-all duration-150
403
404
  bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700
404
- 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"
405
407
  onclick={toggleDropdown}
408
+ disabled={appState.isLoading}
406
409
  >
407
410
  {#if currentEngine}
408
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>
@@ -420,8 +423,10 @@
420
423
  type="button"
421
424
  class="flex items-center gap-1.5 px-2 py-1 text-xs rounded-lg transition-all duration-150
422
425
  bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700
423
- 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"
424
428
  onclick={toggleAccountDropdown}
429
+ disabled={appState.isLoading}
425
430
  >
426
431
  <Icon name="lucide:user" class="w-3.5 h-3.5" />
427
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();