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.
This commit is contained in:
Mohamed Boudra
2026-08-17 22:55:45 +02:00
parent d7f892251f
commit df3e646fbe
13 changed files with 350 additions and 134 deletions
@@ -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);
});
});
@@ -0,0 +1,11 @@
export function resolveBottomOverlayTailInset({
overlayHeight,
clearance,
existingTailSpacing,
}: {
overlayHeight: number;
clearance: number;
existingTailSpacing: number;
}): number {
return Math.max(0, overlayHeight + clearance - existingTailSpacing);
}
@@ -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 }) => (
<div data-testid={testID}>{children}</div>
),
@@ -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,
+38 -8
View File
@@ -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: {
<View style={stylesheet.listHeaderContent}>{input.pendingPermissions}</View>
</View>
) : null}
{input.bottomOverlayInset > 0 ? (
<BottomOverlayInset height={input.bottomOverlayInset} />
) : null}
</>
);
}
function BottomOverlayInset({ height }: { height: number }) {
const style = useMemo(() => ({ height }), [height]);
return <View style={style} />;
}
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<AgentStreamViewHandle, AgentStreamVi
turnPresentation,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
bottomOverlayHeight = 0,
bottomOverlayClearance = 0,
toast,
onOpenWorkspaceFile,
readOnly = false,
@@ -946,11 +963,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}),
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
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<StreamSegmentRenderers>(
() => ({
@@ -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,
},