clawdex-mobile 2.0.1 → 3.0.0

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 (71) hide show
  1. package/.github/workflows/pages.yml +41 -0
  2. package/AGENTS.md +263 -110
  3. package/README.md +1 -1
  4. package/apps/mobile/.env.example +2 -2
  5. package/apps/mobile/App.tsx +175 -14
  6. package/apps/mobile/app.json +27 -9
  7. package/apps/mobile/eas.json +14 -4
  8. package/apps/mobile/package.json +13 -13
  9. package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
  10. package/apps/mobile/src/api/__tests__/client.test.ts +579 -6
  11. package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
  12. package/apps/mobile/src/api/account.ts +47 -0
  13. package/apps/mobile/src/api/chatMapping.ts +435 -18
  14. package/apps/mobile/src/api/client.ts +296 -36
  15. package/apps/mobile/src/api/rateLimits.ts +143 -0
  16. package/apps/mobile/src/api/types.ts +106 -0
  17. package/apps/mobile/src/api/ws.ts +10 -1
  18. package/apps/mobile/src/components/ChatHeader.tsx +12 -12
  19. package/apps/mobile/src/components/ChatInput.tsx +154 -88
  20. package/apps/mobile/src/components/ChatMessage.tsx +548 -93
  21. package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
  22. package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
  23. package/apps/mobile/src/components/ToolBlock.tsx +17 -15
  24. package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
  25. package/apps/mobile/src/components/WorkspacePickerModal.tsx +572 -0
  26. package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
  27. package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
  28. package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
  29. package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
  30. package/apps/mobile/src/components/chat-input-layout.ts +59 -0
  31. package/apps/mobile/src/components/chatImageSource.ts +86 -0
  32. package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
  33. package/apps/mobile/src/components/voiceWaveform.ts +46 -0
  34. package/apps/mobile/src/config.ts +9 -2
  35. package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
  36. package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
  37. package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
  38. package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
  39. package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
  40. package/apps/mobile/src/navigation/drawerChats.ts +9 -0
  41. package/apps/mobile/src/screens/GitScreen.tsx +2 -0
  42. package/apps/mobile/src/screens/MainScreen.tsx +4244 -1237
  43. package/apps/mobile/src/screens/OnboardingScreen.tsx +2 -0
  44. package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
  45. package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
  46. package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
  47. package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
  48. package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
  49. package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
  50. package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
  51. package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
  52. package/apps/mobile/src/screens/agentThreads.ts +167 -0
  53. package/apps/mobile/src/screens/planCardState.ts +40 -0
  54. package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
  55. package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
  56. package/apps/mobile/src/theme.ts +6 -12
  57. package/docs/codex-app-server-cli-gap-tracker.md +14 -5
  58. package/docs/privacy-policy.md +54 -0
  59. package/docs/setup-and-operations.md +4 -3
  60. package/docs/terms-of-service.md +33 -0
  61. package/package.json +3 -3
  62. package/services/mac-bridge/package.json +6 -6
  63. package/services/rust-bridge/Cargo.lock +56 -47
  64. package/services/rust-bridge/Cargo.toml +1 -1
  65. package/services/rust-bridge/package.json +1 -1
  66. package/services/rust-bridge/src/main.rs +507 -9
  67. package/site/index.html +54 -0
  68. package/site/privacy/index.html +80 -0
  69. package/site/styles.css +135 -0
  70. package/site/support/index.html +51 -0
  71. package/site/terms/index.html +68 -0
@@ -0,0 +1,261 @@
1
+ import type { ComponentProps } from 'react';
2
+ import type { Ionicons } from '@expo/vector-icons';
3
+
4
+ import type { ChatSummary } from '../api/types';
5
+ import type { ActivityTone } from '../components/ActivityBar';
6
+ import { colors } from '../theme';
7
+
8
+ type IoniconName = ComponentProps<typeof Ionicons>['name'];
9
+
10
+ const AGENT_ACCENT_PALETTE = [
11
+ '#F5A524',
12
+ '#4CC9F0',
13
+ '#7ED957',
14
+ '#FF8A65',
15
+ '#F472B6',
16
+ '#8BD3DD',
17
+ ] as const;
18
+
19
+ const RUNNING_STATUS_COLOR = '#7EE787';
20
+ const WAITING_STATUS_COLOR = '#F5A524';
21
+ const COMPLETE_STATUS_COLOR = '#93C5FD';
22
+
23
+ export interface AgentThreadRuntimeSnapshotLike {
24
+ activity?: {
25
+ tone: ActivityTone;
26
+ title: string;
27
+ detail?: string;
28
+ };
29
+ activeCommands?: unknown[];
30
+ pendingApproval?: unknown | null;
31
+ pendingUserInputRequest?: unknown | null;
32
+ activeTurnId?: string | null;
33
+ runWatchdogUntil?: number;
34
+ updatedAtMs?: number;
35
+ }
36
+
37
+ export interface AgentThreadDisplayState {
38
+ icon: IoniconName;
39
+ label: string;
40
+ detail: string | null;
41
+ tone: ActivityTone;
42
+ accentColor: string;
43
+ statusColor: string;
44
+ statusSurfaceColor: string;
45
+ statusBorderColor: string;
46
+ isActive: boolean;
47
+ }
48
+
49
+ export function buildAgentThreadDisplayState(
50
+ chat: ChatSummary,
51
+ snapshot: AgentThreadRuntimeSnapshotLike | null | undefined,
52
+ nowMs = Date.now()
53
+ ): AgentThreadDisplayState {
54
+ const accentColor = getAgentThreadAccentColor(chat.id);
55
+ const status = resolveAgentRuntimeStatus(chat, snapshot, nowMs);
56
+
57
+ return {
58
+ ...status,
59
+ accentColor,
60
+ };
61
+ }
62
+
63
+ export function getAgentThreadAccentColor(threadId: string): string {
64
+ let hash = 0;
65
+ for (let index = 0; index < threadId.length; index += 1) {
66
+ hash = (hash * 33 + threadId.charCodeAt(index)) >>> 0;
67
+ }
68
+
69
+ return AGENT_ACCENT_PALETTE[hash % AGENT_ACCENT_PALETTE.length];
70
+ }
71
+
72
+ function resolveAgentRuntimeStatus(
73
+ chat: ChatSummary,
74
+ snapshot: AgentThreadRuntimeSnapshotLike | null | undefined,
75
+ nowMs: number
76
+ ): Omit<AgentThreadDisplayState, 'accentColor'> {
77
+ const activity = snapshot?.activity;
78
+ const activityTitle = normalizeValue(activity?.title);
79
+ const activityDetail = normalizeValue(activity?.detail);
80
+ const hasActiveTurn = Boolean(snapshot?.activeTurnId);
81
+ const watchdogActive =
82
+ typeof snapshot?.runWatchdogUntil === 'number' && snapshot.runWatchdogUntil > nowMs;
83
+ const hasActiveCommands = (snapshot?.activeCommands?.length ?? 0) > 0;
84
+ const needsApproval = snapshot?.pendingApproval != null;
85
+ const needsInput = snapshot?.pendingUserInputRequest != null;
86
+
87
+ if (chat.status === 'error' || activity?.tone === 'error') {
88
+ return {
89
+ icon: 'alert-circle-outline',
90
+ label: 'Error',
91
+ detail:
92
+ activityDetail ??
93
+ normalizeErrorActivityTitle(activityTitle) ??
94
+ normalizeValue(chat.lastError) ??
95
+ null,
96
+ tone: 'error',
97
+ statusColor: colors.statusError,
98
+ statusSurfaceColor: 'rgba(239, 68, 68, 0.16)',
99
+ statusBorderColor: 'rgba(239, 68, 68, 0.42)',
100
+ isActive: false,
101
+ };
102
+ }
103
+
104
+ if (needsApproval) {
105
+ return {
106
+ icon: 'hand-left-outline',
107
+ label: 'Needs approval',
108
+ detail: activityDetail ?? normalizeRunningDetail(activityTitle),
109
+ tone: 'running',
110
+ statusColor: WAITING_STATUS_COLOR,
111
+ statusSurfaceColor: 'rgba(245, 165, 36, 0.16)',
112
+ statusBorderColor: 'rgba(245, 165, 36, 0.4)',
113
+ isActive: true,
114
+ };
115
+ }
116
+
117
+ if (needsInput) {
118
+ return {
119
+ icon: 'help-circle-outline',
120
+ label: 'Needs input',
121
+ detail: activityDetail ?? normalizeRunningDetail(activityTitle),
122
+ tone: 'running',
123
+ statusColor: WAITING_STATUS_COLOR,
124
+ statusSurfaceColor: 'rgba(245, 165, 36, 0.16)',
125
+ statusBorderColor: 'rgba(245, 165, 36, 0.4)',
126
+ isActive: true,
127
+ };
128
+ }
129
+
130
+ if (
131
+ activity?.tone === 'running' ||
132
+ chat.status === 'running' ||
133
+ hasActiveTurn ||
134
+ watchdogActive ||
135
+ hasActiveCommands
136
+ ) {
137
+ const label = normalizeRunningLabel(activityTitle);
138
+ return {
139
+ icon: runningIconForLabel(label),
140
+ label,
141
+ detail: activityDetail,
142
+ tone: 'running',
143
+ statusColor: RUNNING_STATUS_COLOR,
144
+ statusSurfaceColor: 'rgba(126, 231, 135, 0.14)',
145
+ statusBorderColor: 'rgba(126, 231, 135, 0.34)',
146
+ isActive: true,
147
+ };
148
+ }
149
+
150
+ if (chat.status === 'complete' || activity?.tone === 'complete') {
151
+ return {
152
+ icon: 'checkmark-circle-outline',
153
+ label: 'Complete',
154
+ detail:
155
+ activityDetail ??
156
+ normalizeCompleteActivityTitle(activityTitle) ??
157
+ null,
158
+ tone: 'complete',
159
+ statusColor: COMPLETE_STATUS_COLOR,
160
+ statusSurfaceColor: 'rgba(147, 197, 253, 0.15)',
161
+ statusBorderColor: 'rgba(147, 197, 253, 0.34)',
162
+ isActive: false,
163
+ };
164
+ }
165
+
166
+ return {
167
+ icon: 'ellipse-outline',
168
+ label: 'Idle',
169
+ detail: null,
170
+ tone: 'idle',
171
+ statusColor: colors.statusIdle,
172
+ statusSurfaceColor: 'rgba(180, 188, 203, 0.12)',
173
+ statusBorderColor: 'rgba(180, 188, 203, 0.24)',
174
+ isActive: false,
175
+ };
176
+ }
177
+
178
+ function normalizeRunningLabel(activityTitle: string | null): string {
179
+ const normalized = activityTitle?.trim().toLowerCase();
180
+ if (!normalized || normalized === 'turn started' || normalized === 'ready') {
181
+ return 'Working';
182
+ }
183
+
184
+ if (normalized === 'working') {
185
+ return 'Working';
186
+ }
187
+ if (normalized === 'reasoning') {
188
+ return 'Reasoning';
189
+ }
190
+ if (normalized === 'planning') {
191
+ return 'Planning';
192
+ }
193
+
194
+ return activityTitle ?? 'Working';
195
+ }
196
+
197
+ function normalizeRunningDetail(activityTitle: string | null): string | null {
198
+ if (!activityTitle) {
199
+ return null;
200
+ }
201
+
202
+ const normalized = activityTitle.trim().toLowerCase();
203
+ if (
204
+ normalized === 'working' ||
205
+ normalized === 'reasoning' ||
206
+ normalized === 'planning' ||
207
+ normalized === 'turn started' ||
208
+ normalized === 'ready'
209
+ ) {
210
+ return null;
211
+ }
212
+
213
+ return activityTitle;
214
+ }
215
+
216
+ function normalizeCompleteActivityTitle(activityTitle: string | null): string | null {
217
+ if (!activityTitle) {
218
+ return null;
219
+ }
220
+
221
+ const normalized = activityTitle.trim().toLowerCase();
222
+ if (normalized === 'turn completed' || normalized === 'ready') {
223
+ return null;
224
+ }
225
+
226
+ return activityTitle;
227
+ }
228
+
229
+ function normalizeErrorActivityTitle(activityTitle: string | null): string | null {
230
+ if (!activityTitle) {
231
+ return null;
232
+ }
233
+
234
+ const normalized = activityTitle.trim().toLowerCase();
235
+ if (
236
+ normalized === 'turn failed' ||
237
+ normalized === 'turn interrupted' ||
238
+ normalized === 'error'
239
+ ) {
240
+ return null;
241
+ }
242
+
243
+ return activityTitle;
244
+ }
245
+
246
+ function runningIconForLabel(label: string): IoniconName {
247
+ const normalized = label.trim().toLowerCase();
248
+ if (normalized === 'planning') {
249
+ return 'map-outline';
250
+ }
251
+ if (normalized === 'reasoning') {
252
+ return 'sparkles-outline';
253
+ }
254
+
255
+ return 'sync-outline';
256
+ }
257
+
258
+ function normalizeValue(value: string | null | undefined): string | null {
259
+ const trimmed = value?.trim();
260
+ return trimmed ? trimmed : null;
261
+ }
@@ -0,0 +1,167 @@
1
+ import type { ChatSummary } from '../api/types';
2
+
3
+ export interface RelatedAgentThreadsResult {
4
+ rootThreadId: string | null;
5
+ threads: ChatSummary[];
6
+ }
7
+
8
+ export interface LiveAgentPanelThreadLike {
9
+ id: string;
10
+ isRootThread: boolean;
11
+ isActive: boolean;
12
+ }
13
+
14
+ export function collectRelatedAgentThreads(
15
+ chats: ChatSummary[],
16
+ focusChat: ChatSummary | null
17
+ ): RelatedAgentThreadsResult {
18
+ if (!focusChat) {
19
+ return {
20
+ rootThreadId: null,
21
+ threads: [],
22
+ };
23
+ }
24
+
25
+ const chatMap = new Map<string, ChatSummary>();
26
+ for (const chat of chats) {
27
+ chatMap.set(chat.id, chat);
28
+ }
29
+ if (!chatMap.has(focusChat.id)) {
30
+ chatMap.set(focusChat.id, focusChat);
31
+ }
32
+
33
+ const rootThreadId = resolveRootThreadId(focusChat.id, chatMap);
34
+ const threads = Array.from(chatMap.values())
35
+ .filter((chat) => resolveRootThreadId(chat.id, chatMap) === rootThreadId)
36
+ .sort((left, right) => compareAgentThreads(left, right, rootThreadId));
37
+
38
+ return {
39
+ rootThreadId,
40
+ threads,
41
+ };
42
+ }
43
+
44
+ export function findMatchingAgentThread(
45
+ threads: ChatSummary[],
46
+ query: string
47
+ ): ChatSummary | null {
48
+ const normalized = query.trim().toLowerCase();
49
+ if (!normalized) {
50
+ return null;
51
+ }
52
+
53
+ const exactMatch =
54
+ threads.find((chat) => {
55
+ const title = chat.title.trim().toLowerCase();
56
+ return chat.id.toLowerCase() === normalized || title === normalized;
57
+ }) ?? null;
58
+ if (exactMatch) {
59
+ return exactMatch;
60
+ }
61
+
62
+ return (
63
+ threads.find((chat) => {
64
+ const nickname = chat.agentNickname?.trim().toLowerCase() ?? '';
65
+ const role = chat.agentRole?.trim().toLowerCase() ?? '';
66
+ const title = chat.title.trim().toLowerCase();
67
+ const preview = chat.lastMessagePreview.trim().toLowerCase();
68
+ return (
69
+ chat.id.toLowerCase().includes(normalized) ||
70
+ nickname.includes(normalized) ||
71
+ role.includes(normalized) ||
72
+ title.includes(normalized) ||
73
+ preview.includes(normalized)
74
+ );
75
+ }) ?? null
76
+ );
77
+ }
78
+
79
+ export function describeAgentThreadSource(
80
+ chat: ChatSummary,
81
+ rootThreadId: string | null
82
+ ): string {
83
+ if (rootThreadId && chat.id === rootThreadId) {
84
+ return 'Main thread';
85
+ }
86
+
87
+ switch (chat.sourceKind) {
88
+ case 'subAgentReview':
89
+ return 'Review agent';
90
+ case 'subAgentCompact':
91
+ return 'Compaction agent';
92
+ case 'subAgentThreadSpawn':
93
+ case 'subAgent':
94
+ return 'Spawned sub-agent';
95
+ case 'subAgentOther':
96
+ return 'Sub-agent';
97
+ default:
98
+ return 'Agent thread';
99
+ }
100
+ }
101
+
102
+ export function collectLiveAgentPanelThreadIds(
103
+ threads: LiveAgentPanelThreadLike[]
104
+ ): string[] {
105
+ const hasActiveSubAgent = threads.some((thread) => !thread.isRootThread && thread.isActive);
106
+ if (!hasActiveSubAgent) {
107
+ return [];
108
+ }
109
+
110
+ return threads
111
+ .filter((thread) => thread.isRootThread || thread.isActive)
112
+ .map((thread) => thread.id);
113
+ }
114
+
115
+ function resolveRootThreadId(
116
+ threadId: string,
117
+ chatMap: Map<string, ChatSummary>
118
+ ): string {
119
+ let currentId = threadId;
120
+ const seen = new Set<string>();
121
+
122
+ while (true) {
123
+ if (seen.has(currentId)) {
124
+ return currentId;
125
+ }
126
+ seen.add(currentId);
127
+
128
+ const current = chatMap.get(currentId);
129
+ const parentThreadId = current?.parentThreadId?.trim();
130
+ if (!parentThreadId) {
131
+ return currentId;
132
+ }
133
+ if (!chatMap.has(parentThreadId)) {
134
+ return parentThreadId;
135
+ }
136
+
137
+ currentId = parentThreadId;
138
+ }
139
+ }
140
+
141
+ function compareAgentThreads(
142
+ left: ChatSummary,
143
+ right: ChatSummary,
144
+ rootThreadId: string
145
+ ): number {
146
+ if (left.id === rootThreadId && right.id !== rootThreadId) {
147
+ return -1;
148
+ }
149
+ if (right.id === rootThreadId && left.id !== rootThreadId) {
150
+ return 1;
151
+ }
152
+
153
+ if (left.status === 'running' && right.status !== 'running') {
154
+ return -1;
155
+ }
156
+ if (right.status === 'running' && left.status !== 'running') {
157
+ return 1;
158
+ }
159
+
160
+ const leftDepth = left.subAgentDepth ?? 0;
161
+ const rightDepth = right.subAgentDepth ?? 0;
162
+ if (leftDepth !== rightDepth) {
163
+ return leftDepth - rightDepth;
164
+ }
165
+
166
+ return right.updatedAt.localeCompare(left.updatedAt);
167
+ }
@@ -0,0 +1,40 @@
1
+ import type { CollaborationMode, TurnPlanStep } from '../api/types';
2
+
3
+ export interface PlanCardStateLike {
4
+ explanation: string | null;
5
+ steps: TurnPlanStep[];
6
+ }
7
+
8
+ export type WorkflowCardMode = 'plan' | 'approval' | 'execution';
9
+
10
+ export interface ResolveWorkflowCardModeArgs {
11
+ collaborationMode: CollaborationMode;
12
+ hasStructuredPlan: boolean;
13
+ hasPlanApprovalPrompt: boolean;
14
+ }
15
+
16
+ export function hasStructuredPlanCardContent(
17
+ plan: PlanCardStateLike | null | undefined
18
+ ): boolean {
19
+ return Boolean(plan && (plan.steps.length > 0 || plan.explanation?.trim()));
20
+ }
21
+
22
+ export function resolveWorkflowCardMode({
23
+ collaborationMode,
24
+ hasStructuredPlan,
25
+ hasPlanApprovalPrompt,
26
+ }: ResolveWorkflowCardModeArgs): WorkflowCardMode | null {
27
+ if (hasPlanApprovalPrompt) {
28
+ return 'approval';
29
+ }
30
+
31
+ if (hasStructuredPlan && collaborationMode === 'default') {
32
+ return 'execution';
33
+ }
34
+
35
+ if (hasStructuredPlan) {
36
+ return 'plan';
37
+ }
38
+
39
+ return null;
40
+ }
@@ -0,0 +1,149 @@
1
+ import type { ChatMessage } from '../api/types';
2
+
3
+ export interface TrimmedSubAgentTranscript {
4
+ messages: ChatMessage[];
5
+ hiddenInheritedMessageCount: number;
6
+ }
7
+
8
+ export function trimInheritedParentMessages(
9
+ parentMessages: ChatMessage[],
10
+ childMessages: ChatMessage[],
11
+ childThreadId?: string | null
12
+ ): TrimmedSubAgentTranscript {
13
+ const normalizedChildThreadId = childThreadId?.trim() ?? '';
14
+ if (normalizedChildThreadId) {
15
+ const startIndex = findSpawnPromptStartIndex(
16
+ parentMessages,
17
+ childMessages,
18
+ normalizedChildThreadId
19
+ );
20
+ if (startIndex > 0 && startIndex < childMessages.length) {
21
+ return {
22
+ messages: childMessages.slice(startIndex),
23
+ hiddenInheritedMessageCount: startIndex,
24
+ };
25
+ }
26
+ }
27
+
28
+ const sharedLeadingCount = sharedLeadingMessageCount(parentMessages, childMessages);
29
+ if (sharedLeadingCount <= 0 || sharedLeadingCount >= childMessages.length) {
30
+ return {
31
+ messages: childMessages,
32
+ hiddenInheritedMessageCount: 0,
33
+ };
34
+ }
35
+
36
+ return {
37
+ messages: childMessages.slice(sharedLeadingCount),
38
+ hiddenInheritedMessageCount: sharedLeadingCount,
39
+ };
40
+ }
41
+
42
+ function findSpawnPromptStartIndex(
43
+ parentMessages: ChatMessage[],
44
+ childMessages: ChatMessage[],
45
+ childThreadId: string
46
+ ): number {
47
+ const prompt = findSpawnPrompt(parentMessages, childThreadId);
48
+ if (!prompt) {
49
+ return -1;
50
+ }
51
+
52
+ const normalizedPrompt = normalizeMessageContent(prompt);
53
+ if (!normalizedPrompt) {
54
+ return -1;
55
+ }
56
+
57
+ const sharedLeadingCount = sharedLeadingMessageCount(parentMessages, childMessages);
58
+ return childMessages.findIndex((message, index) => {
59
+ if (index < sharedLeadingCount) {
60
+ return false;
61
+ }
62
+ if (message.role !== 'user') {
63
+ return false;
64
+ }
65
+
66
+ const candidate = normalizeUserPromptContent(message.content);
67
+ if (!candidate) {
68
+ return false;
69
+ }
70
+
71
+ return (
72
+ candidate === normalizedPrompt ||
73
+ candidate.includes(normalizedPrompt) ||
74
+ normalizedPrompt.includes(candidate)
75
+ );
76
+ });
77
+ }
78
+
79
+ function findSpawnPrompt(
80
+ parentMessages: ChatMessage[],
81
+ childThreadId: string
82
+ ): string | null {
83
+ let fallbackPrompt: string | null = null;
84
+
85
+ for (const message of parentMessages) {
86
+ if (message.systemKind !== 'subAgent') {
87
+ continue;
88
+ }
89
+
90
+ const meta = message.subAgentMeta;
91
+ if (!meta) {
92
+ continue;
93
+ }
94
+
95
+ const receiverThreadIds = meta.receiverThreadIds ?? [];
96
+ if (!receiverThreadIds.includes(childThreadId)) {
97
+ continue;
98
+ }
99
+
100
+ const prompt = meta.prompt?.trim();
101
+ if (!prompt) {
102
+ continue;
103
+ }
104
+
105
+ const normalizedTool = normalizeMessageContent(meta.tool ?? '');
106
+ if (normalizedTool === 'spawn_agent' || normalizedTool === 'spawnagent') {
107
+ return prompt;
108
+ }
109
+
110
+ if (!fallbackPrompt) {
111
+ fallbackPrompt = prompt;
112
+ }
113
+ }
114
+
115
+ return fallbackPrompt;
116
+ }
117
+
118
+ function sharedLeadingMessageCount(left: ChatMessage[], right: ChatMessage[]): number {
119
+ const max = Math.min(left.length, right.length);
120
+ let count = 0;
121
+
122
+ while (count < max && messagesMatch(left[count], right[count])) {
123
+ count += 1;
124
+ }
125
+
126
+ return count;
127
+ }
128
+
129
+ function messagesMatch(left: ChatMessage, right: ChatMessage): boolean {
130
+ return (
131
+ left.role === right.role &&
132
+ left.systemKind === right.systemKind &&
133
+ normalizeMessageContent(left.content) === normalizeMessageContent(right.content)
134
+ );
135
+ }
136
+
137
+ function normalizeUserPromptContent(value: string): string {
138
+ const lines = value
139
+ .split('\n')
140
+ .map((line) => line.trim())
141
+ .filter((line) => line.length > 0)
142
+ .filter((line) => !/^\[(file|image|local image):/i.test(line));
143
+
144
+ return normalizeMessageContent(lines.join('\n'));
145
+ }
146
+
147
+ function normalizeMessageContent(value: string): string {
148
+ return value.replace(/\s+/g, ' ').trim();
149
+ }
@@ -0,0 +1,102 @@
1
+ import type { ChatMessage, ChatStatus } from '../api/types';
2
+
3
+ export function getVisibleTranscriptMessages(
4
+ messages: ChatMessage[],
5
+ showToolCalls: boolean
6
+ ): ChatMessage[] {
7
+ const filtered = messages.filter((msg) => {
8
+ const text = msg.content || '';
9
+ if (!showToolCalls && msg.role === 'system' && msg.systemKind !== 'subAgent') {
10
+ return false;
11
+ }
12
+ if (text.includes('FINAL_TASK_RESULT_JSON')) {
13
+ return false;
14
+ }
15
+ if (text.includes('Current working directory is:')) {
16
+ return false;
17
+ }
18
+ if (text.includes('You are operating in task worktree')) {
19
+ return false;
20
+ }
21
+ if (msg.role === 'assistant' && !text.trim()) {
22
+ return false;
23
+ }
24
+ return true;
25
+ });
26
+
27
+ return filtered.filter((msg, index) => {
28
+ if (msg.role !== 'assistant') {
29
+ return true;
30
+ }
31
+
32
+ const next = filtered[index + 1];
33
+ return !next || next.role !== 'assistant';
34
+ });
35
+ }
36
+
37
+ export function syncVisibleSubAgentStatuses(
38
+ messages: ChatMessage[],
39
+ threadStatuses: ReadonlyMap<string, ChatStatus>
40
+ ): ChatMessage[] {
41
+ if (threadStatuses.size === 0) {
42
+ return messages;
43
+ }
44
+
45
+ return messages.map((message) => syncSubAgentMessageStatus(message, threadStatuses));
46
+ }
47
+
48
+ function syncSubAgentMessageStatus(
49
+ message: ChatMessage,
50
+ threadStatuses: ReadonlyMap<string, ChatStatus>
51
+ ): ChatMessage {
52
+ if (message.systemKind !== 'subAgent' || !message.subAgentMeta) {
53
+ return message;
54
+ }
55
+
56
+ const receiverThreadIds = message.subAgentMeta.receiverThreadIds ?? [];
57
+ const nextStatus =
58
+ receiverThreadIds
59
+ .map((threadId) => threadStatuses.get(threadId))
60
+ .find((status): status is ChatStatus => typeof status === 'string') ?? null;
61
+
62
+ if (!nextStatus) {
63
+ return message;
64
+ }
65
+
66
+ const nextContent = replaceSubAgentStatusLine(message.content, nextStatus);
67
+ const previousStatus = message.subAgentMeta.agentStatus;
68
+ if (nextContent === message.content && previousStatus === nextStatus) {
69
+ return message;
70
+ }
71
+
72
+ return {
73
+ ...message,
74
+ content: nextContent,
75
+ subAgentMeta: {
76
+ ...message.subAgentMeta,
77
+ agentStatus: nextStatus,
78
+ },
79
+ };
80
+ }
81
+
82
+ function replaceSubAgentStatusLine(content: string, status: ChatStatus): string {
83
+ const statusLine = `Status: ${status}`;
84
+ const lines = content.split('\n');
85
+ let replaced = false;
86
+
87
+ const nextLines = lines.map((line) => {
88
+ if (!/^\s*Status:\s*/i.test(line)) {
89
+ return line;
90
+ }
91
+
92
+ replaced = true;
93
+ const indentation = line.match(/^\s*/)?.[0] ?? '';
94
+ return `${indentation}${statusLine}`;
95
+ });
96
+
97
+ if (replaced) {
98
+ return nextLines.join('\n');
99
+ }
100
+
101
+ return [...nextLines, ` ${statusLine}`].join('\n');
102
+ }