Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,82 @@ describe("buildThreadFeed", () => {
);
});

it("collapses interleaved lifecycle rows by top-level tool identity", () => {
const turnId = TurnId.make("turn-interleaved-tools");
const lifecycleActivity = (
id: string,
createdAt: string,
kind: "tool.updated" | "tool.completed",
toolCallId: string,
title: string,
) =>
makeActivity({
id: EventId.make(id),
kind,
tone: "tool",
summary: title,
createdAt,
turnId,
payload: {
itemType: "command_execution",
toolCallId,
title,
detail: title,
},
});
const thread = makeThread({
id: ThreadId.make("thread-interleaved-tools"),
projectId: ProjectId.make("project-1"),
title: "Interleaved tools",
latestTurn: {
turnId,
state: "completed",
requestedAt: "2026-04-01T00:00:00.000Z",
startedAt: "2026-04-01T00:00:01.000Z",
completedAt: "2026-04-01T00:00:05.000Z",
assistantMessageId: null,
},
activities: [
lifecycleActivity(
"tool-a-updated",
"2026-04-01T00:00:01.000Z",
"tool.updated",
"call-a",
"Preparing first call",
),
lifecycleActivity(
"tool-b-updated",
"2026-04-01T00:00:02.000Z",
"tool.updated",
"call-b",
"Preparing second call",
),
lifecycleActivity(
"tool-a-completed",
"2026-04-01T00:00:03.000Z",
"tool.completed",
"call-a",
"First call complete",
),
lifecycleActivity(
"tool-b-completed",
"2026-04-01T00:00:04.000Z",
"tool.completed",
"call-b",
"Second call complete",
),
],
});

const group = buildThreadFeed(thread)[0];
expect(group?.type).toBe("activity-group");
if (!group || group.type !== "activity-group") return;
expect(group.activities.map((activity) => activity.id)).toEqual([
"tool-a-completed",
"tool-b-completed",
]);
});

it("keeps MCP inputs available to expanded mobile work rows", () => {
const turnId = TurnId.make("turn-mcp");
const thread = makeThread({
Expand Down
41 changes: 40 additions & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ interface WorkLogEntry {
id: string;
createdAt: string;
turnId: TurnId | null;
toolCallId?: string;
label: string;
detail?: string;
command?: string;
Expand Down Expand Up @@ -352,6 +353,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
const commandPreview = extractToolCommand(payload);
const changedFiles = extractChangedFiles(payload);
const title = extractToolTitle(payload);
const toolCallId = extractToolCallId(payload);
// task.updated included: terminal bypassed updates (Codex children's only
// terminal signal) must carry task identity so they collapse per child
// instead of stacking anonymous "Task idle" rows.
Expand Down Expand Up @@ -426,6 +428,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo
if (requestKind) {
entry.requestKind = requestKind;
}
if (toolCallId) {
entry.toolCallId = toolCallId;
}
let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload);
if (!toolLifecycleStatus && activity.kind === "tool.completed") {
toolLifecycleStatus = "completed";
Expand All @@ -447,6 +452,7 @@ function collapseDerivedWorkLogEntries(
// Subagent rows collapse by identity, not adjacency (quiet-timeline
// guarantee; mirrors web's session-logic).
const taskRowIndex = new Map<string, number>();
const toolLifecycleRowIndex = new Map<string, number>();
for (const entry of entries) {
const isTaskRow =
entry.taskId !== undefined &&
Expand All @@ -463,12 +469,35 @@ function collapseDerivedWorkLogEntries(
collapsed.push(entry);
continue;
}
const lifecycleKey = entry.toolCallId ? entry.collapseKey : undefined;
if (lifecycleKey !== undefined) {
const matchingIndex = toolLifecycleRowIndex.get(lifecycleKey);
const matchingEntry = matchingIndex !== undefined ? collapsed[matchingIndex] : undefined;
if (matchingIndex !== undefined && matchingEntry) {
if (shouldCollapseToolLifecycleEntries(matchingEntry, entry)) {
collapsed[matchingIndex] = mergeDerivedWorkLogEntries(matchingEntry, entry);
continue;
}
toolLifecycleRowIndex.delete(lifecycleKey);
}
}
const previous = collapsed.at(-1);
if (previous && shouldCollapseToolLifecycleEntries(previous, entry)) {
collapsed[collapsed.length - 1] = mergeDerivedWorkLogEntries(previous, entry);
const previousIndex = collapsed.length - 1;
if (previous.toolCallId && previous.collapseKey) {
toolLifecycleRowIndex.delete(previous.collapseKey);
}
const merged = mergeDerivedWorkLogEntries(previous, entry);
collapsed[previousIndex] = merged;
if (merged.toolCallId && merged.collapseKey) {
toolLifecycleRowIndex.set(merged.collapseKey, previousIndex);
}
continue;
}
collapsed.push(entry);
if (lifecycleKey !== undefined) {
toolLifecycleRowIndex.set(lifecycleKey, collapsed.length - 1);
}
}
return collapsed;
}
Expand Down Expand Up @@ -501,6 +530,7 @@ function mergeDerivedWorkLogEntries(
const itemType = next.itemType ?? previous.itemType;
const requestKind = next.requestKind ?? previous.requestKind;
const collapseKey = next.collapseKey ?? previous.collapseKey;
const toolCallId = next.toolCallId ?? previous.toolCallId;
const toolLifecycleStatus = next.toolLifecycleStatus ?? previous.toolLifecycleStatus;
const toolData = next.toolData ?? previous.toolData;
return {
Expand All @@ -514,6 +544,7 @@ function mergeDerivedWorkLogEntries(
...(itemType ? { itemType } : {}),
...(requestKind ? { requestKind } : {}),
...(collapseKey ? { collapseKey } : {}),
...(toolCallId ? { toolCallId } : {}),
...(toolLifecycleStatus ? { toolLifecycleStatus } : {}),
...(toolData !== undefined ? { toolData } : {}),
};
Expand All @@ -534,6 +565,9 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un
if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") {
return undefined;
}
if (entry.toolCallId) {
return `tool:${entry.turnId ?? "no-turn"}:${entry.toolCallId}`;
}
const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label);
const detail = entry.detail?.trim() ?? "";
const itemType = entry.itemType ?? "";
Expand Down Expand Up @@ -915,6 +949,11 @@ function extractToolTitle(payload: Record<string, unknown> | null): string | nul
return asTrimmedString(payload?.title);
}

function extractToolCallId(payload: Record<string, unknown> | null): string | null {
const data = asRecord(payload?.data);
return asTrimmedString(payload?.toolCallId) ?? asTrimmedString(data?.toolCallId);
}

function extractWorkLogToolLifecycleStatus(
payload: Record<string, unknown> | null,
): WorkLogToolLifecycleStatus | undefined {
Expand Down
31 changes: 7 additions & 24 deletions apps/web/src/components/ChatView.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compact 11px drawer action now has two owners: this inline string and APPROVAL_ACTION_CLASS_NAME in chat/ComposerPendingApprovalActions.tsx:14. Both render size="micro" variant="ghost-muted" at 11px in a composer-attached drawer, so the banner action and the approval actions can drift apart even though they sit in the same stack.

sm:text-[11px] is also inert here: micro sets text-xs with no sm: counterpart (ui/button.tsx:32), so the base override already applies at every width.

Suggest expressing this once — a shared exported class constant or a named button size/variant — and leaving only contextual classes at the call site.

Posted via Macroscope — UI Consistency

Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ import {
derivePhase,
deriveTimelineEntries,
deriveActiveWorkStartedAt,
deriveActivePlanState,
deriveTurnPlans,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
findLatestProposedPlan,
deriveWorkLogEntries,
Expand Down Expand Up @@ -556,8 +555,10 @@ function useLocalDispatchState(input: {
threadError: string | null | undefined;
}) {
const [localDispatch, setLocalDispatch] = useState<LocalDispatchSnapshot | null>(null);
const latestUserMessageId =
input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null;
const latestUserMessage = input.activeThread?.messages.findLast(
(message) => message.role === "user",
);
const latestUserMessageId = latestUserMessage?.id ?? null;

const resetLocalDispatch = useCallback(() => {
setLocalDispatch(null);
Expand Down Expand Up @@ -607,6 +608,7 @@ function useLocalDispatchState(input: {
beginLocalDispatch,
resetLocalDispatch,
localDispatchStartedAt: activeLocalDispatch?.startedAt ?? null,
latestUserMessageAt: latestUserMessage?.createdAt ?? null,
isPreparingWorktree: activeLocalDispatch?.preparingWorktree ?? false,
isSendBusy: activeLocalDispatch !== null,
};
Expand Down Expand Up @@ -2299,25 +2301,6 @@ function ChatViewContent(props: ChatViewProps) {
activeLatestTurn?.turnId ?? null,
);
}, [activeLatestTurn?.turnId, activeThread?.proposedPlans, latestTurnSettled]);
const activePlan = useMemo(
() => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined),
[activeLatestTurn?.turnId, threadActivities],
);
// Current step for the in-chat working row: only for the running turn's own
// plan (deriveActivePlanState falls back to older turns' plans, which must
// not label fresh work). Falls back to the first pending step so an
// all-pending freshly written plan labels the row, matching the chip and
// the server's planProgress.
const workingStepLabel = useMemo(() => {
if (!activePlan || activePlan.turnId !== (activeLatestTurn?.turnId ?? null)) {
return null;
}
return (
activePlan.steps.find((step) => step.status === "inProgress")?.step ??
activePlan.steps.find((step) => step.status === "pending")?.step ??
null
);
}, [activeLatestTurn?.turnId, activePlan]);
const showPlanFollowUpPrompt =
pendingUserInputs.length === 0 &&
interactionMode === "plan" &&
Expand All @@ -2328,6 +2311,7 @@ function ChatViewContent(props: ChatViewProps) {
beginLocalDispatch,
resetLocalDispatch,
localDispatchStartedAt,
latestUserMessageAt,
isPreparingWorktree,
isSendBusy,
} = useLocalDispatchState({
Expand All @@ -2343,6 +2327,7 @@ function ChatViewContent(props: ChatViewProps) {
activeLatestTurn,
activeThread?.session ?? null,
localDispatchStartedAt,
latestUserMessageAt,
);
useEffect(() => {
attachmentPreviewHandoffByMessageIdRef.current = attachmentPreviewHandoffByMessageId;
Expand Down Expand Up @@ -6317,8 +6302,6 @@ function ChatViewContent(props: ChatViewProps) {
onOpenAgents={addAgentsSurface}
key={activeThread.id}
isWorking={isWorking}
workingStepLabel={workingStepLabel}
activeTurnInProgress={isWorking || !latestTurnSettled}
activeTurnStartedAt={activeWorkStartedAt}
listRef={legendListRef}
timelineEntries={timelineEntries}
Expand Down
Loading
Loading