From 1cc6712e517425f346c9ed106d861316105b4af8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 17 Aug 2026 23:23:51 +0200 Subject: [PATCH] fix(app): make composer track clearance deterministic Layout measurement excluded pill margin on some renderers, collapsing the intended gap above visible tracks. Keep the two-pill rail on one line and derive its complete tail clearance from shared geometry so chat switching and layout callbacks cannot change the result. --- .../agent-stream/bottom-overlay-inset.test.ts | 6 ++-- .../src/agent-stream/bottom-overlay-inset.ts | 8 ++--- packages/app/src/agent-stream/view.tsx | 19 +++-------- packages/app/src/composer/pill-styles.ts | 5 +++ packages/app/src/composer/tracks.test.tsx | 25 ++++++++++++-- packages/app/src/composer/tracks.tsx | 31 +++++------------ packages/app/src/panels/agent-panel.tsx | 33 ++++++++----------- packages/app/src/panels/agent-tracks.tsx | 4 +-- 8 files changed, 62 insertions(+), 69 deletions(-) diff --git a/packages/app/src/agent-stream/bottom-overlay-inset.test.ts b/packages/app/src/agent-stream/bottom-overlay-inset.test.ts index 7cef1c626..a49d76659 100644 --- a/packages/app/src/agent-stream/bottom-overlay-inset.test.ts +++ b/packages/app/src/agent-stream/bottom-overlay-inset.test.ts @@ -5,8 +5,7 @@ describe("resolveBottomOverlayTailInset", () => { it("adds only the space missing after existing footer clearance", () => { expect( resolveBottomOverlayTailInset({ - overlayHeight: 44, - clearance: 12, + requiredTailClearance: 56, existingTailSpacing: 24, }), ).toBe(32); @@ -15,8 +14,7 @@ describe("resolveBottomOverlayTailInset", () => { it("reserves the full overlay and clearance when the tail has no spacing", () => { expect( resolveBottomOverlayTailInset({ - overlayHeight: 44, - clearance: 12, + requiredTailClearance: 56, 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 index f462f766a..012026ce3 100644 --- a/packages/app/src/agent-stream/bottom-overlay-inset.ts +++ b/packages/app/src/agent-stream/bottom-overlay-inset.ts @@ -1,11 +1,9 @@ export function resolveBottomOverlayTailInset({ - overlayHeight, - clearance, + requiredTailClearance, existingTailSpacing, }: { - overlayHeight: number; - clearance: number; + requiredTailClearance: number; existingTailSpacing: number; }): number { - return Math.max(0, overlayHeight + clearance - existingTailSpacing); + return Math.max(0, requiredTailClearance - existingTailSpacing); } diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 786f2a4bf..0c1eac043 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -256,10 +256,8 @@ 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; + /** Tail space required by a transparent overlay rendered at the bottom edge. */ + bottomOverlayTailClearance?: number; toast?: ToastApi | null; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; readOnly?: boolean; @@ -308,8 +306,7 @@ const AgentStreamViewComponent = forwardRef( () => ({ diff --git a/packages/app/src/composer/pill-styles.ts b/packages/app/src/composer/pill-styles.ts index 6d1dd7d26..4c42d0c29 100644 --- a/packages/app/src/composer/pill-styles.ts +++ b/packages/app/src/composer/pill-styles.ts @@ -11,6 +11,11 @@ export function resolveComposerPillClearance(isCompact: boolean): number { return isCompact ? COMPOSER_PILL_CLEARANCE.compact : COMPOSER_PILL_CLEARANCE.wide; } +export function resolveComposerTrackTailClearance(isCompact: boolean): number { + const clearance = resolveComposerPillClearance(isCompact); + return clearance + COMPOSER_PILL_MIN_HEIGHT + clearance; +} + /** Shared visual contract for the compact pills immediately above the composer. */ export const composerPillStyles = StyleSheet.create((theme) => ({ body: { diff --git a/packages/app/src/composer/tracks.test.tsx b/packages/app/src/composer/tracks.test.tsx index 60aed29ae..1c7c95dc5 100644 --- a/packages/app/src/composer/tracks.test.tsx +++ b/packages/app/src/composer/tracks.test.tsx @@ -12,8 +12,18 @@ vi.mock("react-native", () => ({ }, Pressable: "button", Text: "span", - View: ({ children, style }: { children?: React.ReactNode; style?: unknown }) => ( -
{children}
+ View: ({ + children, + style, + onLayout, + }: { + children?: React.ReactNode; + style?: unknown; + onLayout?: unknown; + }) => ( +
+ {children} +
), })); @@ -53,6 +63,7 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles, resolveComposerPillClearance, + resolveComposerTrackTailClearance, } from "./pill-styles"; describe("ComposerTrackBar", () => { @@ -89,8 +100,16 @@ describe("ComposerTrackBar", () => { right: 0, bottom: 0, paddingHorizontal: 16, + paddingBottom: { xs: 16, md: 10 }, }); expect(style).not.toHaveProperty("backgroundColor"); + expect(container.firstElementChild?.getAttribute("data-has-layout-handler")).toBe("false"); + + const trackStyle = JSON.parse( + container.firstElementChild?.firstElementChild?.getAttribute("data-style") ?? "{}", + ); + expect(trackStyle).toMatchObject({ flexDirection: "row" }); + expect(trackStyle).not.toHaveProperty("flexWrap"); }); it("matches the composer's corner tangent without radius clamping", () => { @@ -102,6 +121,8 @@ describe("ComposerTrackBar", () => { expect(composerPillStyles.label).toMatchObject({ fontSize: 12 }); expect(resolveComposerPillClearance(true)).toBe(16); expect(resolveComposerPillClearance(false)).toBe(10); + expect(resolveComposerTrackTailClearance(true)).toBe(64); + expect(resolveComposerTrackTailClearance(false)).toBe(52); expect(COMPOSER_PILL_CLEARANCE).toEqual({ compact: 16, wide: 10 }); }); }); diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 8bbc75d1a..976717822 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, type LayoutChangeEvent } from "react-native"; +import { Pressable, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { MenuRoot, @@ -19,25 +19,16 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles"; * * Everything in it is a pill: a count you can read without opening anything, and a panel behind * 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. + * further down the pane. The bar has at most one subagent pill and one task pill, so it remains + * one line and has deterministic geometry on every platform. * * 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, - onHeightChange, -}: { - children: ReactNode; - onHeightChange?: (height: number) => void; -}): ReactElement { - const handleLayout = useCallback( - (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height), - [onHeightChange], - ); +export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement { return ( - + {children} @@ -126,7 +117,6 @@ function ComposerTrackPillTrigger({ const pillStyle = useCallback( ({ hovered, pressed, open: isOpen }: MenuTriggerState) => [ composerPillStyles.body, - styles.pillSpacing, (hovered || pressed || isOpen) && composerPillStyles.bodyActive, ], [], @@ -246,21 +236,18 @@ const styles = StyleSheet.create((theme) => ({ bottom: 0, alignItems: "center", paddingHorizontal: theme.spacing[4], + paddingBottom: { + xs: COMPOSER_PILL_CLEARANCE.compact, + md: COMPOSER_PILL_CLEARANCE.wide, + }, }, track: { width: "100%", maxWidth: MAX_CONTENT_WIDTH, flexDirection: "row", - flexWrap: "wrap", alignItems: "center", gap: theme.spacing[1], }, - pillSpacing: { - marginBottom: { - xs: COMPOSER_PILL_CLEARANCE.compact, - md: COMPOSER_PILL_CLEARANCE.wide, - }, - }, // 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. row: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 7b2175c80..4f70c2b68 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -25,7 +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_MIN_HEIGHT, resolveComposerPillClearance } from "@/composer/pill-styles"; +import { resolveComposerTrackTailClearance } from "@/composer/pill-styles"; import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; @@ -1213,7 +1213,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ }) { const { t } = useTranslation(); const isCompactFormFactor = useIsCompactFormFactor(); - const composerPillClearance = resolveComposerPillClearance(isCompactFormFactor); + const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const tasks = useSessionStore((state): TodoEntry[] | undefined => state.sessions[serverId]?.agentTasks.get(agentId), @@ -1223,14 +1223,14 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ parentAgentId: agentId, rows: subagentRows, }); - const showAgentTracks = hasAgentTracks({ - subagentRows, - tasks, - archiveFinishedStatus: archiveFinishedSubagents.status, - }); - const [agentTracksHeight, setAgentTracksHeight] = useState( - COMPOSER_PILL_MIN_HEIGHT + composerPillClearance, - ); + const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent; + const showAgentTracks = + hasActiveComposer && + hasAgentTracks({ + subagentRows, + tasks, + archiveFinishedStatus: archiveFinishedSubagents.status, + }); const rawAgentInputDraft = useAgentInputDraft({ draftKey: buildDraftStoreKey({ serverId, @@ -1305,8 +1305,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ agent={effectiveAgent} routeBottomAnchorRequest={routeBottomAnchorRequest} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} - bottomOverlayHeight={showAgentTracks ? agentTracksHeight : 0} - bottomOverlayClearance={showAgentTracks ? composerPillClearance : 0} + bottomOverlayTailClearance={showAgentTracks ? composerTrackTailClearance : 0} toast={toastApi} onOpenWorkspaceFile={onOpenWorkspaceFile} /> @@ -1318,7 +1317,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ tasks={tasks} archiveFinishedStatus={archiveFinishedSubagents.status} onArchiveFinished={archiveFinishedSubagents.archiveFinished} - onHeightChange={setAgentTracksHeight} /> ) : null} @@ -1372,8 +1370,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ agent, routeBottomAnchorRequest, hasAppliedAuthoritativeHistory, - bottomOverlayHeight, - bottomOverlayClearance, + bottomOverlayTailClearance, toast, onOpenWorkspaceFile, }: { @@ -1383,8 +1380,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ agent: AgentScreenAgent; routeBottomAnchorRequest: RouteBottomAnchorRequest; hasAppliedAuthoritativeHistory: boolean; - bottomOverlayHeight: number; - bottomOverlayClearance: number; + bottomOverlayTailClearance: number; toast: ReturnType["api"]; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; }) { @@ -1443,8 +1439,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ pendingPermissions={pendingPermissions} routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} - bottomOverlayHeight={bottomOverlayHeight} - bottomOverlayClearance={bottomOverlayClearance} + bottomOverlayTailClearance={bottomOverlayTailClearance} toast={toast} pendingMessageSubmissions={pendingMessageSubmissions} turnPresentation={turnPresentation} diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 7ec919b35..b675aeb3c 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -26,14 +26,12 @@ export const AgentTracks = memo(function AgentTracks({ tasks, archiveFinishedStatus, onArchiveFinished, - onHeightChange, }: { serverId: string; subagentRows: SubagentRow[]; tasks: TodoEntry[] | undefined; archiveFinishedStatus: ArchiveFinishedStatus; onArchiveFinished: () => void; - onHeightChange?: (height: number) => void; }): ReactElement | null { const { openTab } = usePaneContext(); const canDetachSubagents = useSessionStore( @@ -59,7 +57,7 @@ export const AgentTracks = memo(function AgentTracks({ } return ( - +