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
+10 -47
View File
@@ -74,7 +74,7 @@ import {
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { AgentTaskList } from "@/composer/task-list";
import { AgentTracks } from "@/panels/agent-tracks";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
@@ -87,20 +87,12 @@ import {
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme";
import {
useArchiveFinishedSubagents,
useArchiveSubagent,
useDetachSubagent,
useSubagentsForParent,
} from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands";
@@ -1296,8 +1288,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
/>
</RenderProfile>
);
// The tracks float at the foot of the transcript, inside the same transform the composer uses,
// so the pills sit on its top edge with the timeline running under them. They follow the
// composer: an archived or archiving agent has none, and its children can no longer be acted on.
const hasComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const streamContent = (
<ReanimatedAnimated.View style={animatedContentStyle}>{streamSection}</ReanimatedAnimated.View>
<ReanimatedAnimated.View style={animatedContentStyle}>
{streamSection}
{hasComposer ? <AgentTracks serverId={serverId} agentId={agentId} /> : null}
</ReanimatedAnimated.View>
);
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
@@ -1506,37 +1505,11 @@ function ActiveAgentComposer({
{ initialIsBelow: isCompactFormFactor },
);
const paneContext = usePaneContext();
const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext;
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
const { archiveAgent } = useArchiveAgent();
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const subagentRows = useSubagentsForParent({
serverId,
parentAgentId: agentId,
});
const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
);
const handleOpenSubagent = useCallback(
(subagentId: string) => {
navigateToAgent({ serverId, agentId: subagentId });
},
[serverId],
);
const handleOpenProviderSubagent = useCallback(
(parentAgentId: string, subagentId: string) => {
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
},
[openTab],
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd,
@@ -1623,16 +1596,6 @@ function ActiveAgentComposer({
return (
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
<AgentTaskList serverId={serverId} agentId={agentId} />
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
<Composer
agentId={agentId}
serverId={serverId}
+74
View File
@@ -0,0 +1,74 @@
import { memo, useCallback, type ReactElement } from "react";
import { AgentTaskList } from "@/composer/task-list";
import { ComposerTrackBar } from "@/composer/tracks";
import { usePaneContext } from "@/panels/pane-context";
import { useSessionStore } from "@/stores/session-store";
import {
useArchiveFinishedSubagents,
useArchiveSubagent,
useDetachSubagent,
useSubagentsForParent,
} from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
import { navigateToAgent } from "@/utils/navigate-to-agent";
/**
* The pane's trackers — its subagents and its task list — as a row of pills over the foot of
* the transcript.
*
* It is mounted inside the transcript's animated container rather than above the composer, and
* that placement is the whole design: the pills paint over the timeline, so scrolled content
* passes under them instead of stopping at a band, and the container carries the same keyboard
* transform the composer does, so the pills stay glued to its top edge while the keyboard moves.
*
* Its state was living in the composer only because that is where it used to render. None of it
* is composer state — a subagent row opens a tab, the task list reads the agent's stream.
*/
export const AgentTracks = memo(function AgentTracks({
serverId,
agentId,
}: {
serverId: string;
agentId: string;
}): ReactElement {
const { openTab } = usePaneContext();
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
);
const archiveSubagent = useArchiveSubagent({ serverId });
const detachSubagent = useDetachSubagent({ serverId });
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const handleOpenSubagent = useCallback(
(subagentId: string) => {
navigateToAgent({ serverId, agentId: subagentId });
},
[serverId],
);
const handleOpenProviderSubagent = useCallback(
(parentAgentId: string, subagentId: string) => {
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
},
[openTab],
);
return (
<ComposerTrackBar>
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={archiveSubagent}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/>
<AgentTaskList serverId={serverId} agentId={agentId} />
</ComposerTrackBar>
);
});