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:
@@ -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 (
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
@@ -41,29 +48,8 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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 <TaskListCard tasks={tasks} />;
|
||||
});
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-style={JSON.stringify(style)}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: (theme: Record<string, unknown>) => 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(<ComposerTrackBar>track</ComposerTrackBar>));
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<View style={styles.bar} pointerEvents="box-none">
|
||||
<View style={styles.bar} pointerEvents="box-none" onLayout={handleLayout}>
|
||||
<View style={styles.track} pointerEvents="box-none">
|
||||
{children}
|
||||
</View>
|
||||
@@ -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 (
|
||||
<MenuTrigger
|
||||
@@ -245,30 +255,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user