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
View File
@@ -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: {
+23 -2
View File
@@ -12,8 +12,18 @@ vi.mock("react-native", () => ({
},
Pressable: "button",
Text: "span",
View: ({ children, style }: { children?: React.ReactNode; style?: unknown }) => (
<div data-style={JSON.stringify(style)}>{children}</div>
View: ({
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,
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 });
});
});
+9 -22
View File
@@ -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 (
<View style={styles.bar} pointerEvents="box-none" onLayout={handleLayout}>
<View style={styles.bar} pointerEvents="box-none">
<View style={styles.track} pointerEvents="box-none">
{children}
</View>
@@ -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: {