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.
This commit is contained in:
Mohamed Boudra
2026-08-17 23:23:51 +02:00
parent a6a533bc6a
commit 1cc6712e51
8 changed files with 62 additions and 69 deletions
@@ -5,8 +5,7 @@ describe("resolveBottomOverlayTailInset", () => {
it("adds only the space missing after existing footer clearance", () => { it("adds only the space missing after existing footer clearance", () => {
expect( expect(
resolveBottomOverlayTailInset({ resolveBottomOverlayTailInset({
overlayHeight: 44, requiredTailClearance: 56,
clearance: 12,
existingTailSpacing: 24, existingTailSpacing: 24,
}), }),
).toBe(32); ).toBe(32);
@@ -15,8 +14,7 @@ describe("resolveBottomOverlayTailInset", () => {
it("reserves the full overlay and clearance when the tail has no spacing", () => { it("reserves the full overlay and clearance when the tail has no spacing", () => {
expect( expect(
resolveBottomOverlayTailInset({ resolveBottomOverlayTailInset({
overlayHeight: 44, requiredTailClearance: 56,
clearance: 12,
existingTailSpacing: 0, existingTailSpacing: 0,
}), }),
).toBe(56); ).toBe(56);
@@ -1,11 +1,9 @@
export function resolveBottomOverlayTailInset({ export function resolveBottomOverlayTailInset({
overlayHeight, requiredTailClearance,
clearance,
existingTailSpacing, existingTailSpacing,
}: { }: {
overlayHeight: number; requiredTailClearance: number;
clearance: number;
existingTailSpacing: number; existingTailSpacing: number;
}): number { }): number {
return Math.max(0, overlayHeight + clearance - existingTailSpacing); return Math.max(0, requiredTailClearance - existingTailSpacing);
} }
+5 -14
View File
@@ -256,10 +256,8 @@ export interface AgentStreamViewProps {
turnPresentation: TurnPresentation; turnPresentation: TurnPresentation;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean; isAuthoritativeHistoryReady?: boolean;
/** Measured transparent overlay at the bottom edge, rendered by the owning panel. */ /** Tail space required by a transparent overlay rendered at the bottom edge. */
bottomOverlayHeight?: number; bottomOverlayTailClearance?: number;
/** Visible space kept between the final content and that overlay. */
bottomOverlayClearance?: number;
toast?: ToastApi | null; toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean; readOnly?: boolean;
@@ -308,8 +306,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnPresentation, turnPresentation,
routeBottomAnchorRequest = null, routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true, isAuthoritativeHistoryReady = true,
bottomOverlayHeight = 0, bottomOverlayTailClearance = 0,
bottomOverlayClearance = 0,
toast, toast,
onOpenWorkspaceFile, onOpenWorkspaceFile,
readOnly = false, readOnly = false,
@@ -966,8 +963,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const existingTailSpacing = const existingTailSpacing =
auxiliary.turnFooter && !auxiliary.pendingPermissions ? TURN_FOOTER_BOTTOM_SPACING : 0; auxiliary.turnFooter && !auxiliary.pendingPermissions ? TURN_FOOTER_BOTTOM_SPACING : 0;
const bottomOverlayInset = resolveBottomOverlayTailInset({ const bottomOverlayInset = resolveBottomOverlayTailInset({
overlayHeight: bottomOverlayHeight, requiredTailClearance: bottomOverlayTailClearance,
clearance: bottomOverlayClearance,
existingTailSpacing, existingTailSpacing,
}); });
return renderLiveAuxiliaryNode({ return renderLiveAuxiliaryNode({
@@ -975,12 +971,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnFooter: auxiliary.turnFooter, turnFooter: auxiliary.turnFooter,
bottomOverlayInset, bottomOverlayInset,
}); });
}, [ }, [auxiliary.pendingPermissions, auxiliary.turnFooter, bottomOverlayTailClearance]);
auxiliary.pendingPermissions,
auxiliary.turnFooter,
bottomOverlayClearance,
bottomOverlayHeight,
]);
const renderers = useMemo<StreamSegmentRenderers>( const renderers = useMemo<StreamSegmentRenderers>(
() => ({ () => ({
+5
View File
@@ -11,6 +11,11 @@ export function resolveComposerPillClearance(isCompact: boolean): number {
return isCompact ? COMPOSER_PILL_CLEARANCE.compact : COMPOSER_PILL_CLEARANCE.wide; 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. */ /** Shared visual contract for the compact pills immediately above the composer. */
export const composerPillStyles = StyleSheet.create((theme) => ({ export const composerPillStyles = StyleSheet.create((theme) => ({
body: { body: {
+23 -2
View File
@@ -12,8 +12,18 @@ vi.mock("react-native", () => ({
}, },
Pressable: "button", Pressable: "button",
Text: "span", Text: "span",
View: ({ children, style }: { children?: React.ReactNode; style?: unknown }) => ( View: ({
<div data-style={JSON.stringify(style)}>{children}</div> children,
style,
onLayout,
}: {
children?: React.ReactNode;
style?: unknown;
onLayout?: unknown;
}) => (
<div data-style={JSON.stringify(style)} data-has-layout-handler={Boolean(onLayout)}>
{children}
</div>
), ),
})); }));
@@ -53,6 +63,7 @@ import {
COMPOSER_PILL_CLEARANCE, COMPOSER_PILL_CLEARANCE,
composerPillStyles, composerPillStyles,
resolveComposerPillClearance, resolveComposerPillClearance,
resolveComposerTrackTailClearance,
} from "./pill-styles"; } from "./pill-styles";
describe("ComposerTrackBar", () => { describe("ComposerTrackBar", () => {
@@ -89,8 +100,16 @@ describe("ComposerTrackBar", () => {
right: 0, right: 0,
bottom: 0, bottom: 0,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: { xs: 16, md: 10 },
}); });
expect(style).not.toHaveProperty("backgroundColor"); 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", () => { it("matches the composer's corner tangent without radius clamping", () => {
@@ -102,6 +121,8 @@ describe("ComposerTrackBar", () => {
expect(composerPillStyles.label).toMatchObject({ fontSize: 12 }); expect(composerPillStyles.label).toMatchObject({ fontSize: 12 });
expect(resolveComposerPillClearance(true)).toBe(16); expect(resolveComposerPillClearance(true)).toBe(16);
expect(resolveComposerPillClearance(false)).toBe(10); expect(resolveComposerPillClearance(false)).toBe(10);
expect(resolveComposerTrackTailClearance(true)).toBe(64);
expect(resolveComposerTrackTailClearance(false)).toBe(52);
expect(COMPOSER_PILL_CLEARANCE).toEqual({ compact: 16, wide: 10 }); expect(COMPOSER_PILL_CLEARANCE).toEqual({ compact: 16, wide: 10 });
}); });
}); });
+9 -22
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react"; 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 { StyleSheet } from "react-native-unistyles";
import { import {
MenuRoot, 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 * 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 * 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. * 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 * 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. * the final footer clear without turning the overlay into a layout band.
*/ */
export function ComposerTrackBar({ export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
children,
onHeightChange,
}: {
children: ReactNode;
onHeightChange?: (height: number) => void;
}): ReactElement {
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
[onHeightChange],
);
return ( return (
<View style={styles.bar} pointerEvents="box-none" onLayout={handleLayout}> <View style={styles.bar} pointerEvents="box-none">
<View style={styles.track} pointerEvents="box-none"> <View style={styles.track} pointerEvents="box-none">
{children} {children}
</View> </View>
@@ -126,7 +117,6 @@ function ComposerTrackPillTrigger({
const pillStyle = useCallback( const pillStyle = useCallback(
({ hovered, pressed, open: isOpen }: MenuTriggerState) => [ ({ hovered, pressed, open: isOpen }: MenuTriggerState) => [
composerPillStyles.body, composerPillStyles.body,
styles.pillSpacing,
(hovered || pressed || isOpen) && composerPillStyles.bodyActive, (hovered || pressed || isOpen) && composerPillStyles.bodyActive,
], ],
[], [],
@@ -246,21 +236,18 @@ const styles = StyleSheet.create((theme) => ({
bottom: 0, bottom: 0,
alignItems: "center", alignItems: "center",
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingBottom: {
xs: COMPOSER_PILL_CLEARANCE.compact,
md: COMPOSER_PILL_CLEARANCE.wide,
},
}, },
track: { track: {
width: "100%", width: "100%",
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
flexDirection: "row", flexDirection: "row",
flexWrap: "wrap",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], 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 // 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. // inside it, and tall enough that revealing an action button cannot resize the row.
row: { row: {
+14 -19
View File
@@ -25,7 +25,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel"; import { useRetainedPanelActive } from "@/components/retained-panel";
import { SidebarCallout } from "@/components/sidebar-callout"; import { SidebarCallout } from "@/components/sidebar-callout";
import { Composer } from "@/composer"; 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 { getActiveMessageSubmissions } from "@/composer/submission/model";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
@@ -1213,7 +1213,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor(); const isCompactFormFactor = useIsCompactFormFactor();
const composerPillClearance = resolveComposerPillClearance(isCompactFormFactor); const composerTrackTailClearance = resolveComposerTrackTailClearance(isCompactFormFactor);
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined => const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId), state.sessions[serverId]?.agentTasks.get(agentId),
@@ -1223,14 +1223,14 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
parentAgentId: agentId, parentAgentId: agentId,
rows: subagentRows, rows: subagentRows,
}); });
const showAgentTracks = hasAgentTracks({ const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
subagentRows, const showAgentTracks =
tasks, hasActiveComposer &&
archiveFinishedStatus: archiveFinishedSubagents.status, hasAgentTracks({
}); subagentRows,
const [agentTracksHeight, setAgentTracksHeight] = useState( tasks,
COMPOSER_PILL_MIN_HEIGHT + composerPillClearance, archiveFinishedStatus: archiveFinishedSubagents.status,
); });
const rawAgentInputDraft = useAgentInputDraft({ const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({ draftKey: buildDraftStoreKey({
serverId, serverId,
@@ -1305,8 +1305,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
agent={effectiveAgent} agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory} hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
bottomOverlayHeight={showAgentTracks ? agentTracksHeight : 0} bottomOverlayTailClearance={showAgentTracks ? composerTrackTailClearance : 0}
bottomOverlayClearance={showAgentTracks ? composerPillClearance : 0}
toast={toastApi} toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile} onOpenWorkspaceFile={onOpenWorkspaceFile}
/> />
@@ -1318,7 +1317,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
tasks={tasks} tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status} archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={archiveFinishedSubagents.archiveFinished} onArchiveFinished={archiveFinishedSubagents.archiveFinished}
onHeightChange={setAgentTracksHeight}
/> />
) : null} ) : null}
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
@@ -1372,8 +1370,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
agent, agent,
routeBottomAnchorRequest, routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory, hasAppliedAuthoritativeHistory,
bottomOverlayHeight, bottomOverlayTailClearance,
bottomOverlayClearance,
toast, toast,
onOpenWorkspaceFile, onOpenWorkspaceFile,
}: { }: {
@@ -1383,8 +1380,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
agent: AgentScreenAgent; agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest; routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean; hasAppliedAuthoritativeHistory: boolean;
bottomOverlayHeight: number; bottomOverlayTailClearance: number;
bottomOverlayClearance: number;
toast: ReturnType<typeof useToastHost>["api"]; toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) { }) {
@@ -1443,8 +1439,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
pendingPermissions={pendingPermissions} pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
bottomOverlayHeight={bottomOverlayHeight} bottomOverlayTailClearance={bottomOverlayTailClearance}
bottomOverlayClearance={bottomOverlayClearance}
toast={toast} toast={toast}
pendingMessageSubmissions={pendingMessageSubmissions} pendingMessageSubmissions={pendingMessageSubmissions}
turnPresentation={turnPresentation} turnPresentation={turnPresentation}
+1 -3
View File
@@ -26,14 +26,12 @@ export const AgentTracks = memo(function AgentTracks({
tasks, tasks,
archiveFinishedStatus, archiveFinishedStatus,
onArchiveFinished, onArchiveFinished,
onHeightChange,
}: { }: {
serverId: string; serverId: string;
subagentRows: SubagentRow[]; subagentRows: SubagentRow[];
tasks: TodoEntry[] | undefined; tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus; archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void; onArchiveFinished: () => void;
onHeightChange?: (height: number) => void;
}): ReactElement | null { }): ReactElement | null {
const { openTab } = usePaneContext(); const { openTab } = usePaneContext();
const canDetachSubagents = useSessionStore( const canDetachSubagents = useSessionStore(
@@ -59,7 +57,7 @@ export const AgentTracks = memo(function AgentTracks({
} }
return ( return (
<ComposerTrackBar onHeightChange={onHeightChange}> <ComposerTrackBar>
<SubagentsTrack <SubagentsTrack
rows={subagentRows} rows={subagentRows}
onOpenSubagent={handleOpenSubagent} onOpenSubagent={handleOpenSubagent}