@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.
Files changed (54) hide show
  1. package/backend/index.ts +28 -10
  2. package/backend/lib/chat/stream-manager.ts +130 -10
  3. package/backend/lib/database/queries/message-queries.ts +47 -0
  4. package/backend/lib/engine/adapters/claude/stream.ts +65 -1
  5. package/backend/lib/engine/adapters/opencode/message-converter.ts +35 -77
  6. package/backend/lib/engine/types.ts +6 -0
  7. package/backend/lib/files/file-operations.ts +2 -2
  8. package/backend/lib/files/file-reading.ts +2 -2
  9. package/backend/lib/files/path-browsing.ts +2 -2
  10. package/backend/lib/terminal/pty-session-manager.ts +1 -1
  11. package/backend/lib/terminal/shell-utils.ts +4 -4
  12. package/backend/lib/terminal/stream-manager.ts +6 -3
  13. package/backend/ws/chat/background.ts +3 -0
  14. package/backend/ws/chat/stream.ts +43 -1
  15. package/backend/ws/terminal/session.ts +48 -0
  16. package/bin/clopen.ts +10 -0
  17. package/bun.lock +258 -383
  18. package/frontend/lib/components/chat/ChatInterface.svelte +8 -1
  19. package/frontend/lib/components/chat/formatters/MessageFormatter.svelte +20 -0
  20. package/frontend/lib/components/chat/formatters/TextMessage.svelte +3 -15
  21. package/frontend/lib/components/chat/formatters/Tools.svelte +15 -8
  22. package/frontend/lib/components/chat/input/ChatInput.svelte +70 -21
  23. package/frontend/lib/components/chat/input/components/LoadingIndicator.svelte +23 -11
  24. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +1 -1
  25. package/frontend/lib/components/chat/message/ChatMessage.svelte +13 -1
  26. package/frontend/lib/components/chat/message/ChatMessages.svelte +2 -2
  27. package/frontend/lib/components/chat/message/DateSeparator.svelte +1 -1
  28. package/frontend/lib/components/chat/message/MessageBubble.svelte +2 -2
  29. package/frontend/lib/components/chat/message/MessageHeader.svelte +14 -12
  30. package/frontend/lib/components/chat/tools/AgentTool.svelte +95 -0
  31. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +396 -0
  32. package/frontend/lib/components/chat/tools/BashTool.svelte +9 -4
  33. package/frontend/lib/components/chat/tools/EnterPlanModeTool.svelte +24 -0
  34. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +4 -7
  35. package/frontend/lib/components/chat/tools/{KillShellTool.svelte → TaskStopTool.svelte} +6 -6
  36. package/frontend/lib/components/chat/tools/index.ts +5 -2
  37. package/frontend/lib/components/checkpoint/TimelineModal.svelte +7 -2
  38. package/frontend/lib/components/history/HistoryModal.svelte +13 -5
  39. package/frontend/lib/components/workspace/DesktopNavigator.svelte +2 -1
  40. package/frontend/lib/components/workspace/MobileNavigator.svelte +2 -1
  41. package/frontend/lib/services/chat/chat.service.ts +146 -12
  42. package/frontend/lib/services/terminal/project.service.ts +65 -10
  43. package/frontend/lib/services/terminal/terminal.service.ts +19 -0
  44. package/frontend/lib/stores/core/app.svelte.ts +77 -0
  45. package/frontend/lib/stores/features/terminal.svelte.ts +10 -0
  46. package/frontend/lib/utils/chat/message-grouper.ts +94 -12
  47. package/frontend/lib/utils/chat/message-processor.ts +37 -4
  48. package/frontend/lib/utils/chat/tool-handler.ts +96 -5
  49. package/package.json +4 -5
  50. package/shared/constants/engines.ts +1 -1
  51. package/shared/types/database/schema.ts +1 -0
  52. package/shared/types/messaging/index.ts +15 -13
  53. package/shared/types/messaging/tool.ts +185 -361
  54. package/shared/utils/message-formatter.ts +1 -0
@@ -12,7 +12,7 @@
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 } from '$frontend/lib/stores/core/app.svelte';
15
+ import { appState, getSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
16
16
  import { presenceState } 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';
@@ -310,11 +310,18 @@
310
310
  </div>
311
311
  {/if}
312
312
  {#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}
313
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">
314
320
  <span class="w-1.5 h-1.5 rounded-full bg-violet-500 animate-pulse"></span>
315
321
  AI
316
322
  </span>
317
323
  {/if}
324
+ {/if}
318
325
  </button>
319
326
  {/each}
320
327
  </div>
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { SDKMessage, SDKPartialAssistantMessage } from '$shared/types/messaging';
11
11
  import type { SDKMessageFormatter } from '$shared/types/database/schema';
12
+ import { getCompactSummary } from '$frontend/lib/utils/chat/message-grouper';
12
13
 
13
14
  const { message }: { message: SDKMessageFormatter } = $props();
14
15
 
@@ -39,6 +40,25 @@
39
40
  function parseContent() {
40
41
  const elements: ContentElement[] = [];
41
42
 
43
+ // Handle compact boundary messages (conversation compaction indicator)
44
+ if (message.type === 'system' && (message as any).subtype === 'compact_boundary') {
45
+ const compactMeta = (message as any).compact_metadata;
46
+ const trigger = compactMeta?.trigger === 'manual' ? 'Manual' : 'Auto';
47
+ elements.push({
48
+ type: 'text',
49
+ content: `Context was compacted (${trigger}). Previous messages have been summarized to free up context space.`
50
+ });
51
+ // Include synthetic user content (continuation summary) via side-channel lookup
52
+ const summary = getCompactSummary(message);
53
+ if (summary) {
54
+ elements.push({
55
+ type: 'text',
56
+ content: summary
57
+ });
58
+ }
59
+ return elements;
60
+ }
61
+
42
62
  // Handle partial messages (streaming) — both text and reasoning
43
63
  if (message.type === 'stream_event') {
44
64
  if ('partialText' in message && message.partialText) {
@@ -91,8 +91,7 @@
91
91
  const processedContent = $derived(processContent(content));
92
92
  </script>
93
93
 
94
- <div class="message-content">
95
- <!-- eslint-disable-next-line svelte/no-at-html-tags -->
94
+ <div class="message-content space-y-4 wrap-break-word">
96
95
  {@html processedContent}
97
96
  </div>
98
97
 
@@ -101,7 +100,6 @@
101
100
  :global(.message-content) {
102
101
  color: rgb(30 41 59);
103
102
  line-height: 1.5;
104
- margin-bottom: -1rem;
105
103
  }
106
104
 
107
105
  :global(.dark .message-content) {
@@ -109,21 +107,11 @@
109
107
  }
110
108
 
111
109
  /* Headings */
112
- :global(.message-content h1:first-child),
113
- :global(.message-content h2:first-child),
114
- :global(.message-content h3:first-child),
115
- :global(.message-content h4:first-child),
116
- :global(.message-content h5:first-child),
117
- :global(.message-content h6:first-child) {
110
+ :global(.message-content *:first-child) {
118
111
  margin-top: 0;
119
112
  }
120
113
 
121
- :global(.message-content h1:last-child),
122
- :global(.message-content h2:last-child),
123
- :global(.message-content h3:last-child),
124
- :global(.message-content h4:last-child),
125
- :global(.message-content h5:last-child),
126
- :global(.message-content h6:last-child) {
114
+ :global(.message-content *:last-child) {
127
115
  margin-bottom: 0;
128
116
  }
129
117
 
@@ -2,11 +2,12 @@
2
2
  import Icon from '$frontend/lib/components/common/Icon.svelte';
3
3
  import type { ToolInput } from '$shared/types/messaging';
4
4
  import {
5
- BashTool, BashOutputTool, EditTool, ExitPlanModeTool,
6
- GlobTool, GrepTool, KillShellTool, ListMcpResourcesTool,
5
+ BashTool, BashOutputTool, EditTool, EnterPlanModeTool, ExitPlanModeTool,
6
+ GlobTool, GrepTool, TaskStopTool, ListMcpResourcesTool,
7
7
  NotebookEditTool,
8
- ReadTool, ReadMcpResourceTool, TaskTool, TodoWriteTool,
9
- WebFetchTool, WebSearchTool, WriteTool, CustomMcpTool
8
+ ReadTool, ReadMcpResourceTool, AgentTool, TaskTool, TodoWriteTool,
9
+ WebFetchTool, WebSearchTool, WriteTool, CustomMcpTool,
10
+ AskUserQuestionTool
10
11
  } from '../tools';
11
12
 
12
13
  const { toolInput }: { toolInput: ToolInput } = $props();
@@ -32,14 +33,16 @@
32
33
  <BashOutputTool {toolInput} />
33
34
  {:else if toolInput.name === 'Edit'}
34
35
  <EditTool {toolInput} />
36
+ {:else if toolInput.name === 'EnterPlanMode'}
37
+ <EnterPlanModeTool {toolInput} />
35
38
  {:else if toolInput.name === 'ExitPlanMode'}
36
39
  <ExitPlanModeTool {toolInput} />
37
40
  {:else if toolInput.name === 'Glob'}
38
41
  <GlobTool {toolInput} />
39
42
  {:else if toolInput.name === 'Grep'}
40
43
  <GrepTool {toolInput} />
41
- {:else if toolInput.name === 'KillShell'}
42
- <KillShellTool {toolInput} />
44
+ {:else if toolInput.name === 'TaskStop'}
45
+ <TaskStopTool {toolInput} />
43
46
  {:else if toolInput.name === 'ListMcpResources'}
44
47
  <ListMcpResourcesTool {toolInput} />
45
48
  {:else if toolInput.name === 'NotebookEdit'}
@@ -48,6 +51,8 @@
48
51
  <ReadTool {toolInput} />
49
52
  {:else if toolInput.name === 'ReadMcpResource'}
50
53
  <ReadMcpResourceTool {toolInput} />
54
+ {:else if toolInput.name === 'Agent'}
55
+ <AgentTool {toolInput} />
51
56
  {:else if toolInput.name === 'Task'}
52
57
  <TaskTool {toolInput} />
53
58
  {:else if toolInput.name === 'WebFetch'}
@@ -56,8 +61,10 @@
56
61
  <WebSearchTool {toolInput} />
57
62
  {:else if toolInput.name === 'Write'}
58
63
  <WriteTool {toolInput} />
64
+ {:else if toolInput.name === 'AskUserQuestion'}
65
+ <AskUserQuestionTool {toolInput} />
59
66
  {:else}
60
- <!-- Generic fallback for unknown tools -->
67
+ <!-- Generic fallback for unknown tools (Config, EnterWorktree, etc.) -->
61
68
  <div class="bg-slate-50 dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg p-4 mb-4">
62
69
  <div class="flex items-center gap-2 mb-2">
63
70
  <Icon name="lucide:wrench" class="text-green-600 dark:text-green-400 w-5 h-5" />
@@ -67,4 +74,4 @@
67
74
  <pre class="text-xs text-slate-600 dark:text-slate-400 whitespace-pre-wrap font-mono">{JSON.stringify((toolInput as any).input, null, 2)}</pre>
68
75
  </div>
69
76
  </div>
70
- {/if}
77
+ {/if}
@@ -146,6 +146,9 @@
146
146
  if (chatBlockedReason === 'no-model') {
147
147
  return 'No model selected. Please select a model to start chatting.';
148
148
  }
149
+ if (appState.isWaitingInput) {
150
+ return 'Answer the question above to continue...';
151
+ }
149
152
  return placeholderAnimation.placeholderText;
150
153
  });
151
154
 
@@ -238,8 +241,10 @@
238
241
  async function catchupActiveStream(status: any) {
239
242
  if (!status?.streams?.length || !sessionState.currentSession?.id) return;
240
243
 
241
- // Find the active stream
242
- const activeStream = status.streams.find((s: any) => s.status === 'active');
244
+ // Find the active stream for the current session
245
+ const activeStream = status.streams.find(
246
+ (s: any) => s.status === 'active' && s.chatSessionId === sessionState.currentSession?.id
247
+ );
243
248
  if (!activeStream) return;
244
249
 
245
250
  try {
@@ -248,27 +253,67 @@
248
253
  });
249
254
 
250
255
  if (streamState && streamState.status === 'active' && streamState.processId) {
251
- // Check if we already have a streaming message for this processId
252
- const hasStreamingMessage = sessionState.messages.some(
253
- (m: any) => m.type === 'stream_event' && m.processId === streamState.processId
254
- );
256
+ // ── Inject reasoning stream_event (if available) ──
257
+ if (streamState.currentReasoningText) {
258
+ const hasReasoningStream = sessionState.messages.some(
259
+ (m: any) => m.type === 'stream_event' && m.metadata?.reasoning && m.processId === streamState.processId
260
+ );
261
+
262
+ if (!hasReasoningStream) {
263
+ const reasoningMessage = {
264
+ type: 'stream_event' as const,
265
+ processId: streamState.processId,
266
+ partialText: streamState.currentReasoningText,
267
+ metadata: { reasoning: true }
268
+ };
269
+ (sessionState.messages as any[]).push(reasoningMessage);
270
+ } else {
271
+ const existingReasoning = sessionState.messages.find(
272
+ (m: any) => m.type === 'stream_event' && m.metadata?.reasoning && m.processId === streamState.processId
273
+ );
274
+ if (existingReasoning) {
275
+ (existingReasoning as any).partialText = streamState.currentReasoningText;
276
+ }
277
+ }
278
+ }
255
279
 
256
- if (!hasStreamingMessage) {
257
- // Inject streaming message with the current partial text from the server
258
- const streamingMessage = {
259
- type: 'stream_event' as const,
260
- processId: streamState.processId,
261
- partialText: streamState.currentPartialText || '',
262
- timestamp: new Date().toISOString()
263
- };
264
- (sessionState.messages as any[]).push(streamingMessage);
265
- } else {
266
- // Update existing stream_event with latest partial text
267
- const existingMsg = sessionState.messages.find(
280
+ // ── Inject regular text stream_event (if available) ──
281
+ if (streamState.currentPartialText) {
282
+ const hasTextStream = sessionState.messages.some(
283
+ (m: any) => m.type === 'stream_event' && !m.metadata?.reasoning && m.processId === streamState.processId
284
+ );
285
+
286
+ if (!hasTextStream) {
287
+ const streamingMessage = {
288
+ type: 'stream_event' as const,
289
+ processId: streamState.processId,
290
+ partialText: streamState.currentPartialText,
291
+ metadata: {}
292
+ };
293
+ (sessionState.messages as any[]).push(streamingMessage);
294
+ } else {
295
+ const existingMsg = sessionState.messages.find(
296
+ (m: any) => m.type === 'stream_event' && !m.metadata?.reasoning && m.processId === streamState.processId
297
+ );
298
+ if (existingMsg) {
299
+ (existingMsg as any).partialText = streamState.currentPartialText;
300
+ }
301
+ }
302
+ }
303
+
304
+ // If neither text nor reasoning is available yet, inject an empty
305
+ // stream_event placeholder so the loading indicator is visible
306
+ if (!streamState.currentPartialText && !streamState.currentReasoningText) {
307
+ const hasAnyStream = sessionState.messages.some(
268
308
  (m: any) => m.type === 'stream_event' && m.processId === streamState.processId
269
309
  );
270
- if (existingMsg && streamState.currentPartialText) {
271
- (existingMsg as any).partialText = streamState.currentPartialText;
310
+ if (!hasAnyStream) {
311
+ (sessionState.messages as any[]).push({
312
+ type: 'stream_event' as const,
313
+ processId: streamState.processId,
314
+ partialText: '',
315
+ metadata: {}
316
+ });
272
317
  }
273
318
  }
274
319
 
@@ -278,9 +323,13 @@
278
323
  streamState.processId
279
324
  );
280
325
 
326
+ // Detect if an interactive tool (e.g. AskUserQuestion) is pending in existing messages
327
+ chatService.detectPendingInteractiveTools();
328
+
281
329
  debug.log('chat', 'Caught up with active stream:', {
282
330
  processId: streamState.processId,
283
- partialLength: streamState.currentPartialText?.length || 0
331
+ partialLength: streamState.currentPartialText?.length || 0,
332
+ reasoningLength: streamState.currentReasoningText?.length || 0
284
333
  });
285
334
  }
286
335
  } catch (error) {
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { appState } from '$frontend/lib/stores/core/app.svelte';
3
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
3
4
  import { fly } from 'svelte/transition';
4
5
 
5
6
  interface Props {
@@ -15,17 +16,28 @@
15
16
  class="absolute z-20 {isWelcomeState ? '-top-16' : '-top-14'} left-0 right-0 flex justify-center pointer-events-none"
16
17
  transition:fly={{ y: 100, duration: 300 }}
17
18
  >
18
- <div class="flex items-center gap-2.5 px-4 py-2 bg-slate-100 dark:bg-slate-800 rounded-full border border-slate-300 dark:border-slate-600 shadow-sm">
19
- <!-- Simple spinner -->
20
- <svg class="animate-spin h-4 w-4 text-slate-700 dark:text-slate-300" viewBox="0 0 24 24">
21
- <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
22
- <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
23
- </svg>
19
+ {#if appState.isWaitingInput}
20
+ <!-- Waiting for user input state -->
21
+ <div class="flex items-center gap-2.5 px-4 py-2 bg-amber-50 dark:bg-amber-900/30 rounded-full border border-amber-300 dark:border-amber-700 shadow-sm">
22
+ <Icon name="lucide:message-circle-question-mark" class="w-4 h-4 text-amber-600 dark:text-amber-400" />
23
+ <span class="text-sm font-medium text-amber-700 dark:text-amber-300">
24
+ Waiting for your input...
25
+ </span>
26
+ </div>
27
+ {:else}
28
+ <!-- Normal loading state -->
29
+ <div class="flex items-center gap-2.5 px-4 py-2 bg-slate-100 dark:bg-slate-800 rounded-full border border-slate-300 dark:border-slate-600 shadow-sm">
30
+ <!-- Simple spinner -->
31
+ <svg class="animate-spin h-4 w-4 text-slate-700 dark:text-slate-300" viewBox="0 0 24 24">
32
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
33
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
34
+ </svg>
24
35
 
25
- <!-- Text with typewriter effect -->
26
- <span class="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">
27
- {visibleLoadingText}
28
- </span>
29
- </div>
36
+ <!-- Text with typewriter effect -->
37
+ <span class="text-sm font-medium text-slate-700 dark:text-slate-300 capitalize">
38
+ {visibleLoadingText}
39
+ </span>
40
+ </div>
41
+ {/if}
30
42
  </div>
31
43
  {/if}
@@ -34,7 +34,7 @@ export function useChatActions(params: ChatActionsParams) {
34
34
 
35
35
  // Handle send message with SDK streaming
36
36
  async function sendMessage(messageText: string, setMessageText: (value: string) => void) {
37
- if ((!messageText.trim() && params.attachedFiles.length === 0) || appState.isLoading) return;
37
+ if ((!messageText.trim() && params.attachedFiles.length === 0) || appState.isLoading || appState.isWaitingInput) return;
38
38
 
39
39
  // Initialize sound notifications on first user interaction (browser policy requirement)
40
40
  soundNotification.initialize();
@@ -61,6 +61,10 @@
61
61
  const shouldBeDimmed = $derived(shouldDimMessage(messageId));
62
62
 
63
63
  const roleCategory = $derived.by(() => {
64
+ // Compact boundary messages (conversation compaction indicator)
65
+ if (message.type === 'system' && (message as any).subtype === 'compact_boundary') {
66
+ return 'compact';
67
+ }
64
68
  // Reasoning messages (from both engines)
65
69
  if (message.metadata?.reasoning) {
66
70
  return 'reasoning';
@@ -164,7 +168,7 @@
164
168
  // Detect agent processing status
165
169
  // When stream is no longer active (not loading) and tools don't have results,
166
170
  // mark them as failed (cancelled/interrupted) instead of perpetually "processing"
167
- const agentStatus = $derived.by((): 'processing' | 'success' | 'error' | null => {
171
+ const agentStatus = $derived.by((): 'processing' | 'waiting' | 'success' | 'error' | null => {
168
172
  if (roleCategory !== 'agent') return null;
169
173
 
170
174
  if (message.type === 'assistant' && 'message' in message && Array.isArray(message.message.content)) {
@@ -176,6 +180,8 @@
176
180
  const allHaveResults = toolUses.every((tool: any) => '$result' in tool && tool.$result);
177
181
 
178
182
  if (!allHaveResults) {
183
+ // If stream is active and waiting for user input → show as waiting
184
+ if (appState.isWaitingInput) return 'waiting';
179
185
  // If stream is still active, tools are processing
180
186
  // If stream ended (not loading), tools were interrupted/cancelled → show as error
181
187
  return appState.isLoading ? 'processing' : 'error';
@@ -191,6 +197,12 @@
191
197
 
192
198
  const roleConfig = $derived.by((): { gradient: string; icon: IconName; name: string } => {
193
199
  switch (roleCategory) {
200
+ case 'compact':
201
+ return {
202
+ gradient: 'from-amber-500 to-orange-600',
203
+ icon: 'lucide:layers',
204
+ name: 'System'
205
+ };
194
206
  case 'reasoning':
195
207
  return {
196
208
  gradient: 'from-emerald-500 to-green-600',
@@ -48,8 +48,8 @@
48
48
 
49
49
  // Process messages through grouping and embedding
50
50
  const processedMessages = $derived.by(() => {
51
- const { groups, toolUseMap } = groupMessages(sessionState.messages);
52
- return embedToolResults(groups, toolUseMap);
51
+ const { groups, toolUseMap, subAgentMap } = groupMessages(sessionState.messages);
52
+ return embedToolResults(groups, toolUseMap, subAgentMap);
53
53
  });
54
54
 
55
55
  // Filter out messages with empty content arrays
@@ -62,7 +62,7 @@
62
62
  </script>
63
63
 
64
64
  <!-- Date Separator -->
65
- <div class="flex items-center justify-center my-3 md:my-4 px-4 md:px-6 select-none">
65
+ <div class="flex items-center justify-center mt-6 mb-3 md:mt-8 md:mb-4 px-4 md:px-6 select-none">
66
66
  <div class="relative flex items-center w-full max-w-md">
67
67
  <!-- Left line -->
68
68
  <div
@@ -36,7 +36,7 @@
36
36
  isLastUserMessage?: boolean;
37
37
  roleConfig: { gradient: string; icon: IconName; name: string };
38
38
  roleCategory: 'user' | 'assistant' | 'agent' | string;
39
- agentStatus: 'processing' | 'success' | 'error' | null;
39
+ agentStatus: 'processing' | 'waiting' | 'success' | 'error' | null;
40
40
  senderName: string | null;
41
41
  hasTokenUsageData: any;
42
42
  formatTime: (timestamp?: string) => string;
@@ -74,7 +74,7 @@
74
74
 
75
75
  <!-- Message Content -->
76
76
  <div class="p-3 md:p-4">
77
- <div class="max-w-none">
77
+ <div class="max-w-none space-y-4">
78
78
  <!-- Content rendering using MessageFormatter component -->
79
79
  <MessageFormatter {message} />
80
80
  </div>
@@ -35,7 +35,7 @@
35
35
  isLastUserMessage?: boolean;
36
36
  roleConfig: { gradient: string; icon: IconName; name: string };
37
37
  roleCategory: 'user' | 'assistant' | 'agent' | string;
38
- agentStatus: 'processing' | 'success' | 'error' | null;
38
+ agentStatus: 'processing' | 'waiting' | 'success' | 'error' | null;
39
39
  senderName: string | null;
40
40
  hasTokenUsageData: any;
41
41
  formatTime: (timestamp?: string) => string;
@@ -59,7 +59,7 @@
59
59
  }
60
60
  </script>
61
61
 
62
- <div class="flex items-center justify-between gap-5 px-3 md:px-4 py-1 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800">
62
+ <div class="flex items-center justify-between gap-5 h-8 px-3 md:px-4 py-1 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800">
63
63
  <!-- Timestamp and Type -->
64
64
  <div class="flex items-center gap-2">
65
65
  <span class="text-xs text-slate-500 dark:text-slate-400 font-medium">
@@ -76,6 +76,10 @@
76
76
  <span title="Agent is processing...">
77
77
  <Icon name="lucide:loader" class="w-3 h-3 text-violet-500 dark:text-violet-400 animate-spin" />
78
78
  </span>
79
+ {:else if agentStatus === 'waiting'}
80
+ <span title="Waiting for your input...">
81
+ <Icon name="lucide:message-circle-question-mark" class="w-3 h-3 text-amber-500 dark:text-amber-400" />
82
+ </span>
79
83
  {:else if agentStatus === 'success'}
80
84
  <span title="Agent completed successfully">
81
85
  <Icon name="lucide:check" class="w-3 h-3 text-green-500 dark:text-green-400" />
@@ -143,15 +147,13 @@
143
147
  {/if}
144
148
 
145
149
  <!-- Debug toggle button -->
146
- {#if 'parent_tool_use_id' in message}
147
- <button
148
- onclick={onShowDebug}
149
- class="inline-flex p-1.5 rounded-md hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors opacity-60 hover:opacity-100"
150
- aria-label="Show debug info"
151
- title="Debug info"
152
- >
153
- <Icon name="lucide:bug" class="w-3.5 h-3.5" />
154
- </button>
155
- {/if}
150
+ <button
151
+ onclick={onShowDebug}
152
+ class="inline-flex p-1.5 rounded-md hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors opacity-60 hover:opacity-100"
153
+ aria-label="Show debug info"
154
+ title="Debug info"
155
+ >
156
+ <Icon name="lucide:bug" class="w-3.5 h-3.5" />
157
+ </button>
156
158
  </div>
157
159
  </div>
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import type { AgentToolInput, SubAgentActivity } from '$shared/types/messaging';
4
+ import { InfoLine } from './components';
5
+ import TextMessage from '../formatters/TextMessage.svelte';
6
+
7
+ const { toolInput }: { toolInput: AgentToolInput } = $props();
8
+
9
+ const description = toolInput.input.description || '';
10
+ const subagentType = toolInput.input.subagent_type || 'general-purpose';
11
+ const subMessages = (toolInput as any).$subMessages as SubAgentActivity[] | undefined;
12
+ const toolUseCount = subMessages?.filter(a => a.type === 'tool_use').length ?? 0;
13
+
14
+ let scrollContainer: HTMLDivElement | undefined = $state();
15
+
16
+ // Auto-scroll to bottom when new activities arrive
17
+ $effect(() => {
18
+ const _len = subMessages?.length ?? 0;
19
+ if (_len > 0 && scrollContainer) {
20
+ tick().then(() => {
21
+ if (scrollContainer) {
22
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
23
+ }
24
+ });
25
+ }
26
+ });
27
+
28
+ function getToolBrief(activity: SubAgentActivity): string {
29
+ if (!activity.toolInput) return '';
30
+ switch (activity.toolName) {
31
+ case 'Bash': return activity.toolInput.command || '';
32
+ case 'Read': return activity.toolInput.file_path || '';
33
+ case 'Write': return activity.toolInput.file_path || '';
34
+ case 'Edit': return activity.toolInput.file_path || '';
35
+ case 'Glob': return activity.toolInput.pattern || '';
36
+ case 'Grep': return activity.toolInput.pattern || '';
37
+ case 'WebFetch': return activity.toolInput.url || '';
38
+ case 'WebSearch': return activity.toolInput.query || '';
39
+ default: return '';
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <!-- Header card -->
45
+ <div class="bg-white dark:bg-slate-800 rounded-md border border-slate-200/60 dark:border-slate-700/60 p-3">
46
+ <div class="space-y-1">
47
+ <InfoLine icon="lucide:search" text={description} />
48
+ <InfoLine icon="lucide:bot" text="Using {subagentType} agent" />
49
+ </div>
50
+ </div>
51
+
52
+ <!-- Sub-agent tool calls (separate from header) -->
53
+ {#if subMessages && subMessages.length > 0}
54
+ <div class="bg-white dark:bg-slate-800 rounded-md border border-slate-200/60 dark:border-slate-700/60 p-3">
55
+ <div class="text-xs text-slate-500 dark:text-slate-400 mb-2">
56
+ {toolUseCount} tool {toolUseCount === 1 ? 'call' : 'calls'}:
57
+ </div>
58
+ <div bind:this={scrollContainer} class="max-h-64 overflow-y-auto">
59
+ <ul class="list-disc pl-5 space-y-0.5">
60
+ {#each subMessages as activity}
61
+ {#if activity.type === 'tool_use'}
62
+ <li class="text-xs text-slate-600 dark:text-slate-400">
63
+ <span class="font-medium">{activity.toolName}</span>
64
+ {#if getToolBrief(activity)}
65
+ <span class="text-slate-400 dark:text-slate-500 ml-1">{getToolBrief(activity)}</span>
66
+ {/if}
67
+ </li>
68
+ {:else if activity.type === 'text'}
69
+ <li class="text-xs text-slate-600 dark:text-slate-400 line-clamp-2">
70
+ {activity.text}
71
+ </li>
72
+ {/if}
73
+ {/each}
74
+ </ul>
75
+ </div>
76
+ </div>
77
+ {/if}
78
+
79
+ <!-- Tool Result -->
80
+ {#if toolInput.$result}
81
+ {@const resultContent = toolInput.$result.content as any}
82
+ <div class="mt-4">
83
+ {#if typeof resultContent === 'string'}
84
+ <TextMessage content={resultContent} />
85
+ {:else if Array.isArray(resultContent)}
86
+ {#each resultContent as block}
87
+ {#if typeof block === 'object' && block.type === 'text'}
88
+ <TextMessage content={block.text} />
89
+ {/if}
90
+ {/each}
91
+ {:else}
92
+ <TextMessage content={JSON.stringify(resultContent)} />
93
+ {/if}
94
+ </div>
95
+ {/if}