feat(app): float composer trackers as pills over the transcript (#3482)

The subagents track and the task list were stacked cards above the
composer. Each one pushed the composer further down the pane and painted
an opaque band the timeline stopped at, and both spent a full row on a
count that fits in a pill.

Both are now pills that open a panel — a popover on wide screens, a sheet
on compact ones, from the existing menu engine rather than a fourth
floating-panel implementation. The row frame is shared, so a subagent row
and a task row sit on the same rail whatever their contents.

The bar mounts inside the transcript's animated container and pins itself
to the bottom edge, so scrolled content passes under the pills instead of
stopping above them. It stays a child of that container rather than a
sibling floating over the composer: on Android a view outside its parent's
bounds paints but takes no touches, and every pill is pressable. The
container carries the same keyboard transform the composer does, so the
pills track the composer's top edge when the keyboard moves.

The tracker state moves out of the composer with it. None of it was
composer state; it lived there because that is where it used to render.
This commit is contained in:
Mohamed Boudra
2026-08-17 12:36:28 +02:00
committed by GitHub
parent ac4331456c
commit 0eab0b7828
23 changed files with 672 additions and 442 deletions
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import type { PaseoSubagentRow, ProviderSubagentRow, SubagentRow } from "./select";
import {
aggregateSubagentStatusBucket,
buildSubagentRowPresentationData,
countFinishedSubagents,
formatHeaderLabel,
resolveRowLabel,
} from "./track-presentation";
@@ -23,55 +23,28 @@ function row(
};
}
describe("formatHeaderLabel", () => {
it("uses singular 'subagent' for a single row", () => {
expect(formatHeaderLabel([row({ id: "a" })])).toBe("1 subagent");
describe("aggregateSubagentStatusBucket", () => {
it("has no bucket without rows", () => {
expect(aggregateSubagentStatusBucket([])).toBeNull();
});
it("uses plural 'subagents' for two rows with no running rows", () => {
expect(formatHeaderLabel([row({ id: "a" }), row({ id: "b" })])).toBe("2 subagents");
it("has no bucket when every child is done", () => {
expect(aggregateSubagentStatusBucket([row({ id: "a" }), row({ id: "b" })])).toBeNull();
});
it("appends the running count when at least one row is running", () => {
it("reports running when any child is running", () => {
expect(
formatHeaderLabel([row({ id: "a", status: "running" }), row({ id: "b" }), row({ id: "c" })]),
).toBe("3 subagents · 1 running");
aggregateSubagentStatusBucket([row({ id: "a" }), row({ id: "b", status: "running" })]),
).toBe("running");
});
it("counts every running row in the suffix", () => {
it("ranks a failed child above a running one", () => {
expect(
formatHeaderLabel([
aggregateSubagentStatusBucket([
row({ id: "a", status: "running" }),
row({ id: "b", status: "running" }),
row({ id: "c", requiresAttention: true }),
row({ id: "d" }),
row({ id: "e" }),
row({ id: "b", status: "error", requiresAttention: true }),
]),
).toBe("5 subagents · 2 running");
});
it("ignores requiresAttention on non-running rows in the header copy", () => {
expect(
formatHeaderLabel([
row({ id: "a", status: "error", requiresAttention: false }),
row({ id: "b", status: "idle", requiresAttention: false }),
row({ id: "c", status: "idle", requiresAttention: true }),
]),
).toBe("3 subagents");
});
it("still counts running rows even when they require attention", () => {
expect(
formatHeaderLabel([
row({ id: "a", status: "error", requiresAttention: true }),
row({ id: "b", status: "running", requiresAttention: true }),
row({ id: "c", status: "idle", requiresAttention: true }),
]),
).toBe("3 subagents · 1 running");
});
it("uses singular 'subagent' for a single row that requires attention upstream", () => {
expect(formatHeaderLabel([row({ id: "a", requiresAttention: true })])).toBe("1 subagent");
).toBe("failed");
});
});