@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.
- package/backend/lib/chat/stream-manager.ts +8 -0
- package/backend/lib/database/migrations/022_add_snapshot_changes_column.ts +35 -0
- package/backend/lib/database/migrations/index.ts +7 -0
- package/backend/lib/database/queries/snapshot-queries.ts +7 -4
- package/backend/lib/files/file-watcher.ts +34 -0
- package/backend/lib/project/status-manager.ts +6 -4
- package/backend/lib/snapshot/snapshot-service.ts +471 -316
- package/backend/lib/terminal/pty-session-manager.ts +1 -32
- package/backend/ws/chat/stream.ts +45 -2
- package/backend/ws/snapshot/restore.ts +77 -67
- package/frontend/lib/components/chat/ChatInterface.svelte +14 -14
- package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
- package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +8 -3
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
- package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
- package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
- package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
- package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
- package/frontend/lib/components/git/DiffViewer.svelte +16 -2
- package/frontend/lib/components/history/HistoryModal.svelte +3 -4
- package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
- package/frontend/lib/components/terminal/Terminal.svelte +1 -7
- package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
- package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
- package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
- package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
- package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
- package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
- package/frontend/lib/stores/core/presence.svelte.ts +63 -1
- package/frontend/lib/stores/features/settings.svelte.ts +9 -1
- package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
- package/package.json +1 -1
- package/shared/types/database/schema.ts +18 -0
- 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
|
-
//
|
|
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 (
|
|
2
|
+
* Snapshot Restore Handler (v2 - Session-Scoped with Conflict Detection)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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="
|
|
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
|
|
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}
|
|
@@ -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
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
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();
|