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:
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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} />;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { 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.
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user