Skip to content
Merged
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
8 changes: 4 additions & 4 deletions apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,7 @@ describe("ChatView", () => {
expect(frame).toContain("+2 −1");
});

it("keeps subagent lifecycle and child tool chatter out of the center transcript", () => {
it("shows compact subagent lifecycle while keeping child tool chatter out of the center transcript", () => {
const turnId = "turn-subagents";
const frame = renderEvents([
{
Expand Down Expand Up @@ -883,12 +883,12 @@ describe("ChatView", () => {
expect(frame).toContain("Tool calls");
expect(frame).toContain("spawn_agent");
expect(frame).toContain("Explore renderer");
expect(frame).not.toContain("child launch spam");
expect(frame).toContain("Activity");
expect(frame).toContain("child result spam");
expect(frame).toContain("agent");
expect(frame).not.toContain("read_file");
expect(frame).not.toContain("noisy-child");
expect(frame).not.toContain("child tool result spam");
expect(frame).not.toContain("child progress spam");
expect(frame).not.toContain("child result spam");
});

it("renders a selected subagent transcript with the same ChatView format", () => {
Expand Down
33 changes: 30 additions & 3 deletions apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,7 @@ describe("aggregateChatBlocks typed groups", () => {
expect(toolGroup!.entries.map((e) => e.itemId)).toEqual(["kept-1"]);
});

it("drops tool-derived activity, spawning_agent, and subagent lifecycle from the transcript", () => {
// Desktop parity: the subagent roster (chat-info pane) is the surface for
// lifecycle + spawn chatter; the transcript shows none of it.
it("bundles subagent lifecycle while still dropping tool-derived activity", () => {
const events: AgentChatEventEnvelope[] = [
env("2026-01-01T12:00:00.000Z", { type: "activity", activity: "thinking", detail: "Thinking through the answer", turnId: "turn-1" }),
env("2026-01-01T12:00:01.000Z", { type: "activity", activity: "reading", detail: "apps/ade-cli/src/tuiClient/app.tsx", turnId: "turn-1" }),
Expand All @@ -158,6 +156,35 @@ describe("aggregateChatBlocks typed groups", () => {

const blocks = aggregate(events);
expect(blocks.some((b) => b.kind === "runtime-activity")).toBe(false);
const activity = blocks.find((b) => b.kind === "activity-bundle") as Extract<AggregatedBlock, { kind: "activity-bundle" }> | undefined;
expect(activity).toBeDefined();
expect(activity!.entries.map((entry) => entry.status)).toEqual(["running", "running", "ok"]);
expect(activity!.entries.map((entry) => entry.label)).toEqual(["child launch spam", "child progress", "child done"]);
});

it("bundles adjacent task and scheduled work updates in the TUI transcript", () => {
const events: AgentChatEventEnvelope[] = [
env("2026-01-01T12:00:00.000Z", {
type: "todo_update",
turnId: "turn-1",
items: [{ id: "task-1", description: "Review UI parity", status: "in_progress" }],
}),
env("2026-01-01T12:00:01.000Z", {
type: "scheduled_work_update",
id: "cron-1",
kind: "cron",
status: "scheduled",
title: "CI follow-up",
turnId: "turn-1",
} as unknown as AgentChatEvent),
];

const blocks = aggregate(events);
expect(blocks).toHaveLength(1);
const activity = blocks[0] as Extract<AggregatedBlock, { kind: "activity-bundle" }>;
expect(activity.kind).toBe("activity-bundle");
expect(activity.entries.map((entry) => entry.kind)).toEqual(["task", "schedule"]);
expect(activity.entries.map((entry) => entry.label)).toEqual(["Review UI parity", "CI follow-up"]);
});

it("still surfaces unrecognized activity events as runtime activity", () => {
Expand Down
155 changes: 151 additions & 4 deletions apps/ade-cli/src/tuiClient/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ export type RuntimeActivityEntry = {
status: WorkToolStatus | "info";
};

export type ActivityBundleKind = "task" | "agent" | "workflow" | "schedule";

export type ActivityBundleEntry = {
id: string;
kind: ActivityBundleKind;
label: string;
detail?: string;
status: WorkToolStatus | "info";
};

export type PlanStep = {
text: string;
status: "pending" | "in_progress" | "completed" | "failed";
Expand All @@ -64,6 +74,7 @@ export type AggregatedBlock =
| { kind: "tool-calls-group"; id: string; turnId: string | null; entries: ToolCallEntry[]; live: boolean; durationMs?: number }
| { kind: "files-changed-group"; id: string; turnId: string | null; entries: FileChangeEntry[]; live: boolean; durationMs?: number }
| { kind: "runtime-activity"; id: string; turnId: string | null; entries: RuntimeActivityEntry[]; live: boolean }
| { kind: "activity-bundle"; id: string; turnId: string | null; entries: ActivityBundleEntry[]; live: boolean }
| { kind: "compaction"; id: string; turnId: string | null; trigger: "manual" | "auto"; live: boolean; preTokens?: number; postTokens?: number; durationMs?: number; sessionCompactionCount?: number }
| { kind: "queued-steer"; id: string; turnId: string | null; steerId: string; text: string }
| { kind: "plan"; id: string; turnId: string | null; steps: PlanStep[]; current: number; total: number; live: boolean }
Expand Down Expand Up @@ -359,10 +370,11 @@ function findCompactionBlockForCompletion(

function isLiveTurnBlock(
block: AggregatedBlock,
): block is Extract<AggregatedBlock, { kind: "tool-calls-group" | "files-changed-group" | "runtime-activity" | "plan" | "compaction" | "reasoning" }> {
): block is Extract<AggregatedBlock, { kind: "tool-calls-group" | "files-changed-group" | "runtime-activity" | "activity-bundle" | "plan" | "compaction" | "reasoning" }> {
return block.kind === "tool-calls-group"
|| block.kind === "files-changed-group"
|| block.kind === "runtime-activity"
|| block.kind === "activity-bundle"
|| block.kind === "plan"
|| block.kind === "compaction"
|| block.kind === "reasoning";
Expand Down Expand Up @@ -459,6 +471,110 @@ function runtimeActivityFromEvent(id: string, event: AgentChatEvent): RuntimeAct
return null;
}

function activityStatusFromRawStatus(status: string | null | undefined): WorkToolStatus | "info" {
const normalized = (status ?? "").trim().toLowerCase();
if (normalized === "failed" || normalized === "error") return "failed";
if (normalized === "completed" || normalized === "complete" || normalized === "done") return "ok";
if (
normalized === "running"
|| normalized === "started"
|| normalized === "fired"
|| normalized === "in_progress"
) return "running";
return "info";
}

function activityBundleEntryFromEvent(id: string, event: AgentChatEvent): ActivityBundleEntry | null {
const eventType = String((event as { type?: unknown }).type ?? "");
if (event.type === "todo_update") {
const completed = event.items.filter((task) => task.status === "completed").length;
const active = event.items.find((task) => task.status === "in_progress")
?? event.items.find((task) => task.status !== "completed")
?? event.items.at(-1);
const detail = event.items.length > 0 ? `${completed}/${event.items.length} complete` : "updated";
return {
id,
kind: "task",
label: active?.description?.trim() || "Task list updated",
detail,
status: event.items.length > 0 && completed === event.items.length ? "ok" : "running",
};
}
if (event.type === "scheduled_work_update") {
return {
id,
kind: "schedule",
label: event.title?.trim() || event.reason?.trim() || event.prompt?.trim() || (event.kind === "cron" ? "Cron schedule" : "Scheduled work"),
detail: compactActivityDetail(event.summary) ?? compactActivityDetail(event.error) ?? compactActivityDetail(event.cron) ?? compactActivityDetail(event.nextRunAt),
status: activityStatusFromRawStatus(event.status),
};
}
if (event.type === "subagent_started" || event.type === "subagent_progress" || event.type === "subagent_result") {
const isWorkflow = Boolean(event.workflowName) || event.taskType === "local_workflow";
const description = "description" in event ? stringField(event.description) : null;
const summary = "summary" in event ? stringField(event.summary) : null;
const status = event.type === "subagent_started" || event.type === "subagent_progress"
? "running"
: activityStatusFromRawStatus(event.status);
return {
id,
kind: isWorkflow ? "workflow" : "agent",
label: stringField(event.label)
?? stringField(event.workflowName)
?? stringField(event.agentType)
?? description
?? summary
?? event.agentId
?? event.taskId,
detail: event.type === "subagent_progress"
? compactActivityDetail(event.summary) ?? compactActivityDetail(event.lastToolName)
: event.type === "subagent_result"
? compactActivityDetail(event.finalSummary) ?? compactActivityDetail(event.summary)
: description ?? undefined,
status,
};
}
if (eventType === "subagent.started" || eventType === "subagent.progress" || eventType === "subagent.completed") {
const record = event as Record<string, unknown>;
const status = eventType === "subagent.completed"
? activityStatusFromRawStatus(stringField(record.status) ?? "completed")
: "running";
return {
id,
kind: "agent",
label: stringField(record.label)
?? stringField(record.agentType)
?? stringField(record.description)
?? stringField(record.summary)
?? stringField(record.agentId)
?? "Subagent",
detail: compactActivityDetail(record.summary) ?? compactActivityDetail(record.description),
status,
};
}
if (eventType === "task.completed") {
const record = event as Record<string, unknown>;
return {
id,
kind: "task",
label: stringField(record.name) ?? stringField(record.title) ?? stringField(record.summary) ?? "Task completed",
detail: compactActivityDetail(record.summary),
status: "ok",
};
}
if (eventType === "teammate.idle") {
const record = event as Record<string, unknown>;
return {
id,
kind: "agent",
label: stringField(record.name) ?? stringField(record.agentId) ?? "Agent idle",
detail: compactActivityDetail(record.reason),
status: "info",
};
}
return null;
}

function summarizePreToolUseHookError(event: AgentChatEvent): string | null {
if (event.type !== "system_notice") return null;
if ((event as { noticeKind?: string }).noticeKind !== "hook") return null;
Expand Down Expand Up @@ -489,6 +605,34 @@ function appendRuntimeActivityBlock(
if (block.entries.length > 8) block.entries.splice(0, block.entries.length - 8);
}

function appendActivityBundleBlock(
blocks: AggregatedBlock[],
id: string,
turnId: string | null,
entry: ActivityBundleEntry,
): void {
const last = blocks[blocks.length - 1];
let block: Extract<AggregatedBlock, { kind: "activity-bundle" }>;
if (last && last.kind === "activity-bundle" && last.turnId === turnId) {
block = last;
} else {
block = { kind: "activity-bundle", id, turnId, entries: [], live: true };
blocks.push(block);
}
const previous = block.entries[block.entries.length - 1];
if (
previous
&& previous.kind === entry.kind
&& previous.label === entry.label
&& previous.detail === entry.detail
&& previous.status === entry.status
) {
return;
}
block.entries.push(entry);
if (block.entries.length > 12) block.entries.splice(0, block.entries.length - 12);
}

function subagentParentItemId(event: AgentChatEvent): string | null {
if (!isSubagentTimelineEvent(event)) return null;
return stringField((event as { parentToolUseId?: unknown }).parentToolUseId);
Expand Down Expand Up @@ -632,9 +776,12 @@ export function aggregateChatBlocks(args: {
const id = chatEventLineId(envelope, index);
const turnId = turnIdOf(event);

// Subagent lifecycle (started/progress/result, teammate idle, task done)
// never reaches the transcript — the subagent roster in the chat-info pane
// is its surface, matching the desktop transcript which hides these too.
const activityEntry = activityBundleEntryFromEvent(id, event);
if (activityEntry) {
appendActivityBundleBlock(blocks, id, turnId, activityEntry);
continue;
}

if (isSubagentTimelineEvent(event)) {
continue;
}
Expand Down
63 changes: 63 additions & 0 deletions apps/ade-cli/src/tuiClient/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "../format";
import {
aggregateChatBlocks,
type ActivityBundleEntry,
type AggregatedBlock,
type FileChangeEntry,
type PlanStep,
Expand Down Expand Up @@ -919,6 +920,66 @@ function runtimeActivityRows(
return out;
}

function activityKindLabel(kind: ActivityBundleEntry["kind"]): string {
if (kind === "task") return "task";
if (kind === "schedule") return "schedule";
if (kind === "workflow") return "workflow";
return "agent";
}

function activityBundleRows(
block: Extract<AggregatedBlock, { kind: "activity-bundle" }>,
spinFrame: string,
expandedGroupIds: Set<string>,
): RenderedChatRow[] {
if (!block.entries.length) return [];
const expandKey = workGroupExpandKey(block.id);
const expanded = expandedGroupIds.has(expandKey);
const latest = block.entries[block.entries.length - 1]!;
const glyph = activityStatusGlyph(latest.status, spinFrame);
const headerRuns: InlineRun[] = [
{ text: expanded ? "▾ " : "▸ ", color: theme.color.t3 },
{ text: "Activity", color: theme.color.t2, bold: true },
{ text: ` (${block.entries.length})`, color: theme.color.t4 },
];
if (!expanded) {
headerRuns.push({ text: " " });
headerRuns.push({ text: glyph, color: ACTIVITY_STATUS_COLOR[latest.status] });
headerRuns.push({ text: ` ${activityKindLabel(latest.kind)}`, color: theme.color.t1 });
headerRuns.push({ text: ` ${truncateLongLine(latest.label)}`, color: theme.color.t3 });
if (latest.detail) headerRuns.push({ text: ` ${truncateLongLine(latest.detail)}`, color: theme.color.t4 });
}
const out: RenderedChatRow[] = [{
id: block.id,
tone: "work",
text: runsPlainText(headerRuns),
runs: headerRuns,
rail: null,
expandableGroupId: expandKey,
}];
if (!expanded) return out;
const { shown, remaining } = visibleEntries(block.entries);
for (const entry of shown) {
const entryGlyph = activityStatusGlyph(entry.status, spinFrame);
const runs: InlineRun[] = [
{ text: " " },
{ text: entryGlyph, color: ACTIVITY_STATUS_COLOR[entry.status] },
{ text: ` ${activityKindLabel(entry.kind)}`, color: theme.color.t1 },
{ text: ` ${truncateLongLine(entry.label)}`, color: theme.color.t3 },
];
if (entry.detail) runs.push({ text: ` ${truncateLongLine(entry.detail)}`, color: theme.color.t4 });
out.push({
id: `${block.id}:${entry.id}`,
tone: "work",
text: runsPlainText(runs),
runs,
rail: null,
});
}
if (remaining > 0) out.push(moreLineRow(block.id, remaining));
return out;
}

/**
* Collapsed reasoning row — mirrors the desktop MinimalThought line:
* `▸ Thinking… <preview>` while live, `▸ Thought · <preview>` once done.
Expand Down Expand Up @@ -1158,6 +1219,8 @@ function rowsForBlock(
return filesChangedGroupRows(block, width, spinFrame, expandedGroupIds);
case "runtime-activity":
return runtimeActivityRows(block, spinFrame);
case "activity-bundle":
return activityBundleRows(block, spinFrame, expandedGroupIds);
case "reasoning":
return reasoningRows(block, spinFrame);
case "compaction":
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/services/chat/agentChatService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13219,6 +13219,12 @@ export function createAgentChatService(args: {
|| runtime.query !== sessionQuery
|| runtime.idleReaderGeneration !== generation
) {
const staleGenerationOnly = managed.runtime === runtime
&& runtime.query === sessionQuery
&& runtime.idleReaderGeneration !== generation;
if (staleGenerationOnly && handoffToForegroundIfActive(nextMessage, next)) {
return;
}
return;
}
if (handoffToForegroundIfActive(nextMessage, next)) {
Expand Down
Loading
Loading