diff --git a/packages/app/src/agent-stream/bottom-overlay-inset.test.ts b/packages/app/src/agent-stream/bottom-overlay-inset.test.ts
new file mode 100644
index 000000000..7cef1c626
--- /dev/null
+++ b/packages/app/src/agent-stream/bottom-overlay-inset.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { resolveBottomOverlayTailInset } from "./bottom-overlay-inset";
+
+describe("resolveBottomOverlayTailInset", () => {
+ it("adds only the space missing after existing footer clearance", () => {
+ expect(
+ resolveBottomOverlayTailInset({
+ overlayHeight: 44,
+ clearance: 12,
+ existingTailSpacing: 24,
+ }),
+ ).toBe(32);
+ });
+
+ it("reserves the full overlay and clearance when the tail has no spacing", () => {
+ expect(
+ resolveBottomOverlayTailInset({
+ overlayHeight: 44,
+ clearance: 12,
+ existingTailSpacing: 0,
+ }),
+ ).toBe(56);
+ });
+});
diff --git a/packages/app/src/agent-stream/bottom-overlay-inset.ts b/packages/app/src/agent-stream/bottom-overlay-inset.ts
new file mode 100644
index 000000000..f462f766a
--- /dev/null
+++ b/packages/app/src/agent-stream/bottom-overlay-inset.ts
@@ -0,0 +1,11 @@
+export function resolveBottomOverlayTailInset({
+ overlayHeight,
+ clearance,
+ existingTailSpacing,
+}: {
+ overlayHeight: number;
+ clearance: number;
+ existingTailSpacing: number;
+}): number {
+ return Math.max(0, overlayHeight + clearance - existingTailSpacing);
+}
diff --git a/packages/app/src/agent-stream/turn-footer.test.tsx b/packages/app/src/agent-stream/turn-footer.test.tsx
index 86984e555..ab33c03bf 100644
--- a/packages/app/src/agent-stream/turn-footer.test.tsx
+++ b/packages/app/src/agent-stream/turn-footer.test.tsx
@@ -6,7 +6,10 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-native", () => ({
- Platform: { OS: "web" },
+ Platform: {
+ OS: "web",
+ select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
+ },
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => (
{children}
),
diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx
index 22408c583..733c54cee 100644
--- a/packages/app/src/agent-stream/turn-footer.tsx
+++ b/packages/app/src/agent-stream/turn-footer.tsx
@@ -2,7 +2,7 @@ import React, { memo, useCallback, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
-import type { Theme } from "@/styles/theme";
+import { SPACING, type Theme } from "@/styles/theme";
import type { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
@@ -23,6 +23,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
+export const TURN_FOOTER_BOTTOM_SPACING = SPACING[6];
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -226,7 +227,7 @@ const stylesheet = StyleSheet.create((theme) => ({
alignItems: "center",
alignSelf: "flex-start",
minHeight: 24,
- paddingBottom: theme.spacing[6],
+ paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
height: 24,
diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx
index 8bc999cb0..786f2a4bf 100644
--- a/packages/app/src/agent-stream/view.tsx
+++ b/packages/app/src/agent-stream/view.tsx
@@ -74,10 +74,12 @@ import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import {
CompletedTurnFooterRow,
TurnFooter,
+ TURN_FOOTER_BOTTOM_SPACING,
type AssistantTurnForkHandler,
type InFlightTurnForkHandler,
type TurnContentStrategy,
} from "./turn-footer";
+import { resolveBottomOverlayTailInset } from "./bottom-overlay-inset";
import { layoutStream, type StreamLayoutItem } from "./layout";
import {
type BottomAnchorLocalRequest,
@@ -106,8 +108,9 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
+ bottomOverlayInset: number;
}): ReactNode {
- if (!input.pendingPermissions && !input.turnFooter) {
+ if (!input.pendingPermissions && !input.turnFooter && input.bottomOverlayInset === 0) {
return null;
}
return (
@@ -118,10 +121,18 @@ function renderLiveAuxiliaryNode(input: {
{input.pendingPermissions}
) : null}
+ {input.bottomOverlayInset > 0 ? (
+
+ ) : null}
>
);
}
+function BottomOverlayInset({ height }: { height: number }) {
+ const style = useMemo(() => ({ height }), [height]);
+ return ;
+}
+
function renderPendingPermissionsNode(input: {
pendingPermissions: PendingPermission[];
client: DaemonClient | null;
@@ -245,6 +256,10 @@ export interface AgentStreamViewProps {
turnPresentation: TurnPresentation;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
+ /** Measured transparent overlay at the bottom edge, rendered by the owning panel. */
+ bottomOverlayHeight?: number;
+ /** Visible space kept between the final content and that overlay. */
+ bottomOverlayClearance?: number;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean;
@@ -293,6 +308,8 @@ const AgentStreamViewComponent = forwardRef(() => {
+ const existingTailSpacing =
+ auxiliary.turnFooter && !auxiliary.pendingPermissions ? TURN_FOOTER_BOTTOM_SPACING : 0;
+ const bottomOverlayInset = resolveBottomOverlayTailInset({
+ overlayHeight: bottomOverlayHeight,
+ clearance: bottomOverlayClearance,
+ existingTailSpacing,
+ });
return renderLiveAuxiliaryNode({
pendingPermissions: auxiliary.pendingPermissions,
turnFooter: auxiliary.turnFooter,
+ bottomOverlayInset,
});
- }, [auxiliary.pendingPermissions, auxiliary.turnFooter]);
+ }, [
+ auxiliary.pendingPermissions,
+ auxiliary.turnFooter,
+ bottomOverlayClearance,
+ bottomOverlayHeight,
+ ]);
const renderers = useMemo(
() => ({
@@ -1508,7 +1538,7 @@ const stylesheet = StyleSheet.create((theme) => ({
},
syncingIndicatorText: {
color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
+ fontSize: theme.fontSize.base,
},
invertedWrapper: {
transform: [{ scaleY: -1 }],
@@ -1516,7 +1546,7 @@ const stylesheet = StyleSheet.create((theme) => ({
},
emptyStateText: {
color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
+ fontSize: theme.fontSize.base,
textAlign: "center",
},
scrollToBottomContainer: {
@@ -1556,7 +1586,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
},
description: {
- fontSize: theme.fontSize.sm,
+ fontSize: theme.fontSize.base,
lineHeight: 20,
color: theme.colors.foregroundMuted,
},
@@ -1564,10 +1594,10 @@ const permissionStyles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
sectionTitle: {
- fontSize: theme.fontSize.xs,
+ fontSize: theme.fontSize.sm,
},
question: {
- fontSize: theme.fontSize.sm,
+ fontSize: theme.fontSize.base,
marginTop: theme.spacing[1],
marginBottom: theme.spacing[1],
color: theme.colors.foregroundMuted,
@@ -1603,7 +1633,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
optionText: {
- fontSize: theme.fontSize.sm,
+ fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
diff --git a/packages/app/src/composer/draft/import-pill.tsx b/packages/app/src/composer/draft/import-pill.tsx
index 37a16176b..46f93041d 100644
--- a/packages/app/src/composer/draft/import-pill.tsx
+++ b/packages/app/src/composer/draft/import-pill.tsx
@@ -3,6 +3,7 @@ import { Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Import as ImportIcon } from "lucide-react-native";
+import { composerPillStyles } from "@/composer/pill-styles";
import type { Theme } from "@/styles/theme";
const ThemedImportIcon = withUnistyles(ImportIcon);
@@ -18,8 +19,14 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
- const bodyStyle = useMemo(() => [styles.body, isHovered && styles.bodyHovered], [isHovered]);
- const labelStyle = useMemo(() => [styles.label, isHovered && styles.labelHovered], [isHovered]);
+ const bodyStyle = useMemo(
+ () => [composerPillStyles.body, isHovered && composerPillStyles.bodyActive],
+ [isHovered],
+ );
+ const labelStyle = useMemo(
+ () => [composerPillStyles.label, isHovered && composerPillStyles.labelActive],
+ [isHovered],
+ );
return (
({
+const styles = StyleSheet.create(() => ({
row: {
flexDirection: "row",
},
- body: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- paddingHorizontal: theme.spacing[3],
- paddingVertical: theme.spacing[2],
- borderRadius: theme.borderRadius.xl,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.borderAccent,
- backgroundColor: theme.colors.surface1,
- },
- bodyHovered: {
- backgroundColor: theme.colors.surface2,
- },
- label: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.sm,
- },
- labelHovered: {
- color: theme.colors.foreground,
- },
}));
diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx
index 0293d7c3e..9f0f48435 100644
--- a/packages/app/src/composer/draft/workspace-tab.tsx
+++ b/packages/app/src/composer/draft/workspace-tab.tsx
@@ -10,6 +10,7 @@ import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { ComposerImportPill } from "@/composer/draft/import-pill";
+import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
@@ -751,8 +752,8 @@ const styles = StyleSheet.create((theme) => ({
importPillRow: {
width: "100%",
paddingHorizontal: theme.spacing[4],
- paddingTop: theme.spacing[3],
- paddingBottom: theme.spacing[3],
+ paddingTop: COMPOSER_PILL_CLEARANCE,
+ paddingBottom: COMPOSER_PILL_CLEARANCE,
alignItems: "center",
},
importPillContent: {
diff --git a/packages/app/src/composer/pill-styles.ts b/packages/app/src/composer/pill-styles.ts
new file mode 100644
index 000000000..d85a9df82
--- /dev/null
+++ b/packages/app/src/composer/pill-styles.ts
@@ -0,0 +1,33 @@
+import { StyleSheet } from "react-native-unistyles";
+import { SPACING } from "@/styles/theme";
+
+export const COMPOSER_PILL_CLEARANCE = SPACING[4];
+export const COMPOSER_PILL_MIN_HEIGHT = 32;
+
+/** Shared visual contract for the compact pills immediately above the composer. */
+export const composerPillStyles = StyleSheet.create((theme) => ({
+ body: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ minHeight: COMPOSER_PILL_MIN_HEIGHT,
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[1],
+ // Match the composer's 16px tangent point. The 32px floor prevents short labels from
+ // clamping the radius and pulling the point where the curve meets the straight edge inward.
+ borderRadius: theme.borderRadius["2xl"],
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.borderAccent,
+ backgroundColor: theme.colors.surface1,
+ },
+ bodyActive: {
+ backgroundColor: theme.colors.surface2,
+ },
+ label: {
+ fontSize: theme.fontSize.sm,
+ color: theme.colors.foregroundMuted,
+ },
+ labelActive: {
+ color: theme.colors.foreground,
+ },
+}));
diff --git a/packages/app/src/composer/task-list/index.tsx b/packages/app/src/composer/task-list/index.tsx
index 81b9d7cc9..8a3537f3b 100644
--- a/packages/app/src/composer/task-list/index.tsx
+++ b/packages/app/src/composer/task-list/index.tsx
@@ -4,17 +4,13 @@ import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { TaskListRow } from "@/components/task-list-row";
-import { useSessionStore } from "@/stores/session-store";
import type { TodoEntry } from "@/types/stream";
export const AgentTaskList = memo(function AgentTaskList({
- serverId,
- agentId,
+ tasks,
}: {
- serverId: string;
- agentId: string;
+ tasks: TodoEntry[] | undefined;
}) {
- const tasks = useSessionStore((state) => state.sessions[serverId]?.agentTasks.get(agentId));
if (!tasks?.length) return null;
return ;
});
diff --git a/packages/app/src/composer/tracks.test.tsx b/packages/app/src/composer/tracks.test.tsx
new file mode 100644
index 000000000..d155cf4c6
--- /dev/null
+++ b/packages/app/src/composer/tracks.test.tsx
@@ -0,0 +1,101 @@
+/**
+ * @vitest-environment jsdom
+ */
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("react-native", () => ({
+ Platform: {
+ OS: "web",
+ select: (options: { web?: unknown; default?: unknown }) => options.web ?? options.default,
+ },
+ Pressable: "button",
+ Text: "span",
+ View: ({ children, style }: { children?: React.ReactNode; style?: unknown }) => (
+ {children}
+ ),
+}));
+
+vi.mock("react-native-unistyles", () => ({
+ StyleSheet: {
+ create: (factory: (theme: Record) => unknown) =>
+ factory({
+ spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 },
+ borderRadius: { md: 6, lg: 8, xl: 12, "2xl": 16, full: 9999 },
+ borderWidth: { 1: 1 },
+ fontSize: { sm: 12 },
+ colors: {
+ statusDotWarning: "#1",
+ statusDotDanger: "#2",
+ statusDotRunning: "#3",
+ statusDotSuccess: "#4",
+ border: "#5",
+ surface2: "#6",
+ borderAccent: "#7",
+ surface1: "#8",
+ foregroundMuted: "#9",
+ foreground: "#a",
+ },
+ }),
+ },
+}));
+
+vi.mock("@/components/ui/menu", () => ({
+ MenuRoot: ({ children }: { children?: React.ReactNode }) => children,
+ MenuSurface: ({ children }: { children?: React.ReactNode }) => children,
+ MenuTrigger: ({ children }: { children?: React.ReactNode }) => children,
+ useMenuContext: () => ({ open: false }),
+}));
+
+import { ComposerTrackBar } from "./tracks";
+import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
+
+describe("ComposerTrackBar", () => {
+ let root: Root | null = null;
+ let container: HTMLDivElement | null = null;
+
+ beforeEach(() => {
+ vi.stubGlobal("React", React);
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
+ value: true,
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ if (root) act(() => root?.unmount());
+ container?.remove();
+ root = null;
+ container = null;
+ vi.unstubAllGlobals();
+ });
+
+ it("floats over content without painting a background band", () => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ act(() => root?.render(track));
+
+ const style = JSON.parse(container.firstElementChild?.getAttribute("data-style") ?? "{}");
+ expect(style).toMatchObject({
+ position: "absolute",
+ left: 0,
+ right: 0,
+ bottom: 0,
+ paddingHorizontal: 16,
+ });
+ expect(style).not.toHaveProperty("backgroundColor");
+ });
+
+ it("matches the composer's corner tangent without radius clamping", () => {
+ expect(composerPillStyles.body).toMatchObject({
+ minHeight: 32,
+ paddingHorizontal: 12,
+ borderRadius: 16,
+ });
+ expect(composerPillStyles.label).toMatchObject({ fontSize: 12 });
+ expect(COMPOSER_PILL_CLEARANCE).toBe(16);
+ });
+});
diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx
index c85caa86b..cd75c3166 100644
--- a/packages/app/src/composer/tracks.tsx
+++ b/packages/app/src/composer/tracks.tsx
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react";
-import { Pressable, Text, View } from "react-native";
+import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import {
MenuRoot,
@@ -12,6 +12,7 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
+import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
/**
* The strip of pills where a pane's ambient trackers live — subagents and tasks today.
@@ -20,18 +21,23 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
* it for the detail. Trackers used to be stacked cards, so every one of them pushed the composer
* further down the pane; a row of pills costs one line no matter how many there are.
*
- * **Mount it in a container that spans the transcript.** The bar pins itself to that container's
- * bottom edge and paints over it, with no background of its own and `pointerEvents="box-none"`
- * so the gaps between pills still belong to the timeline scrolling underneath. Absolute, not in
- * flow: in flow it reserves a strip the transcript cannot use, which is the band this replaced.
- *
- * It stays a child of the transcript rather than a sibling floating above the composer because
- * on Android a view outside its parent's bounds paints but takes no touches — see
- * docs/floating-panels.md — and every pill here is pressable.
+ * The bar floats over the transcript with no background, so content remains visible underneath.
+ * Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
+ * the final footer clear without turning the overlay into a layout band.
*/
-export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
+export function ComposerTrackBar({
+ children,
+ onHeightChange,
+}: {
+ children: ReactNode;
+ onHeightChange?: (height: number) => void;
+}): ReactElement {
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
+ [onHeightChange],
+ );
return (
-
+
{children}
@@ -119,12 +125,16 @@ function ComposerTrackPillTrigger({
const ariaExpandedProps = isWeb ? { "aria-expanded": open } : null;
const pillStyle = useCallback(
({ hovered, pressed, open: isOpen }: MenuTriggerState) => [
- styles.pill,
- (hovered || pressed || isOpen) && styles.pillActive,
+ composerPillStyles.body,
+ styles.pillSpacing,
+ (hovered || pressed || isOpen) && composerPillStyles.bodyActive,
],
[],
);
- const labelStyle = useMemo(() => [styles.pillLabel, open && styles.pillLabelActive], [open]);
+ const labelStyle = useMemo(
+ () => [composerPillStyles.label, open && composerPillStyles.labelActive],
+ [open],
+ );
return (
({
alignItems: "center",
gap: theme.spacing[1],
},
- pill: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- paddingHorizontal: theme.spacing[3],
- paddingVertical: theme.spacing[1],
- marginBottom: theme.spacing[2],
- // One step inside the composer's corner, not a stadium: the pills sit directly on top of it
- // and read as part of the same stack. See composer/input/input.tsx `inputWrapper`.
- borderRadius: theme.borderRadius.xl,
- borderWidth: theme.borderWidth[1],
- borderColor: theme.colors.border,
- backgroundColor: theme.colors.surface1,
- },
- pillActive: {
- backgroundColor: theme.colors.surface2,
- borderColor: theme.colors.borderAccent,
- },
- pillLabel: {
- fontSize: theme.fontSize.xs,
- color: theme.colors.foregroundMuted,
- },
- pillLabelActive: {
- color: theme.colors.foreground,
+ pillSpacing: {
+ marginBottom: COMPOSER_PILL_CLEARANCE,
},
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx
index 5b4776f32..0d3c80d10 100644
--- a/packages/app/src/panels/agent-panel.tsx
+++ b/packages/app/src/panels/agent-panel.tsx
@@ -25,6 +25,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { SidebarCallout } from "@/components/sidebar-callout";
import { Composer } from "@/composer";
+import { COMPOSER_PILL_CLEARANCE, COMPOSER_PILL_MIN_HEIGHT } from "@/composer/pill-styles";
import { getActiveMessageSubmissions } from "@/composer/submission/model";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import { getProviderIcon } from "@/components/provider-icons";
@@ -74,7 +75,7 @@ import {
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
-import { AgentTracks } from "@/panels/agent-tracks";
+import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
@@ -88,7 +89,8 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme";
import type { PendingPermission } from "@/types/shared";
-import type { StreamItem } from "@/types/stream";
+import type { StreamItem, TodoEntry } from "@/types/stream";
+import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
@@ -1210,6 +1212,23 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
+ const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
+ const tasks = useSessionStore((state): TodoEntry[] | undefined =>
+ state.sessions[serverId]?.agentTasks.get(agentId),
+ );
+ const archiveFinishedSubagents = useArchiveFinishedSubagents({
+ serverId,
+ parentAgentId: agentId,
+ rows: subagentRows,
+ });
+ const showAgentTracks = hasAgentTracks({
+ subagentRows,
+ tasks,
+ archiveFinishedStatus: archiveFinishedSubagents.status,
+ });
+ const [agentTracksHeight, setAgentTracksHeight] = useState(
+ COMPOSER_PILL_MIN_HEIGHT + COMPOSER_PILL_CLEARANCE,
+ );
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
@@ -1256,20 +1275,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
composerState,
],
);
- const streamSection = (
-
-
-
- );
const composerSection = (
);
- // 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 = (
- {streamSection}
- {hasComposer ? : null}
+
+
+
+ {showAgentTracks ? (
+
+ ) : null}
);
const contentContainer = {streamContent};
@@ -1347,6 +1370,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
agent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
+ bottomOverlayHeight,
+ bottomOverlayClearance,
toast,
onOpenWorkspaceFile,
}: {
@@ -1356,6 +1381,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
+ bottomOverlayHeight: number;
+ bottomOverlayClearance: number;
toast: ReturnType["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
@@ -1414,6 +1441,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
+ bottomOverlayHeight={bottomOverlayHeight}
+ bottomOverlayClearance={bottomOverlayClearance}
toast={toast}
pendingMessageSubmissions={pendingMessageSubmissions}
turnPresentation={turnPresentation}
@@ -1744,13 +1773,13 @@ const styles = StyleSheet.create((theme) => ({
zIndex: 50,
},
archivingTitle: {
- fontSize: theme.fontSize.lg,
+ fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
archivingSubtitle: {
- fontSize: theme.fontSize.sm,
+ fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
@@ -1777,14 +1806,14 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
},
errorText: {
- fontSize: theme.fontSize.lg,
+ fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
statusText: {
marginTop: theme.spacing[2],
textAlign: "center",
- fontSize: theme.fontSize.sm,
+ fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
offlineTitle: {
@@ -1794,12 +1823,12 @@ const styles = StyleSheet.create((theme) => ({
textAlign: "center",
},
offlineDescription: {
- fontSize: theme.fontSize.sm,
+ fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
offlineDetails: {
- fontSize: theme.fontSize.xs,
+ fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx
index b2d6db35a..7ec919b35 100644
--- a/packages/app/src/panels/agent-tracks.tsx
+++ b/packages/app/src/panels/agent-tracks.tsx
@@ -4,46 +4,43 @@ import { ComposerTrackBar } from "@/composer/tracks";
import { usePaneContext } from "@/panels/pane-context";
import { useSessionStore } from "@/stores/session-store";
import {
- useArchiveFinishedSubagents,
+ type ArchiveFinishedStatus,
useArchiveSubagent,
useDetachSubagent,
- useSubagentsForParent,
+ type SubagentRow,
} from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
+import type { TodoEntry } from "@/types/stream";
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.
+ * The pane's trackers — its subagents and its task list — as a row of pills above the composer.
*
- * 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.
+ * The row shares the composer's keyboard transform and owns the space between itself and the
+ * transcript. Its data remains agent state: a subagent row opens a tab and the task list reads
+ * the agent's stream.
*/
export const AgentTracks = memo(function AgentTracks({
serverId,
- agentId,
+ subagentRows,
+ tasks,
+ archiveFinishedStatus,
+ onArchiveFinished,
+ onHeightChange,
}: {
serverId: string;
- agentId: string;
-}): ReactElement {
+ subagentRows: SubagentRow[];
+ tasks: TodoEntry[] | undefined;
+ archiveFinishedStatus: ArchiveFinishedStatus;
+ onArchiveFinished: () => void;
+ onHeightChange?: (height: number) => void;
+}): ReactElement | null {
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 });
@@ -57,18 +54,34 @@ export const AgentTracks = memo(function AgentTracks({
[openTab],
);
+ if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
+ return null;
+ }
+
return (
-
+
-
+
);
});
+
+export function hasAgentTracks({
+ subagentRows,
+ tasks,
+ archiveFinishedStatus,
+}: {
+ subagentRows: readonly SubagentRow[];
+ tasks: readonly TodoEntry[] | undefined;
+ archiveFinishedStatus: ArchiveFinishedStatus;
+}): boolean {
+ return subagentRows.length > 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle";
+}