@myrialabs/clopen 0.0.7 → 0.1.1
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/index.ts +28 -10
- package/backend/lib/chat/stream-manager.ts +130 -10
- package/backend/lib/database/queries/message-queries.ts +47 -0
- package/backend/lib/engine/adapters/claude/stream.ts +65 -1
- package/backend/lib/engine/adapters/opencode/message-converter.ts +35 -77
- package/backend/lib/engine/types.ts +6 -0
- package/backend/lib/files/file-operations.ts +2 -2
- package/backend/lib/files/file-reading.ts +2 -2
- package/backend/lib/files/path-browsing.ts +2 -2
- package/backend/lib/terminal/pty-session-manager.ts +1 -1
- package/backend/lib/terminal/shell-utils.ts +4 -4
- package/backend/lib/terminal/stream-manager.ts +6 -3
- package/backend/ws/chat/background.ts +3 -0
- package/backend/ws/chat/stream.ts +43 -1
- package/backend/ws/terminal/session.ts +48 -0
- package/bin/clopen.ts +10 -0
- package/bun.lock +258 -383
- package/frontend/lib/components/chat/ChatInterface.svelte +8 -1
- package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +20 -0
- package/frontend/lib/components/chat/formatters/TextMessage.svelte +3 -15
- package/frontend/lib/components/chat/formatters/Tools.svelte +15 -8
- package/frontend/lib/components/chat/input/ChatInput.svelte +70 -21
- package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +23 -11
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +1 -1
- package/frontend/lib/components/chat/message/ChatMessage.svelte +13 -1
- package/frontend/lib/components/chat/message/ChatMessages.svelte +2 -2
- package/frontend/lib/components/chat/message/DateSeparator.svelte +1 -1
- package/frontend/lib/components/chat/message/MessageBubble.svelte +2 -2
- package/frontend/lib/components/chat/message/MessageHeader.svelte +14 -12
- package/frontend/lib/components/chat/tools/AgentTool.svelte +95 -0
- package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +396 -0
- package/frontend/lib/components/chat/tools/BashTool.svelte +9 -4
- package/frontend/lib/components/chat/tools/EnterPlanModeTool.svelte +24 -0
- package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +4 -7
- package/frontend/lib/components/chat/tools/{KillShellTool.svelte → TaskStopTool.svelte} +6 -6
- package/frontend/lib/components/chat/tools/index.ts +5 -2
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +7 -2
- package/frontend/lib/components/history/HistoryModal.svelte +13 -5
- package/frontend/lib/components/workspace/DesktopNavigator.svelte +2 -1
- package/frontend/lib/components/workspace/MobileNavigator.svelte +2 -1
- package/frontend/lib/services/chat/chat.service.ts +146 -12
- package/frontend/lib/services/terminal/project.service.ts +65 -10
- package/frontend/lib/services/terminal/terminal.service.ts +19 -0
- package/frontend/lib/stores/core/app.svelte.ts +77 -0
- package/frontend/lib/stores/features/terminal.svelte.ts +10 -0
- package/frontend/lib/utils/chat/message-grouper.ts +94 -12
- package/frontend/lib/utils/chat/message-processor.ts +37 -4
- package/frontend/lib/utils/chat/tool-handler.ts +96 -5
- package/package.json +4 -5
- package/shared/constants/engines.ts +1 -1
- package/shared/types/database/schema.ts +1 -0
- package/shared/types/messaging/index.ts +15 -13
- package/shared/types/messaging/tool.ts +185 -361
- package/shared/utils/message-formatter.ts +1 -0
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* - Proper presence synchronization
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { appState } from '$frontend/lib/stores/core/app.svelte';
|
|
14
|
+
import { appState, updateSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
|
|
15
|
+
import type { SessionProcessState } from '$frontend/lib/stores/core/app.svelte';
|
|
15
16
|
import { chatModelState } from '$frontend/lib/stores/ui/chat-model.svelte';
|
|
16
17
|
import { projectState } from '$frontend/lib/stores/core/projects.svelte';
|
|
17
18
|
import { sessionState, setCurrentSession, createSession, updateSession } from '$frontend/lib/stores/core/sessions.svelte';
|
|
@@ -23,6 +24,14 @@ import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
|
|
|
23
24
|
import { debug } from '$shared/utils/logger';
|
|
24
25
|
import ws from '$frontend/lib/utils/ws';
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Tools that block the SDK waiting for user interaction.
|
|
29
|
+
* When these tools appear in an assistant message without a result,
|
|
30
|
+
* and the stream is active, the chat status switches to "waiting for input".
|
|
31
|
+
* Extend this list for future interactive tools.
|
|
32
|
+
*/
|
|
33
|
+
const INTERACTIVE_TOOLS = new Set(['AskUserQuestion']);
|
|
34
|
+
|
|
26
35
|
class ChatService {
|
|
27
36
|
private activeProcessId: string | null = null;
|
|
28
37
|
private streamCompleted: boolean = false;
|
|
@@ -58,6 +67,30 @@ class ChatService {
|
|
|
58
67
|
this.setupWebSocketHandlers();
|
|
59
68
|
}
|
|
60
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Update process state for a session.
|
|
72
|
+
* Writes to both the per-session map (for multi-session support)
|
|
73
|
+
* and global convenience flags (for backward-compatible single-session components).
|
|
74
|
+
*
|
|
75
|
+
* @param update - Partial state to merge
|
|
76
|
+
* @param sessionId - Override session ID (defaults to this.currentSessionId or current session)
|
|
77
|
+
*/
|
|
78
|
+
private setProcessState(
|
|
79
|
+
update: Partial<SessionProcessState>,
|
|
80
|
+
sessionId?: string | null
|
|
81
|
+
): void {
|
|
82
|
+
const resolvedId = sessionId ?? this.currentSessionId ?? sessionState.currentSession?.id;
|
|
83
|
+
if (resolvedId) {
|
|
84
|
+
updateSessionProcessState(resolvedId, update);
|
|
85
|
+
}
|
|
86
|
+
// Always sync to global convenience flags
|
|
87
|
+
if ('isLoading' in update) appState.isLoading = update.isLoading!;
|
|
88
|
+
if ('isWaitingInput' in update) appState.isWaitingInput = update.isWaitingInput!;
|
|
89
|
+
if ('isCancelling' in update) appState.isCancelling = update.isCancelling!;
|
|
90
|
+
if ('isRestoring' in update) appState.isRestoring = update.isRestoring!;
|
|
91
|
+
if ('error' in update && update.error !== undefined) appState.error = update.error;
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
/**
|
|
62
95
|
* Check if event should be skipped (sequence-based deduplication)
|
|
63
96
|
*/
|
|
@@ -137,8 +170,10 @@ class ChatService {
|
|
|
137
170
|
|
|
138
171
|
this.streamCompleted = true;
|
|
139
172
|
this.reconnected = false;
|
|
140
|
-
|
|
141
|
-
|
|
173
|
+
this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
|
|
174
|
+
|
|
175
|
+
// Mark any tool_use blocks that never got a tool_result
|
|
176
|
+
this.markInterruptedTools();
|
|
142
177
|
|
|
143
178
|
// Stream completed successfully — all old cancelled streams' events
|
|
144
179
|
// have definitely been delivered by now, so clear the blacklist.
|
|
@@ -161,8 +196,10 @@ class ChatService {
|
|
|
161
196
|
this.streamCompleted = true;
|
|
162
197
|
this.reconnected = false;
|
|
163
198
|
this.activeProcessId = null;
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
|
|
200
|
+
|
|
201
|
+
// Mark any tool_use blocks that never got a tool_result
|
|
202
|
+
this.markInterruptedTools();
|
|
166
203
|
|
|
167
204
|
// Notifications handled by GlobalStreamMonitor via chat:stream-finished
|
|
168
205
|
});
|
|
@@ -178,8 +215,10 @@ class ChatService {
|
|
|
178
215
|
// (e.g. from multiple subscriptions or late-arriving events with different processId/seq)
|
|
179
216
|
this.streamCompleted = true;
|
|
180
217
|
this.reconnected = false;
|
|
181
|
-
|
|
182
|
-
|
|
218
|
+
this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
|
|
219
|
+
|
|
220
|
+
// Mark any tool_use blocks that never got a tool_result
|
|
221
|
+
this.markInterruptedTools();
|
|
183
222
|
|
|
184
223
|
// Don't show notification for cancel-triggered errors
|
|
185
224
|
if (data.error === 'Stream cancelled') return;
|
|
@@ -277,11 +316,10 @@ class ChatService {
|
|
|
277
316
|
}
|
|
278
317
|
|
|
279
318
|
// Set loading state
|
|
280
|
-
appState.isLoading = true;
|
|
281
|
-
appState.isCancelling = false;
|
|
282
319
|
this.streamCompleted = false;
|
|
283
320
|
this.reconnected = false;
|
|
284
321
|
this.currentSessionId = sessionState.currentSession.id;
|
|
322
|
+
this.setProcessState({ isLoading: true, isWaitingInput: false, isCancelling: false });
|
|
285
323
|
// DON'T clear cancelledProcessIds — late events from previously cancelled
|
|
286
324
|
// streams must still be blocked. The set is cleared on stream complete.
|
|
287
325
|
// Clear sequence tracking for new stream
|
|
@@ -432,9 +470,9 @@ class ChatService {
|
|
|
432
470
|
this.currentSessionId = null;
|
|
433
471
|
this.streamCompleted = true;
|
|
434
472
|
this.reconnected = false;
|
|
435
|
-
|
|
436
|
-
//
|
|
437
|
-
|
|
473
|
+
// Update per-session map with captured ID (before it was nulled above)
|
|
474
|
+
// and global flags — cancel sets isCancelling=true to prevent presence re-enabling
|
|
475
|
+
this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: true }, chatSessionId);
|
|
438
476
|
|
|
439
477
|
// Clean up stale stream_events from the cancelled stream.
|
|
440
478
|
// Without this, stale stream_events remain in the messages array and cause
|
|
@@ -465,6 +503,7 @@ class ChatService {
|
|
|
465
503
|
this.reconnected = false;
|
|
466
504
|
this.lastEventSeq.clear();
|
|
467
505
|
appState.isLoading = false;
|
|
506
|
+
appState.isWaitingInput = false;
|
|
468
507
|
appState.isCancelling = false;
|
|
469
508
|
}
|
|
470
509
|
|
|
@@ -549,6 +588,27 @@ class ChatService {
|
|
|
549
588
|
})
|
|
550
589
|
};
|
|
551
590
|
|
|
591
|
+
// Detect interactive tool_use blocks (e.g., AskUserQuestion) and set waiting status.
|
|
592
|
+
// When the SDK is blocked on canUseTool, partial events stop and the user must interact.
|
|
593
|
+
if (sdkMessage.type === 'assistant' && sdkMessage.message?.content) {
|
|
594
|
+
const content = Array.isArray(sdkMessage.message.content) ? sdkMessage.message.content : [];
|
|
595
|
+
const hasInteractiveTool = content.some(
|
|
596
|
+
(item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name)
|
|
597
|
+
);
|
|
598
|
+
if (hasInteractiveTool) {
|
|
599
|
+
this.setProcessState({ isWaitingInput: true });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// When a user message with tool_result arrives, the SDK is unblocked — clear waiting status
|
|
604
|
+
if (sdkMessage.type === 'user' && sdkMessage.message?.content) {
|
|
605
|
+
const content = Array.isArray(sdkMessage.message.content) ? sdkMessage.message.content : [];
|
|
606
|
+
const hasToolResult = content.some((item: any) => item.type === 'tool_result');
|
|
607
|
+
if (hasToolResult && appState.isWaitingInput) {
|
|
608
|
+
this.setProcessState({ isWaitingInput: false });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
552
612
|
// For reasoning messages that couldn't find a matching stream_event,
|
|
553
613
|
// insert BEFORE trailing non-reasoning assistant messages (tools/text)
|
|
554
614
|
// to preserve reasoning-before-tool ordering within the same turn.
|
|
@@ -671,6 +731,80 @@ class ChatService {
|
|
|
671
731
|
}
|
|
672
732
|
}
|
|
673
733
|
|
|
734
|
+
/**
|
|
735
|
+
* Detect whether any interactive tool (e.g. AskUserQuestion) is pending in the current messages.
|
|
736
|
+
* Used after browser refresh / catchup to restore the isWaitingInput state.
|
|
737
|
+
*/
|
|
738
|
+
detectPendingInteractiveTools(): void {
|
|
739
|
+
if (!appState.isLoading) return;
|
|
740
|
+
|
|
741
|
+
// Collect all tool_use IDs that have a matching tool_result
|
|
742
|
+
const answeredToolIds = new Set<string>();
|
|
743
|
+
for (const msg of sessionState.messages) {
|
|
744
|
+
const msgAny = msg as any;
|
|
745
|
+
if (msgAny.type !== 'user' || !msgAny.message?.content) continue;
|
|
746
|
+
const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
|
|
747
|
+
for (const item of content) {
|
|
748
|
+
if (item.type === 'tool_result' && item.tool_use_id) {
|
|
749
|
+
answeredToolIds.add(item.tool_use_id);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Check if any interactive tool is unanswered
|
|
755
|
+
for (const msg of sessionState.messages) {
|
|
756
|
+
const msgAny = msg as any;
|
|
757
|
+
if (msgAny.type !== 'assistant' || !msgAny.message?.content) continue;
|
|
758
|
+
const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
|
|
759
|
+
const hasPendingInteractive = content.some(
|
|
760
|
+
(item: any) => item.type === 'tool_use' && INTERACTIVE_TOOLS.has(item.name) && item.id && !answeredToolIds.has(item.id)
|
|
761
|
+
);
|
|
762
|
+
if (hasPendingInteractive) {
|
|
763
|
+
this.setProcessState({ isWaitingInput: true });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Mark assistant messages with unanswered tool_use blocks as interrupted.
|
|
771
|
+
* Sets metadata.interrupted at the MESSAGE level (not tool_use content level).
|
|
772
|
+
* Called when stream ends (complete/error/cancel) for immediate in-memory update.
|
|
773
|
+
* The backend persists this to DB via stream:lifecycle for durability.
|
|
774
|
+
*/
|
|
775
|
+
private markInterruptedTools(): void {
|
|
776
|
+
// Collect all tool_use IDs that have a matching tool_result
|
|
777
|
+
const answeredToolIds = new Set<string>();
|
|
778
|
+
|
|
779
|
+
for (const msg of sessionState.messages) {
|
|
780
|
+
const msgAny = msg as any;
|
|
781
|
+
if (msgAny.type !== 'user' || !msgAny.message?.content) continue;
|
|
782
|
+
const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
|
|
783
|
+
|
|
784
|
+
for (const item of content) {
|
|
785
|
+
if (item.type === 'tool_result' && item.tool_use_id) {
|
|
786
|
+
answeredToolIds.add(item.tool_use_id);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Mark messages with unanswered tool_use blocks as interrupted (message-level metadata)
|
|
792
|
+
for (const msg of sessionState.messages) {
|
|
793
|
+
const msgAny = msg as any;
|
|
794
|
+
if (msgAny.type !== 'assistant' || !msgAny.message?.content) continue;
|
|
795
|
+
const content = Array.isArray(msgAny.message.content) ? msgAny.message.content : [];
|
|
796
|
+
|
|
797
|
+
const hasUnansweredTool = content.some(
|
|
798
|
+
(item: any) => item.type === 'tool_use' && item.id && !answeredToolIds.has(item.id)
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
if (hasUnansweredTool && !msgAny.metadata?.interrupted) {
|
|
802
|
+
if (!msgAny.metadata) msgAny.metadata = {};
|
|
803
|
+
msgAny.metadata.interrupted = true;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
674
808
|
/**
|
|
675
809
|
* Handle general errors
|
|
676
810
|
*/
|
|
@@ -98,28 +98,83 @@ class TerminalProjectManager {
|
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
100
|
* Create initial terminal sessions for a project
|
|
101
|
+
* First checks backend for existing PTY sessions (e.g., after browser refresh)
|
|
101
102
|
*/
|
|
102
103
|
private async createProjectTerminalSessions(projectId: string, projectPath: string): Promise<void> {
|
|
103
|
-
// Creating terminal session for project
|
|
104
|
-
|
|
105
104
|
const context = this.getOrCreateProjectContext(projectId, projectPath);
|
|
106
|
-
|
|
107
|
-
//
|
|
105
|
+
|
|
106
|
+
// Check backend for existing PTY sessions (survives browser refresh)
|
|
107
|
+
const existingBackendSessions = await terminalService.listProjectSessions(projectId);
|
|
108
|
+
|
|
109
|
+
if (existingBackendSessions.length > 0) {
|
|
110
|
+
debug.log('terminal', `Found ${existingBackendSessions.length} existing PTY sessions for project ${projectId}`);
|
|
111
|
+
|
|
112
|
+
// Sort by sessionId to maintain consistent order (terminal-1, terminal-2, etc.)
|
|
113
|
+
existingBackendSessions.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
|
|
114
|
+
|
|
115
|
+
// Restore all existing sessions as tabs
|
|
116
|
+
for (const backendSession of existingBackendSessions) {
|
|
117
|
+
const sessionParts = backendSession.sessionId.split('-');
|
|
118
|
+
const terminalNumber = sessionParts[sessionParts.length - 1] || '1';
|
|
119
|
+
|
|
120
|
+
const terminalSession: TerminalSession = {
|
|
121
|
+
id: backendSession.sessionId,
|
|
122
|
+
name: `Terminal ${terminalNumber}`,
|
|
123
|
+
directory: backendSession.cwd || projectPath,
|
|
124
|
+
lines: [],
|
|
125
|
+
commandHistory: [],
|
|
126
|
+
isActive: false,
|
|
127
|
+
createdAt: new Date(backendSession.createdAt),
|
|
128
|
+
lastUsedAt: new Date(backendSession.lastActivityAt),
|
|
129
|
+
shellType: 'Unknown',
|
|
130
|
+
terminalBuffer: undefined,
|
|
131
|
+
projectId: projectId,
|
|
132
|
+
projectPath: projectPath
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
terminalStore.addSession(terminalSession);
|
|
136
|
+
terminalSessionManager.createSession(backendSession.sessionId, projectId, projectPath, backendSession.cwd || projectPath);
|
|
137
|
+
context.sessionIds.push(backendSession.sessionId);
|
|
138
|
+
|
|
139
|
+
// Update nextSessionId to avoid ID conflicts
|
|
140
|
+
const match = backendSession.sessionId.match(/terminal-(\d+)/);
|
|
141
|
+
if (match) {
|
|
142
|
+
terminalStore.updateNextSessionId(parseInt(match[1], 10) + 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Restore previously active session from sessionStorage, or default to first
|
|
147
|
+
let activeSessionId = existingBackendSessions[0].sessionId;
|
|
148
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
149
|
+
try {
|
|
150
|
+
const savedActiveId = sessionStorage.getItem(`terminal-active-session-${projectId}`);
|
|
151
|
+
if (savedActiveId && context.sessionIds.includes(savedActiveId)) {
|
|
152
|
+
activeSessionId = savedActiveId;
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// sessionStorage not available
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
context.activeSessionId = activeSessionId;
|
|
159
|
+
terminalStore.switchToSession(context.activeSessionId);
|
|
160
|
+
this.persistContexts();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// No existing backend sessions, create 1 new terminal session
|
|
108
165
|
const sessionId = terminalStore.createNewSession(projectPath, projectPath, projectId);
|
|
109
|
-
|
|
110
|
-
// Update the session's directory to ensure it's correct
|
|
166
|
+
|
|
111
167
|
const session = terminalStore.getSession(sessionId);
|
|
112
168
|
if (session) {
|
|
113
169
|
session.directory = projectPath;
|
|
114
170
|
}
|
|
115
|
-
|
|
116
|
-
// Create a fresh session in terminalSessionManager with correct project association
|
|
171
|
+
|
|
117
172
|
terminalSessionManager.createSession(sessionId, projectId, projectPath, projectPath);
|
|
118
|
-
|
|
173
|
+
|
|
119
174
|
context.sessionIds.push(sessionId);
|
|
120
175
|
context.activeSessionId = sessionId;
|
|
121
176
|
terminalStore.switchToSession(sessionId);
|
|
122
|
-
|
|
177
|
+
|
|
123
178
|
this.persistContexts();
|
|
124
179
|
}
|
|
125
180
|
|
|
@@ -339,6 +339,25 @@ export class TerminalService {
|
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* List active PTY sessions for a project on the backend
|
|
344
|
+
* Used after browser refresh to discover existing sessions
|
|
345
|
+
*/
|
|
346
|
+
async listProjectSessions(projectId: string): Promise<Array<{
|
|
347
|
+
sessionId: string;
|
|
348
|
+
pid: number;
|
|
349
|
+
cwd: string;
|
|
350
|
+
createdAt: string;
|
|
351
|
+
lastActivityAt: string;
|
|
352
|
+
}>> {
|
|
353
|
+
try {
|
|
354
|
+
const data = await ws.http('terminal:list-sessions', { projectId }, 5000);
|
|
355
|
+
return data.sessions || [];
|
|
356
|
+
} catch {
|
|
357
|
+
return [];
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
342
361
|
/**
|
|
343
362
|
* Cleanup listeners for a session
|
|
344
363
|
*/
|
|
@@ -15,14 +15,41 @@ interface PageInfo {
|
|
|
15
15
|
actions?: import('svelte').Snippet;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Per-session process state.
|
|
20
|
+
* Tracks loading/waiting/cancelling state for each chat session independently,
|
|
21
|
+
* enabling correct multi-session and multi-project support.
|
|
22
|
+
*/
|
|
23
|
+
export interface SessionProcessState {
|
|
24
|
+
isLoading: boolean;
|
|
25
|
+
isWaitingInput: boolean;
|
|
26
|
+
isRestoring: boolean;
|
|
27
|
+
isCancelling: boolean;
|
|
28
|
+
error: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_SESSION_STATE: SessionProcessState = {
|
|
32
|
+
isLoading: false,
|
|
33
|
+
isWaitingInput: false,
|
|
34
|
+
isRestoring: false,
|
|
35
|
+
isCancelling: false,
|
|
36
|
+
error: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
18
39
|
interface AppState {
|
|
19
40
|
// UI Navigation
|
|
20
41
|
currentView: string;
|
|
42
|
+
|
|
43
|
+
// Current session process state (convenience — synced from sessionStates for the active session)
|
|
21
44
|
isLoading: boolean;
|
|
45
|
+
isWaitingInput: boolean;
|
|
22
46
|
isRestoring: boolean;
|
|
23
47
|
isCancelling: boolean;
|
|
24
48
|
error: string | null;
|
|
25
49
|
|
|
50
|
+
// Per-session process states (source of truth for multi-session support)
|
|
51
|
+
sessionStates: Record<string, SessionProcessState>;
|
|
52
|
+
|
|
26
53
|
// Page Information
|
|
27
54
|
pageInfo: PageInfo;
|
|
28
55
|
|
|
@@ -36,10 +63,14 @@ export const appState = $state<AppState>({
|
|
|
36
63
|
// UI Navigation
|
|
37
64
|
currentView: 'chat',
|
|
38
65
|
isLoading: false,
|
|
66
|
+
isWaitingInput: false,
|
|
39
67
|
isRestoring: false,
|
|
40
68
|
isCancelling: false,
|
|
41
69
|
error: null,
|
|
42
70
|
|
|
71
|
+
// Per-session process states
|
|
72
|
+
sessionStates: {},
|
|
73
|
+
|
|
43
74
|
// Page Information
|
|
44
75
|
pageInfo: {
|
|
45
76
|
title: 'Claude Code',
|
|
@@ -52,6 +83,52 @@ export const appState = $state<AppState>({
|
|
|
52
83
|
isAppInitialized: false
|
|
53
84
|
});
|
|
54
85
|
|
|
86
|
+
// ========================================
|
|
87
|
+
// PER-SESSION PROCESS STATE MANAGEMENT
|
|
88
|
+
// ========================================
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Get the process state for a specific session.
|
|
92
|
+
* Returns default (idle) state if the session has no entry.
|
|
93
|
+
*/
|
|
94
|
+
export function getSessionProcessState(sessionId: string): SessionProcessState {
|
|
95
|
+
return appState.sessionStates[sessionId] ?? DEFAULT_SESSION_STATE;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Update process state for a specific session in the per-session map.
|
|
100
|
+
* Does NOT touch global convenience flags — caller is responsible for that.
|
|
101
|
+
*/
|
|
102
|
+
export function updateSessionProcessState(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
update: Partial<SessionProcessState>
|
|
105
|
+
): void {
|
|
106
|
+
if (!appState.sessionStates[sessionId]) {
|
|
107
|
+
appState.sessionStates[sessionId] = { ...DEFAULT_SESSION_STATE };
|
|
108
|
+
}
|
|
109
|
+
Object.assign(appState.sessionStates[sessionId], update);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Sync global convenience flags from a session's per-session state.
|
|
114
|
+
* Call when switching sessions to derive global state from the new session.
|
|
115
|
+
*/
|
|
116
|
+
export function syncGlobalStateFromSession(sessionId: string): void {
|
|
117
|
+
const state = appState.sessionStates[sessionId] ?? DEFAULT_SESSION_STATE;
|
|
118
|
+
appState.isLoading = state.isLoading;
|
|
119
|
+
appState.isWaitingInput = state.isWaitingInput;
|
|
120
|
+
appState.isRestoring = state.isRestoring;
|
|
121
|
+
appState.isCancelling = state.isCancelling;
|
|
122
|
+
appState.error = state.error;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Remove a session's process state entry (e.g. when deleting a session).
|
|
127
|
+
*/
|
|
128
|
+
export function clearSessionProcessState(sessionId: string): void {
|
|
129
|
+
delete appState.sessionStates[sessionId];
|
|
130
|
+
}
|
|
131
|
+
|
|
55
132
|
// ========================================
|
|
56
133
|
// UI STATE MANAGEMENT
|
|
57
134
|
// ========================================
|
|
@@ -138,6 +138,16 @@ export const terminalStore = {
|
|
|
138
138
|
}));
|
|
139
139
|
|
|
140
140
|
terminalState.activeSessionId = sessionId;
|
|
141
|
+
|
|
142
|
+
// Persist active session ID for restoration after browser refresh
|
|
143
|
+
const session = terminalState.sessions.find(s => s.id === sessionId);
|
|
144
|
+
if (session?.projectId && typeof sessionStorage !== 'undefined') {
|
|
145
|
+
try {
|
|
146
|
+
sessionStorage.setItem(`terminal-active-session-${session.projectId}`, sessionId);
|
|
147
|
+
} catch {
|
|
148
|
+
// sessionStorage not available
|
|
149
|
+
}
|
|
150
|
+
}
|
|
141
151
|
},
|
|
142
152
|
|
|
143
153
|
async closeSession(sessionId: string): Promise<boolean> {
|
|
@@ -2,7 +2,9 @@ import type { SDKMessageFormatter } from '$shared/types/database/schema';
|
|
|
2
2
|
import {
|
|
3
3
|
shouldFilterMessage,
|
|
4
4
|
extractToolUses,
|
|
5
|
-
extractToolResults
|
|
5
|
+
extractToolResults,
|
|
6
|
+
isCompactBoundaryMessage,
|
|
7
|
+
isSyntheticUserMessage
|
|
6
8
|
} from './message-processor';
|
|
7
9
|
import { processToolMessage } from './tool-handler';
|
|
8
10
|
|
|
@@ -21,13 +23,50 @@ export interface BackgroundBashData {
|
|
|
21
23
|
// Processed message type
|
|
22
24
|
export type ProcessedMessage = SDKMessageFormatter;
|
|
23
25
|
|
|
26
|
+
// Module-level map for compact summary lookup (rebuilt each groupMessages call)
|
|
27
|
+
let _compactSummaryMap = new WeakMap<SDKMessageFormatter, string>();
|
|
28
|
+
|
|
29
|
+
// Lookup compact summary for a given compact_boundary message
|
|
30
|
+
export function getCompactSummary(message: SDKMessageFormatter): string | undefined {
|
|
31
|
+
return _compactSummaryMap.get(message);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Extract text content from a user message
|
|
35
|
+
function extractUserTextContent(message: SDKMessageFormatter): string {
|
|
36
|
+
if (!('message' in message) || !message.message?.content) return '';
|
|
37
|
+
const content = message.message.content;
|
|
38
|
+
if (typeof content === 'string') return content;
|
|
39
|
+
if (Array.isArray(content)) {
|
|
40
|
+
return content
|
|
41
|
+
.filter((item: any) => typeof item === 'object' && item?.type === 'text')
|
|
42
|
+
.map((item: any) => item.text)
|
|
43
|
+
.join('\n');
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get parent_tool_use_id from any message type
|
|
49
|
+
function getParentToolUseId(message: SDKMessageFormatter): string | null {
|
|
50
|
+
if ('parent_tool_use_id' in message) {
|
|
51
|
+
return (message as any).parent_tool_use_id ?? null;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
24
56
|
// Group tool_use and tool_result messages together
|
|
25
57
|
export function groupMessages(messages: SDKMessageFormatter[]): {
|
|
26
58
|
groups: ProcessedMessage[],
|
|
27
|
-
toolUseMap: Map<string, ToolGroup
|
|
59
|
+
toolUseMap: Map<string, ToolGroup>,
|
|
60
|
+
subAgentMap: Map<string, SDKMessageFormatter[]>
|
|
28
61
|
} {
|
|
29
62
|
const groups: ProcessedMessage[] = [];
|
|
30
63
|
const toolUseMap = new Map<string, ToolGroup>();
|
|
64
|
+
const agentToolUseIds = new Set<string>();
|
|
65
|
+
const subAgentMap = new Map<string, SDKMessageFormatter[]>();
|
|
66
|
+
let lastCompactBoundaryIdx = -1;
|
|
67
|
+
|
|
68
|
+
// Rebuild compact summary map each call
|
|
69
|
+
_compactSummaryMap = new WeakMap<SDKMessageFormatter, string>();
|
|
31
70
|
|
|
32
71
|
messages.forEach(message => {
|
|
33
72
|
// Skip messages that should be filtered
|
|
@@ -35,8 +74,30 @@ export function groupMessages(messages: SDKMessageFormatter[]): {
|
|
|
35
74
|
return;
|
|
36
75
|
}
|
|
37
76
|
|
|
77
|
+
// Intercept ALL sub-agent messages (any parent_tool_use_id !== null)
|
|
78
|
+
// before normal processing — these belong to Agent tool sub-conversations
|
|
79
|
+
const parentToolId = getParentToolUseId(message);
|
|
80
|
+
if (parentToolId) {
|
|
81
|
+
if (agentToolUseIds.has(parentToolId)) {
|
|
82
|
+
if (!subAgentMap.has(parentToolId)) {
|
|
83
|
+
subAgentMap.set(parentToolId, []);
|
|
84
|
+
}
|
|
85
|
+
subAgentMap.get(parentToolId)!.push(message);
|
|
86
|
+
}
|
|
87
|
+
// Don't add any sub-agent message to main groups
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Handle compact boundary messages — track for synthetic user embedding
|
|
92
|
+
if (isCompactBoundaryMessage(message)) {
|
|
93
|
+
lastCompactBoundaryIdx = groups.length;
|
|
94
|
+
groups.push(message as ProcessedMessage);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
38
98
|
// Handle assistant messages with tool_use
|
|
39
99
|
if (message.type === 'assistant' && 'message' in message && message.message?.content) {
|
|
100
|
+
lastCompactBoundaryIdx = -1;
|
|
40
101
|
const toolUses = extractToolUses(message.message.content);
|
|
41
102
|
|
|
42
103
|
if (toolUses.length > 0) {
|
|
@@ -47,17 +108,35 @@ export function groupMessages(messages: SDKMessageFormatter[]): {
|
|
|
47
108
|
toolUseMessage: message,
|
|
48
109
|
toolResultMessage: null
|
|
49
110
|
});
|
|
111
|
+
// Track Agent tool IDs for sub-agent message collection
|
|
112
|
+
if (toolUse.name === 'Agent') {
|
|
113
|
+
agentToolUseIds.add(toolUse.id);
|
|
114
|
+
}
|
|
50
115
|
}
|
|
51
116
|
});
|
|
52
117
|
groups.push(message as ProcessedMessage);
|
|
53
118
|
} else {
|
|
54
119
|
groups.push(message as ProcessedMessage);
|
|
55
120
|
}
|
|
121
|
+
return;
|
|
56
122
|
}
|
|
57
|
-
// Handle user messages with tool_result
|
|
58
|
-
else if (message.type === 'user' && 'message' in message && message.message?.content) {
|
|
59
|
-
const toolResults = extractToolResults(message.message.content);
|
|
60
123
|
|
|
124
|
+
// Handle user messages
|
|
125
|
+
if (message.type === 'user' && 'message' in message && message.message?.content) {
|
|
126
|
+
// Synthetic user messages (after compaction): store summary in WeakMap keyed by compact boundary message
|
|
127
|
+
if (isSyntheticUserMessage(message) && lastCompactBoundaryIdx >= 0) {
|
|
128
|
+
const compactMsg = groups[lastCompactBoundaryIdx];
|
|
129
|
+
const summaryText = extractUserTextContent(message);
|
|
130
|
+
if (summaryText) {
|
|
131
|
+
_compactSummaryMap.set(compactMsg, summaryText);
|
|
132
|
+
}
|
|
133
|
+
lastCompactBoundaryIdx = -1;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
lastCompactBoundaryIdx = -1;
|
|
138
|
+
|
|
139
|
+
const toolResults = extractToolResults(message.message.content);
|
|
61
140
|
if (toolResults.length > 0) {
|
|
62
141
|
// Group tool_result with corresponding tool_use
|
|
63
142
|
toolResults.forEach((toolResult: any) => {
|
|
@@ -73,20 +152,22 @@ export function groupMessages(messages: SDKMessageFormatter[]): {
|
|
|
73
152
|
// Regular user message
|
|
74
153
|
groups.push(message as ProcessedMessage);
|
|
75
154
|
}
|
|
155
|
+
return;
|
|
76
156
|
}
|
|
157
|
+
|
|
77
158
|
// Include stream_event and other messages
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
159
|
+
lastCompactBoundaryIdx = -1;
|
|
160
|
+
groups.push(message as ProcessedMessage);
|
|
81
161
|
});
|
|
82
162
|
|
|
83
|
-
return { groups, toolUseMap };
|
|
163
|
+
return { groups, toolUseMap, subAgentMap };
|
|
84
164
|
}
|
|
85
165
|
|
|
86
166
|
// Add tool results to messages
|
|
87
167
|
export function embedToolResults(
|
|
88
168
|
groups: ProcessedMessage[],
|
|
89
|
-
toolUseMap: Map<string, ToolGroup
|
|
169
|
+
toolUseMap: Map<string, ToolGroup>,
|
|
170
|
+
subAgentMap: Map<string, SDKMessageFormatter[]>
|
|
90
171
|
): ProcessedMessage[] {
|
|
91
172
|
// Track background bash sessions
|
|
92
173
|
const backgroundBashMap = trackBackgroundBashSessions(groups, toolUseMap);
|
|
@@ -100,7 +181,8 @@ export function embedToolResults(
|
|
|
100
181
|
const processedMessage = processToolMessage(
|
|
101
182
|
message,
|
|
102
183
|
toolUseMap,
|
|
103
|
-
backgroundBashMap
|
|
184
|
+
backgroundBashMap,
|
|
185
|
+
subAgentMap
|
|
104
186
|
);
|
|
105
187
|
return processedMessage;
|
|
106
188
|
}
|
|
@@ -216,4 +298,4 @@ function trackBashOutput(
|
|
|
216
298
|
bashData.bashOutputs.push(toolResult);
|
|
217
299
|
}
|
|
218
300
|
}
|
|
219
|
-
}
|
|
301
|
+
}
|