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"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("react-native", () => ({ 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 }) => ( View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div> <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 { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; 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 { TurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { import {
@@ -23,6 +23,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[6];
export type TurnContentStrategy = StreamStrategy; export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: { export type AssistantTurnForkHandler = (input: {
@@ -226,7 +227,7 @@ const stylesheet = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
alignSelf: "flex-start", alignSelf: "flex-start",
minHeight: 24, minHeight: 24,
paddingBottom: theme.spacing[6], paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
}, },
turnFooterContent: { turnFooterContent: {
height: 24, height: 24,
+38 -8
View File
@@ -74,10 +74,12 @@ import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { import {
CompletedTurnFooterRow, CompletedTurnFooterRow,
TurnFooter, TurnFooter,
TURN_FOOTER_BOTTOM_SPACING,
type AssistantTurnForkHandler, type AssistantTurnForkHandler,
type InFlightTurnForkHandler, type InFlightTurnForkHandler,
type TurnContentStrategy, type TurnContentStrategy,
} from "./turn-footer"; } from "./turn-footer";
import { resolveBottomOverlayTailInset } from "./bottom-overlay-inset";
import { layoutStream, type StreamLayoutItem } from "./layout"; import { layoutStream, type StreamLayoutItem } from "./layout";
import { import {
type BottomAnchorLocalRequest, type BottomAnchorLocalRequest,
@@ -106,8 +108,9 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
function renderLiveAuxiliaryNode(input: { function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode; pendingPermissions: ReactNode;
turnFooter: ReactNode; turnFooter: ReactNode;
bottomOverlayInset: number;
}): ReactNode { }): ReactNode {
if (!input.pendingPermissions && !input.turnFooter) { if (!input.pendingPermissions && !input.turnFooter && input.bottomOverlayInset === 0) {
return null; return null;
} }
return ( return (
@@ -118,10 +121,18 @@ function renderLiveAuxiliaryNode(input: {
<View style={stylesheet.listHeaderContent}>{input.pendingPermissions}</View> <View style={stylesheet.listHeaderContent}>{input.pendingPermissions}</View>
</View> </View>
) : null} ) : 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: { function renderPendingPermissionsNode(input: {
pendingPermissions: PendingPermission[]; pendingPermissions: PendingPermission[];
client: DaemonClient | null; client: DaemonClient | null;
@@ -245,6 +256,10 @@ 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. */
bottomOverlayHeight?: 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;
@@ -293,6 +308,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
turnPresentation, turnPresentation,
routeBottomAnchorRequest = null, routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true, isAuthoritativeHistoryReady = true,
bottomOverlayHeight = 0,
bottomOverlayClearance = 0,
toast, toast,
onOpenWorkspaceFile, onOpenWorkspaceFile,
readOnly = false, readOnly = false,
@@ -946,11 +963,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}), }),
); );
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => { 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({ return renderLiveAuxiliaryNode({
pendingPermissions: auxiliary.pendingPermissions, pendingPermissions: auxiliary.pendingPermissions,
turnFooter: auxiliary.turnFooter, turnFooter: auxiliary.turnFooter,
bottomOverlayInset,
}); });
}, [auxiliary.pendingPermissions, auxiliary.turnFooter]); }, [
auxiliary.pendingPermissions,
auxiliary.turnFooter,
bottomOverlayClearance,
bottomOverlayHeight,
]);
const renderers = useMemo<StreamSegmentRenderers>( const renderers = useMemo<StreamSegmentRenderers>(
() => ({ () => ({
@@ -1508,7 +1538,7 @@ const stylesheet = StyleSheet.create((theme) => ({
}, },
syncingIndicatorText: { syncingIndicatorText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
}, },
invertedWrapper: { invertedWrapper: {
transform: [{ scaleY: -1 }], transform: [{ scaleY: -1 }],
@@ -1516,7 +1546,7 @@ const stylesheet = StyleSheet.create((theme) => ({
}, },
emptyStateText: { emptyStateText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
textAlign: "center", textAlign: "center",
}, },
scrollToBottomContainer: { scrollToBottomContainer: {
@@ -1556,7 +1586,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
description: { description: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
lineHeight: 20, lineHeight: 20,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
@@ -1564,10 +1594,10 @@ const permissionStyles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
sectionTitle: { sectionTitle: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
}, },
question: { question: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
marginTop: theme.spacing[1], marginTop: theme.spacing[1],
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
@@ -1603,7 +1633,7 @@ const permissionStyles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
optionText: { optionText: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
+10 -24
View File
@@ -3,6 +3,7 @@ import { Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Import as ImportIcon } from "lucide-react-native"; import { Import as ImportIcon } from "lucide-react-native";
import { composerPillStyles } from "@/composer/pill-styles";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
const ThemedImportIcon = withUnistyles(ImportIcon); const ThemedImportIcon = withUnistyles(ImportIcon);
@@ -18,8 +19,14 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []); const handleHoverOut = useCallback(() => setIsHovered(false), []);
const bodyStyle = useMemo(() => [styles.body, isHovered && styles.bodyHovered], [isHovered]); const bodyStyle = useMemo(
const labelStyle = useMemo(() => [styles.label, isHovered && styles.labelHovered], [isHovered]); () => [composerPillStyles.body, isHovered && composerPillStyles.bodyActive],
[isHovered],
);
const labelStyle = useMemo(
() => [composerPillStyles.label, isHovered && composerPillStyles.labelActive],
[isHovered],
);
return ( return (
<View style={styles.row}> <View style={styles.row}>
<Pressable <Pressable
@@ -41,29 +48,8 @@ export function ComposerImportPill({ onPress, disabled = false }: ComposerImport
); );
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create(() => ({
row: { row: {
flexDirection: "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 { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { ComposerImportPill } from "@/composer/draft/import-pill"; import { ComposerImportPill } from "@/composer/draft/import-pill";
import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
import { AgentStreamView } from "@/agent-stream/view"; import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useAgentInputDraft } from "@/composer/draft/input-draft";
@@ -751,8 +752,8 @@ const styles = StyleSheet.create((theme) => ({
importPillRow: { importPillRow: {
width: "100%", width: "100%",
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[3], paddingTop: COMPOSER_PILL_CLEARANCE,
paddingBottom: theme.spacing[3], paddingBottom: COMPOSER_PILL_CLEARANCE,
alignItems: "center", alignItems: "center",
}, },
importPillContent: { importPillContent: {
+33
View File
@@ -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 { StyleSheet } from "react-native-unistyles";
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks"; import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { TaskListRow } from "@/components/task-list-row"; import { TaskListRow } from "@/components/task-list-row";
import { useSessionStore } from "@/stores/session-store";
import type { TodoEntry } from "@/types/stream"; import type { TodoEntry } from "@/types/stream";
export const AgentTaskList = memo(function AgentTaskList({ export const AgentTaskList = memo(function AgentTaskList({
serverId, tasks,
agentId,
}: { }: {
serverId: string; tasks: TodoEntry[] | undefined;
agentId: string;
}) { }) {
const tasks = useSessionStore((state) => state.sessions[serverId]?.agentTasks.get(agentId));
if (!tasks?.length) return null; if (!tasks?.length) return null;
return <TaskListCard tasks={tasks} />; return <TaskListCard tasks={tasks} />;
}); });
+101
View File
@@ -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);
});
});
+26 -38
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 } from "react-native"; import { Pressable, Text, View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { import {
MenuRoot, MenuRoot,
@@ -12,6 +12,7 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry"; import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; 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. * 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 * 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; 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 * The bar floats over the transcript with no background, so content remains visible underneath.
* bottom edge and paints over it, with no background of its own and `pointerEvents="box-none"` * Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
* so the gaps between pills still belong to the timeline scrolling underneath. Absolute, not in * the final footer clear without turning the overlay into a layout band.
* 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.
*/ */
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 ( return (
<View style={styles.bar} pointerEvents="box-none"> <View style={styles.bar} pointerEvents="box-none" onLayout={handleLayout}>
<View style={styles.track} pointerEvents="box-none"> <View style={styles.track} pointerEvents="box-none">
{children} {children}
</View> </View>
@@ -119,12 +125,16 @@ function ComposerTrackPillTrigger({
const ariaExpandedProps = isWeb ? { "aria-expanded": open } : null; const ariaExpandedProps = isWeb ? { "aria-expanded": open } : null;
const pillStyle = useCallback( const pillStyle = useCallback(
({ hovered, pressed, open: isOpen }: MenuTriggerState) => [ ({ hovered, pressed, open: isOpen }: MenuTriggerState) => [
styles.pill, composerPillStyles.body,
(hovered || pressed || isOpen) && styles.pillActive, 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 ( return (
<MenuTrigger <MenuTrigger
@@ -245,30 +255,8 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
pill: { pillSpacing: {
flexDirection: "row", marginBottom: COMPOSER_PILL_CLEARANCE,
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,
}, },
// 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.
+57 -28
View File
@@ -25,6 +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_CLEARANCE, COMPOSER_PILL_MIN_HEIGHT } 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";
@@ -74,7 +75,7 @@ import {
deriveRouteBottomAnchorRequest, deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor"; } from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { AgentTracks } from "@/panels/agent-tracks"; import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
@@ -88,7 +89,8 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream"; import type { StreamItem, TodoEntry } from "@/types/stream";
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
@@ -1210,6 +1212,23 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
);
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const showAgentTracks = hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
});
const [agentTracksHeight, setAgentTracksHeight] = useState(
COMPOSER_PILL_MIN_HEIGHT + COMPOSER_PILL_CLEARANCE,
);
const rawAgentInputDraft = useAgentInputDraft({ const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({ draftKey: buildDraftStoreKey({
serverId, serverId,
@@ -1256,20 +1275,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
composerState, composerState,
], ],
); );
const streamSection = (
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
);
const composerSection = ( const composerSection = (
<RenderProfile id={`AgentComposerSection:${agentId}`}> <RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection <AgentComposerSection
@@ -1288,14 +1293,32 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
/> />
</RenderProfile> </RenderProfile>
); );
// The tracks float at the foot of the transcript, inside the same transform the composer uses,
// so the pills sit on its top edge with the timeline running under them. They follow the
// composer: an archived or archiving agent has none, and its children can no longer be acted on.
const hasComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const streamContent = ( const streamContent = (
<ReanimatedAnimated.View style={animatedContentStyle}> <ReanimatedAnimated.View style={animatedContentStyle}>
{streamSection} <RenderProfile id={`AgentStreamSection:${agentId}`}>
{hasComposer ? <AgentTracks serverId={serverId} agentId={agentId} /> : null} <AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
bottomOverlayHeight={showAgentTracks ? agentTracksHeight : 0}
bottomOverlayClearance={showAgentTracks ? COMPOSER_PILL_CLEARANCE : 0}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
{showAgentTracks ? (
<AgentTracks
serverId={serverId}
subagentRows={subagentRows}
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
onHeightChange={setAgentTracksHeight}
/>
) : null}
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
); );
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>; const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
@@ -1347,6 +1370,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
agent, agent,
routeBottomAnchorRequest, routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory, hasAppliedAuthoritativeHistory,
bottomOverlayHeight,
bottomOverlayClearance,
toast, toast,
onOpenWorkspaceFile, onOpenWorkspaceFile,
}: { }: {
@@ -1356,6 +1381,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
agent: AgentScreenAgent; agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest; routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean; hasAppliedAuthoritativeHistory: boolean;
bottomOverlayHeight: number;
bottomOverlayClearance: number;
toast: ReturnType<typeof useToastHost>["api"]; toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) { }) {
@@ -1414,6 +1441,8 @@ const AgentStreamSection = memo(function AgentStreamSection({
pendingPermissions={pendingPermissions} pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
bottomOverlayHeight={bottomOverlayHeight}
bottomOverlayClearance={bottomOverlayClearance}
toast={toast} toast={toast}
pendingMessageSubmissions={pendingMessageSubmissions} pendingMessageSubmissions={pendingMessageSubmissions}
turnPresentation={turnPresentation} turnPresentation={turnPresentation}
@@ -1744,13 +1773,13 @@ const styles = StyleSheet.create((theme) => ({
zIndex: 50, zIndex: 50,
}, },
archivingTitle: { archivingTitle: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground, color: theme.colors.foreground,
textAlign: "center", textAlign: "center",
}, },
archivingSubtitle: { archivingSubtitle: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textAlign: "center", textAlign: "center",
}, },
@@ -1777,14 +1806,14 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center", justifyContent: "center",
}, },
errorText: { errorText: {
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textAlign: "center", textAlign: "center",
}, },
statusText: { statusText: {
marginTop: theme.spacing[2], marginTop: theme.spacing[2],
textAlign: "center", textAlign: "center",
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
offlineTitle: { offlineTitle: {
@@ -1794,12 +1823,12 @@ const styles = StyleSheet.create((theme) => ({
textAlign: "center", textAlign: "center",
}, },
offlineDescription: { offlineDescription: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textAlign: "center", textAlign: "center",
}, },
offlineDetails: { offlineDetails: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
textAlign: "center", textAlign: "center",
}, },
+38 -25
View File
@@ -4,46 +4,43 @@ import { ComposerTrackBar } from "@/composer/tracks";
import { usePaneContext } from "@/panels/pane-context"; import { usePaneContext } from "@/panels/pane-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { import {
useArchiveFinishedSubagents, type ArchiveFinishedStatus,
useArchiveSubagent, useArchiveSubagent,
useDetachSubagent, useDetachSubagent,
useSubagentsForParent, type SubagentRow,
} from "@/subagents"; } from "@/subagents";
import { SubagentsTrack } from "@/subagents/track"; import { SubagentsTrack } from "@/subagents/track";
import type { TodoEntry } from "@/types/stream";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
/** /**
* The pane's trackers — its subagents and its task list — as a row of pills over the foot of * The pane's trackers — its subagents and its task list — as a row of pills above the composer.
* the transcript.
* *
* It is mounted inside the transcript's animated container rather than above the composer, and * The row shares the composer's keyboard transform and owns the space between itself and the
* that placement is the whole design: the pills paint over the timeline, so scrolled content * transcript. Its data remains agent state: a subagent row opens a tab and the task list reads
* passes under them instead of stopping at a band, and the container carries the same keyboard * the agent's stream.
* transform the composer does, so the pills stay glued to its top edge while the keyboard moves.
*
* Its state was living in the composer only because that is where it used to render. None of it
* is composer state — a subagent row opens a tab, the task list reads the agent's stream.
*/ */
export const AgentTracks = memo(function AgentTracks({ export const AgentTracks = memo(function AgentTracks({
serverId, serverId,
agentId, subagentRows,
tasks,
archiveFinishedStatus,
onArchiveFinished,
onHeightChange,
}: { }: {
serverId: string; serverId: string;
agentId: string; subagentRows: SubagentRow[];
}): ReactElement { tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void;
onHeightChange?: (height: number) => void;
}): ReactElement | null {
const { openTab } = usePaneContext(); const { openTab } = usePaneContext();
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const canDetachSubagents = useSessionStore( const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
); );
const archiveSubagent = useArchiveSubagent({ serverId }); const archiveSubagent = useArchiveSubagent({ serverId });
const detachSubagent = useDetachSubagent({ serverId }); const detachSubagent = useDetachSubagent({ serverId });
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const handleOpenSubagent = useCallback( const handleOpenSubagent = useCallback(
(subagentId: string) => { (subagentId: string) => {
navigateToAgent({ serverId, agentId: subagentId }); navigateToAgent({ serverId, agentId: subagentId });
@@ -57,18 +54,34 @@ export const AgentTracks = memo(function AgentTracks({
[openTab], [openTab],
); );
if (!hasAgentTracks({ subagentRows, tasks, archiveFinishedStatus })) {
return null;
}
return ( return (
<ComposerTrackBar> <ComposerTrackBar onHeightChange={onHeightChange}>
<SubagentsTrack <SubagentsTrack
rows={subagentRows} rows={subagentRows}
onOpenSubagent={handleOpenSubagent} onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent} onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={archiveSubagent} onArchiveSubagent={archiveSubagent}
onArchiveFinished={archiveFinishedSubagents.archiveFinished} onArchiveFinished={onArchiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status} archiveFinishedStatus={archiveFinishedStatus}
onDetachSubagent={canDetachSubagents ? detachSubagent : undefined} onDetachSubagent={canDetachSubagents ? detachSubagent : undefined}
/> />
<AgentTaskList serverId={serverId} agentId={agentId} /> <AgentTaskList tasks={tasks} />
</ComposerTrackBar> </ComposerTrackBar>
); );
}); });
export function hasAgentTracks({
subagentRows,
tasks,
archiveFinishedStatus,
}: {
subagentRows: readonly SubagentRow[];
tasks: readonly TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
}): boolean {
return subagentRows.length > 0 || Boolean(tasks?.length) || archiveFinishedStatus.kind !== "idle";
}