From df3e646fbe976772c857500c14f76563a5f4b8da Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 22:34:32 +0200 Subject: [PATCH] fix(app): float composer track pills consistently Keep agent and task pills as a transparent transcript overlay while measuring their rendered height for scroll-tail clearance. Share geometry and spacing with the import-session pill so borders, type, corners, and composer separation match across platforms. --- .../agent-stream/bottom-overlay-inset.test.ts | 24 +++++ .../src/agent-stream/bottom-overlay-inset.ts | 11 ++ .../app/src/agent-stream/turn-footer.test.tsx | 5 +- packages/app/src/agent-stream/turn-footer.tsx | 5 +- packages/app/src/agent-stream/view.tsx | 46 ++++++-- .../app/src/composer/draft/import-pill.tsx | 34 ++---- .../app/src/composer/draft/workspace-tab.tsx | 5 +- packages/app/src/composer/pill-styles.ts | 33 ++++++ packages/app/src/composer/task-list/index.tsx | 8 +- packages/app/src/composer/tracks.test.tsx | 101 ++++++++++++++++++ packages/app/src/composer/tracks.tsx | 64 +++++------ packages/app/src/panels/agent-panel.tsx | 85 ++++++++++----- packages/app/src/panels/agent-tracks.tsx | 63 ++++++----- 13 files changed, 350 insertions(+), 134 deletions(-) create mode 100644 packages/app/src/agent-stream/bottom-overlay-inset.test.ts create mode 100644 packages/app/src/agent-stream/bottom-overlay-inset.ts create mode 100644 packages/app/src/composer/pill-styles.ts create mode 100644 packages/app/src/composer/tracks.test.tsx 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"; +}