|
3 | 3 | import { type ComponentType, Fragment, useState } from 'react' |
4 | 4 | import { ActivityStatus } from '@/components/ui/activity-status' |
5 | 5 | import type { ToolActivity } from '@/lib/mothership/generated/protocol' |
6 | | -import { |
7 | | - CallIntegrationTool, |
8 | | - RunCode, |
9 | | - SearchWorkspace, |
10 | | -} from '@/lib/mothership/generated/tool-catalog-v1' |
11 | | -import { getToolActivitySummaryActions, readCallActivity } from '@/lib/mothership/tools/activity' |
12 | | -import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/display' |
13 | | -import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' |
| 6 | +import { readCallActivity } from '@/lib/mothership/tools/activity' |
14 | 7 | import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' |
15 | | -import { getNewestRunningTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' |
16 | 8 | import { getSearchActivitySources } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-details' |
17 | 9 | import { SearchActivityResults } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/search-activity-results' |
18 | | -import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' |
19 | 10 | import { |
20 | | - getActivityAttentionKey, |
21 | | - needsToolInput, |
22 | | -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions' |
| 11 | + getActivityHeaderTool, |
| 12 | + getActivityStatusTool, |
| 13 | + getCompletedActivityLabel, |
| 14 | + getInProgressActivityLabel, |
| 15 | + getToolTitle, |
| 16 | + isAwaitingTitle, |
| 17 | + withoutRetriedSearchFailures, |
| 18 | +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-labels' |
| 19 | +import type { ToolCallItemProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item' |
| 20 | +import { getActivityAttentionKey } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-interactions' |
23 | 21 | import { isToolDone } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' |
24 | | -import { type ToolCallData, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' |
25 | | - |
26 | | -/** A completed summary names at most this many distinct actions, and never counts the rest. */ |
27 | | -const MAX_SUMMARY_ACTIONS = 3 |
28 | | - |
29 | | -function isFailedTool(tool: ToolCallData): boolean { |
30 | | - return tool.status === ToolCallStatus.error || tool.status === ToolCallStatus.rejected |
31 | | -} |
32 | | - |
33 | | -/** |
34 | | - * Drops a search that errored when a later search follows it: the model corrected the query |
35 | | - * and searched again, so the failed attempt is not part of what the user reads. The last search |
36 | | - * always stays, so a failure that no search retried, or a run whose searches all failed, still |
37 | | - * shows its outcome. |
38 | | - */ |
39 | | -function withoutRetriedSearchFailures(tools: ToolCallData[]): ToolCallData[] { |
40 | | - let lastSearch = -1 |
41 | | - for (const [index, tool] of tools.entries()) { |
42 | | - if (tool.toolName === SearchWorkspace.id) lastSearch = index |
43 | | - } |
44 | | - return tools.filter( |
45 | | - (tool, index) => |
46 | | - index >= lastSearch || |
47 | | - tool.toolName !== SearchWorkspace.id || |
48 | | - tool.status !== ToolCallStatus.error |
49 | | - ) |
50 | | -} |
51 | | - |
52 | | -function toolCountLabel(tools: ToolCallData[]): string { |
53 | | - return `${tools.length} tool ${tools.length === 1 ? 'call' : 'calls'}` |
54 | | -} |
55 | | - |
56 | | -function getToolTitle(tool: ToolCallData): string { |
57 | | - return getToolStatusDisplayTitle( |
58 | | - tool.displayTitle, |
59 | | - tool.status, |
60 | | - tool.toolName, |
61 | | - tool.activityDescription |
62 | | - ) |
63 | | -} |
64 | | - |
65 | | -/** |
66 | | - * Summarize completed actions without describing failed or skipped work as |
67 | | - * successful: a single call, or the only successful one, keeps its own title; |
68 | | - * several successful calls name their distinct actions ("Navigated, read |
69 | | - * pages, clicked elements"); stopped or skipped calls append an outcome count. |
70 | | - */ |
71 | | -function getToolActivitySummary(tools: ToolCallData[]): string { |
72 | | - if (tools.length === 1) return getToolTitle(tools[0]) |
73 | | - const succeeded = tools.filter((tool) => tool.status === ToolCallStatus.success) |
74 | | - const [first, ...rest] = |
75 | | - succeeded.length === 1 |
76 | | - ? [getToolTitle(succeeded[0])] |
77 | | - : getToolActivitySummaryActions(succeeded, MAX_SUMMARY_ACTIONS) |
78 | | - const summary = first |
79 | | - ? [first.charAt(0).toUpperCase() + first.slice(1), ...rest].join(', ') |
80 | | - : toolCountLabel(tools) |
81 | | - return [summary, ...getToolActivityInterruptions(tools)].join(' · ') |
82 | | -} |
83 | | - |
84 | | -function getToolActivityInterruptions(tools: ToolCallData[]): string[] { |
85 | | - let stopped = 0 |
86 | | - let skipped = 0 |
87 | | - for (const tool of tools) { |
88 | | - if (tool.status === ToolCallStatus.cancelled || tool.status === ToolCallStatus.interrupted) |
89 | | - stopped++ |
90 | | - else if (tool.status === ToolCallStatus.skipped) skipped++ |
91 | | - } |
92 | | - return [...(stopped ? [`${stopped} stopped`] : []), ...(skipped ? [`${skipped} skipped`] : [])] |
93 | | -} |
94 | | - |
95 | | -/** |
96 | | - * An executing call whose streamed arguments do not name its action yet, so its |
97 | | - * title is only its tool's placeholder: a search before its arguments arrive, |
98 | | - * a CLI call before its final frame (the verb parsed mid-stream would only flash |
99 | | - * ahead of the model's description), a `run_code` call before its arguments |
100 | | - * resolve, while their parameters hold at most the activity, and an |
101 | | - * integration gateway call before its description streams ("Calling integration"). |
102 | | - * A call awaiting approval is never untitled, so its permission card keeps the |
103 | | - * header, and neither is one the model already described. |
104 | | - */ |
105 | | -export function isAwaitingTitle(tool: ToolCallData): boolean { |
106 | | - if (tool.status !== ToolCallStatus.executing || tool.activityDescription) return false |
107 | | - if (tool.toolName === CallIntegrationTool.id) { |
108 | | - const description = |
109 | | - tool.params?.description ?? extractStreamingStringArgument(tool.streamingArgs, 'description') |
110 | | - return !(typeof description === 'string' && description.trim()) |
111 | | - } |
112 | | - return ( |
113 | | - (tool.toolName === 'sim_cli' || |
114 | | - tool.toolName.startsWith('cli_') || |
115 | | - tool.toolName === RunCode.id || |
116 | | - tool.toolName === 'search_workspace') && |
117 | | - Object.keys(tool.params ?? {}).every((key) => key === 'activity') |
118 | | - ) |
119 | | -} |
120 | | - |
121 | | -/** |
122 | | - * The call an in-progress header describes, label and icon alike, shared by |
123 | | - * tool group and subagent headers so a live header never flips to placeholder |
124 | | - * text between steps: the status call, unless it is still awaiting its title, |
125 | | - * in which case the call the header described before it keeps the header |
126 | | - * until the new title arrives. A call waiting on the user is never held, since |
127 | | - * its permission card or handoff would replace the header, and neither is a |
128 | | - * failed call, which must not read as live. With no such earlier call, the |
129 | | - * status call. |
130 | | - */ |
131 | | -export function getActivityHeaderTool( |
132 | | - tools: ToolCallData[], |
133 | | - statusTool: ToolCallData |
134 | | -): ToolCallData { |
135 | | - if (!isAwaitingTitle(statusTool)) return statusTool |
136 | | - return ( |
137 | | - getActivityStatusTool( |
138 | | - tools |
139 | | - .slice( |
140 | | - 0, |
141 | | - tools.findIndex((tool) => tool.id === statusTool.id) |
142 | | - ) |
143 | | - .filter((tool) => !isAwaitingTitle(tool) && !needsToolInput(tool) && !isFailedTool(tool)) |
144 | | - ) ?? statusTool |
145 | | - ) |
146 | | -} |
147 | | - |
148 | | -/** The wait wording: the turn between steps, and a first call whose own title is still streaming. */ |
149 | | -export const THINKING_LABEL = 'Thinking' |
150 | | - |
151 | | -/** |
152 | | - * The title of an activity in progress for its header call, with earlier |
153 | | - * stops and skips kept visible. A header call still awaiting its title has no |
154 | | - * earlier call to hold, so it reads as the activity's intent when the model |
155 | | - * gave one, else as the wait it continues: a tool's placeholder ("Running CLI |
156 | | - * command") would flash between the turn's "Thinking" and the model's description. |
157 | | - */ |
158 | | -export function getInProgressActivityLabel( |
159 | | - activeLabel: string, |
160 | | - headerTool: ToolCallData, |
161 | | - tools: ToolCallData[], |
162 | | - activityTitle: string | undefined |
163 | | -): string { |
164 | | - const label = isAwaitingTitle(headerTool) ? activityTitle || THINKING_LABEL : activeLabel |
165 | | - return getActiveToolActivityTitle(label, headerTool, tools) |
166 | | -} |
167 | | - |
168 | | -/** Keep earlier interruptions visible while the latest action continues. */ |
169 | | -function getActiveToolActivityTitle( |
170 | | - label: string, |
171 | | - tool: ToolCallData, |
172 | | - tools: ToolCallData[] |
173 | | -): string { |
174 | | - return tool.status === ToolCallStatus.executing || tool.status === ToolCallStatus.success |
175 | | - ? [label, ...getToolActivityInterruptions(tools)].join(' · ') |
176 | | - : label |
177 | | -} |
178 | | - |
179 | | -/** |
180 | | - * The call a header describes: the newest running call, else the latest call |
181 | | - * that did not error or get rejected, so a failed attempt labels the activity |
182 | | - * only when every call failed. A stopped, skipped, or interrupted call can still be it. |
183 | | - */ |
184 | | -export function getActivityStatusTool(tools: ToolCallData[]): ToolCallData | undefined { |
185 | | - return ( |
186 | | - getNewestRunningTool(tools) ?? |
187 | | - tools.filter((tool) => !isFailedTool(tool)).at(-1) ?? |
188 | | - tools.at(-1) |
189 | | - ) |
190 | | -} |
191 | | - |
192 | | -/** |
193 | | - * The label of a finished activity: the model's completed title when every |
194 | | - * call succeeded, else the summary of what did succeed, so a stopped, skipped, |
195 | | - * or failed call never hides behind a success title. Main and subagent lanes |
196 | | - * share this rule. |
197 | | - */ |
198 | | -export function getCompletedActivityLabel( |
199 | | - tools: ToolCallData[], |
200 | | - activity: ToolActivity | undefined |
201 | | -): string { |
202 | | - return activity?.completedTitle && tools.every((tool) => tool.status === ToolCallStatus.success) |
203 | | - ? activity.completedTitle |
204 | | - : getToolActivitySummary(tools) |
205 | | -} |
| 22 | +import type { ToolCallData } from '@/app/workspace/[workspaceId]/home/types' |
| 23 | + |
| 24 | +export { |
| 25 | + getActivityHeaderTool, |
| 26 | + getActivityStatusTool, |
| 27 | + getCompletedActivityLabel, |
| 28 | + getInProgressActivityLabel, |
| 29 | + isAwaitingTitle, |
| 30 | + THINKING_LABEL, |
| 31 | +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-labels' |
206 | 32 |
|
207 | 33 | interface ToolActivityGroupProps { |
208 | 34 | activity?: ToolActivity |
|
0 commit comments