Organize composer into its own module

This commit is contained in:
Mohamed Boudra
2026-05-26 14:32:45 +07:00
parent 7e792e899a
commit 4231dbbba4
52 changed files with 935 additions and 706 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ The asymmetry is intentional: a subagent's home is the parent's track, not the t
## The subagents track
The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`):
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
```
parentAgentId === thisAgent.id AND !archivedAt
+1
View File
@@ -79,6 +79,7 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `SessionContext` wraps the daemon client for the active session
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- Voice features: dictation (STT) and voice agent (realtime)
+7
View File
@@ -21,6 +21,13 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
## Inconsistencies (documented, not papered over)
@@ -14,7 +14,7 @@ import {
} from "@gorhom/bottom-sheet";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import type { ImageAttachment } from "@/composer/types";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
@@ -1 +0,0 @@
export * from "./composer-height-mirror.native";
@@ -1,5 +0,0 @@
import type { DraftAgentStatusBarProps } from "./agent-status-bar";
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? "draft" : "ready";
}
@@ -4,7 +4,7 @@ import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-na
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input";
import type { ImageAttachment } from "@/composer/types";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps {
@@ -3,23 +3,25 @@ import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import type { CreateAgentRequestOptions, DaemonClient } from "@server/client/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ImageAttachment, MessagePayload } from "./message-input";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -347,18 +349,27 @@ export function WorkspaceSetupDialog() {
addImagesRef.current = addImages;
}, []);
const isCompact = useIsCompactFormFactor();
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const statusControlsWithDisabled = useMemo(
const agentControlsWithDisabled = useMemo(
() =>
composerState
? {
...composerState.statusControls,
...composerState.agentControls,
disabled: pendingAction !== null,
}
: undefined,
[composerState, pendingAction],
);
const composerFooter = useMemo(
() =>
isCompact && agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : undefined,
[isCompact, agentControlsWithDisabled],
);
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
@@ -412,9 +423,10 @@ export function WorkspaceSetupDialog() {
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={statusControlsWithDisabled}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
onAddImages={handleAddImagesCallback}
footer={composerFooter}
/>
</View>
@@ -26,7 +26,7 @@ import {
type ComposerSendClient,
type QueueWriter,
type QueuedComposerMessage,
} from "./composer-actions";
} from "./actions";
const imageMetadata: AttachmentMetadata = {
id: "img-1",
@@ -8,7 +8,7 @@ import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
@@ -0,0 +1,372 @@
import { memo, useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
ShieldQuestionMark,
} from "lucide-react-native";
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { useToast } from "@/contexts/toast-context";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { formatAgentModeLabel } from "@/composer/agent-controls/utils";
import type { AgentMode, AgentProvider } from "@server/server/agent/agent-sdk-types";
import {
getModeVisuals,
type AgentModeColorTier,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
export type AgentModeControlPlacement = "toolbar" | "footer";
function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompact: boolean) {
return placement === "footer" ? isCompact : !isCompact;
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
ShieldQuestionMark,
} as const;
interface ModePalette {
blue: { 500: string };
green: { 500: string };
red: { 500: string };
purple: { 500: string };
}
function getModeIconColor(colorTier: AgentModeColorTier | undefined, palette: ModePalette): string {
if (colorTier?.startsWith("#")) {
return colorTier;
}
switch (colorTier) {
case "safe":
return palette.green[500];
case "moderate":
return palette.blue[500];
case "dangerous":
return palette.red[500];
case "planning":
return palette.purple[500];
default:
return palette.blue[500];
}
}
interface ModeComboboxOptionProps {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
provider: string;
providerDefinitions: AgentProviderDefinition[];
iconColor: string;
}
function ModeComboboxOption({
option,
selected,
active,
onPress,
provider,
providerDefinitions,
iconColor,
}: ModeComboboxOptionProps) {
const visuals = getModeVisuals(provider, option.id, providerDefinitions);
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
const leadingSlot = useMemo(
() => <IconComponent size={16} color={iconColor} />,
[IconComponent, iconColor],
);
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
);
}
interface AgentModeControlViewProps {
provider: string;
providerDefinitions: AgentProviderDefinition[];
modeOptions: AgentMode[];
selectedModeId: string | null | undefined;
onSelectMode: (modeId: string) => void;
disabled?: boolean;
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function AgentModeControlView({
provider,
providerDefinitions,
modeOptions,
selectedModeId,
onSelectMode,
disabled = false,
}: AgentModeControlViewProps) {
const { theme } = useUnistyles();
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const selectedMode = useMemo(() => {
if (modeOptions.length === 0) return null;
return modeOptions.find((m) => m.id === selectedModeId) ?? modeOptions[0];
}, [modeOptions, selectedModeId]);
const visuals = selectedMode
? getModeVisuals(provider, selectedMode.id, providerDefinitions)
: undefined;
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck;
const iconColor = getModeIconColor(visuals?.colorTier, theme.colors.palette);
const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : "";
const allOptions = useMemo<ComboboxOption[]>(
() => modeOptions.map((m) => ({ id: m.id, label: formatAgentModeLabel(m) })),
[modeOptions],
);
const options = useMemo<ComboboxOption[]>(() => {
const q = normalizeSearchQuery(searchQuery);
if (!q) return allOptions;
return allOptions.filter((o) => o.label.toLowerCase().includes(q));
}, [allOptions, searchQuery]);
const handleOpenChange = useCallback((next: boolean) => {
setOpen(next);
if (!next) setSearchQuery("");
}, []);
const handlePress = useCallback(() => handleOpenChange(!open), [handleOpenChange, open]);
const handleSelect = useCallback(
(id: string) => {
onSelectMode(id);
handleOpenChange(false);
},
[onSelectMode, handleOpenChange],
);
const renderOption = useCallback(
(args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement => (
<ModeComboboxOption
option={args.option}
selected={args.selected}
active={args.active}
onPress={args.onPress}
provider={provider}
providerDefinitions={providerDefinitions}
iconColor={theme.colors.foreground}
/>
),
[provider, providerDefinitions, theme.colors.foreground],
);
const pressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
styles.chip,
hovered && styles.chipHovered,
(pressed || open) && styles.chipPressed,
disabled && styles.chipDisabled,
],
[open, disabled],
);
const labelStyle = useMemo(() => [styles.chipLabel, { color: iconColor }], [iconColor]);
const sheetHeader = useMemo<SheetHeader>(
() => ({
title: "Mode",
search: {
value: searchQuery,
onChange: setSearchQuery,
placeholder: "Search modes...",
testID: "mode-search-input",
},
}),
[searchQuery],
);
if (!selectedMode) return null;
return (
<>
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={pressableStyle}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${selectedModeLabel})`}
testID="mode-control"
>
<Icon size={theme.iconSize.md} color={iconColor} />
<Text style={labelStyle}>{selectedModeLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</Pressable>
<Combobox
options={options}
value={selectedMode.id}
onSelect={handleSelect}
open={open}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
desktopPlacement="top-start"
header={sheetHeader}
renderOption={renderOption}
/>
</>
);
}
const EMPTY_MODES: AgentMode[] = [];
function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean {
return a === b || JSON.stringify(a) === JSON.stringify(b);
}
interface AgentModeControlProps {
serverId: string;
agentId: string;
placement: AgentModeControlPlacement;
}
export const AgentModeControl = memo(function AgentModeControl({
serverId,
agentId,
placement,
}: AgentModeControlProps) {
const isCompact = useIsCompactFormFactor();
const slice = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
if (!agent) return null;
return {
provider: agent.provider,
cwd: agent.cwd,
currentModeId: agent.currentModeId,
};
}),
);
const availableModes = useStoreWithEqualityFn(
useSessionStore,
(state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES,
compareAvailableModes,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const toast = useToast();
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
const providerDefinitions = useMemo<AgentProviderDefinition[]>(() => {
if (!slice?.provider) return [];
const definition = resolveProviderDefinition(slice.provider, snapshotEntries);
return definition ? [definition] : [];
}, [slice?.provider, snapshotEntries]);
const handleSelectMode = useCallback(
(modeId: string) => {
if (!client) return;
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentModeControl] setAgentMode failed", error);
toast.error(toErrorMessage(error));
});
},
[agentId, client, toast],
);
if (!slice || availableModes.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (
<AgentModeControlView
provider={slice.provider}
providerDefinitions={providerDefinitions}
modeOptions={availableModes}
selectedModeId={slice.currentModeId}
onSelectMode={handleSelectMode}
disabled={!client}
/>
);
});
export interface DraftAgentModeControlProps {
selectedProvider: AgentProvider | null;
providerDefinitions: AgentProviderDefinition[];
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
disabled?: boolean;
placement: AgentModeControlPlacement;
}
export function DraftAgentModeControl({
selectedProvider,
providerDefinitions,
modeOptions,
selectedMode,
onSelectMode,
disabled,
placement,
}: DraftAgentModeControlProps) {
const isCompact = useIsCompactFormFactor();
if (!selectedProvider || modeOptions.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (
<AgentModeControlView
provider={selectedProvider}
providerDefinitions={providerDefinitions}
modeOptions={modeOptions}
selectedModeId={selectedMode}
onSelectMode={onSelectMode}
disabled={disabled}
/>
);
}
const styles = StyleSheet.create((theme) => ({
chip: {
height: 28,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
},
chipHovered: {
backgroundColor: theme.colors.surface2,
},
chipPressed: {
backgroundColor: theme.colors.surface0,
},
chipDisabled: {
opacity: 0.5,
},
chipLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -1,14 +1,14 @@
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./composer.status-controls";
import { resolveAgentControlsMode } from "./mode";
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {
expect(resolveStatusControlMode(undefined)).toBe("ready");
describe("resolveAgentControlsMode", () => {
it("uses ready mode when no controlled agent controls are provided", () => {
expect(resolveAgentControlsMode(undefined)).toBe("ready");
});
it("uses draft mode when controlled status controls are provided", () => {
it("uses draft mode when controlled agent controls are provided", () => {
expect(
resolveStatusControlMode({
resolveAgentControlsMode({
providerDefinitions: [],
selectedProvider: "codex",
onSelectProvider: () => undefined,
@@ -0,0 +1,5 @@
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) {
return agentControls ? "draft" : "ready";
}
@@ -1,7 +1,7 @@
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { isProviderModelsQueryLoading } from "./agent-status-bar.model-loading";
import { isProviderModelsQueryLoading } from "./model-loading";
describe("isProviderModelsQueryLoading", () => {
it("does not treat a disabled pending query as loading", () => {
@@ -1,17 +1,19 @@
import { describe, expect, it } from "vitest";
import {
formatAgentModeLabel,
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
getAgentControlHint,
formatThinkingOptionLabel,
normalizeModelId,
resolveAgentModelSelection,
} from "./agent-status-bar.utils";
} from "./utils";
describe("getStatusSelectorHint", () => {
it("explains what each editable status control does", () => {
expect(getStatusSelectorHint("thinking")).toBe("Thinking mode");
expect(getStatusSelectorHint("model")).toBe("Change model");
expect(getStatusSelectorHint("mode")).toBe("Change permission mode");
describe("getAgentControlHint", () => {
it("explains what each editable agent control does", () => {
expect(getAgentControlHint("thinking")).toBe("Thinking mode");
expect(getAgentControlHint("model")).toBe("Change model");
expect(getAgentControlHint("mode")).toBe("Change permission mode");
});
});
@@ -52,6 +54,36 @@ describe("normalizeModelId", () => {
});
});
describe("formatAgentModeLabel", () => {
it("sentence-cases provider mode labels", () => {
expect(formatAgentModeLabel({ id: "plan", label: "Plan" })).toBe("Plan");
expect(formatAgentModeLabel({ id: "full-access", label: "Full Access" })).toBe("Full access");
expect(formatAgentModeLabel({ id: "auto-review", label: "Auto-review" })).toBe("Auto-review");
expect(formatAgentModeLabel({ id: "read_only", label: "read_only" })).toBe("Read only");
expect(formatAgentModeLabel({ id: "acceptEdits", label: "acceptEdits" })).toBe("Accept edits");
});
it("splits compact mode ids when no provider label is available", () => {
expect(formatAgentModeLabel({ id: "auto-review" })).toBe("Auto review");
});
});
describe("formatThinkingOptionLabel", () => {
it("formats compact thinking option labels for display", () => {
expect(formatThinkingOptionLabel({ id: "none", label: "none" })).toBe("None");
expect(formatThinkingOptionLabel({ id: "low", label: "low" })).toBe("Low");
expect(formatThinkingOptionLabel({ id: "medium", label: "medium" })).toBe("Medium");
expect(formatThinkingOptionLabel({ id: "high", label: "high" })).toBe("High");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "xhigh" })).toBe("Extra high");
});
it("sentence-cases split provider labels", () => {
expect(formatThinkingOptionLabel({ id: "extra_high", label: "extra_high" })).toBe("Extra high");
expect(formatThinkingOptionLabel({ id: "think-hard", label: "think-hard" })).toBe("Think hard");
expect(formatThinkingOptionLabel({ id: "xhigh", label: "XHigh" })).toBe("Extra high");
});
});
describe("resolveAgentModelSelection", () => {
it("prefers runtime model over configured model", () => {
const selection = resolveAgentModelSelection({
@@ -97,6 +129,28 @@ describe("resolveAgentModelSelection", () => {
expect(selection.displayThinking).toBe("High");
});
it("formats raw thinking labels in the selected model display", () => {
const selection = resolveAgentModelSelection({
models: [
{
id: "a",
provider: "claude",
label: "Model A",
thinkingOptions: [
{ id: "none", label: "none" },
{ id: "xhigh", label: "xhigh" },
],
},
],
runtimeModelId: "a",
configuredModelId: null,
explicitThinkingOptionId: "xhigh",
});
expect(selection.selectedThinkingId).toBe("xhigh");
expect(selection.displayThinking).toBe("Extra high");
});
it("falls back to the provider default model label instead of Auto", () => {
const selection = resolveAgentModelSelection({
models: [
@@ -1,9 +1,9 @@
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
export function getAgentControlHint(selector: ExplainedAgentControl): string {
switch (selector) {
case "thinking":
return "Thinking mode";
@@ -39,6 +39,50 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
}
}
interface ControlLabelInput {
id: string;
label?: string | null;
}
function sentenceCase(value: string): string {
if (!value) {
return value;
}
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
function splitCompactLabel(value: string, splitHyphen: boolean): string {
const separatorPattern = splitHyphen ? /[_-]+/g : /_+/g;
return value
.replace(separatorPattern, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.replace(/\s+/g, " ")
.trim();
}
function formatControlLabel(option: ControlLabelInput, splitHyphen: boolean): string {
const rawLabel = (option.label ?? option.id).trim();
return sentenceCase(splitCompactLabel(rawLabel, splitHyphen));
}
export function formatAgentModeLabel(mode: ControlLabelInput): string {
return formatControlLabel(mode, mode.label == null);
}
export function formatThinkingOptionLabel(option: ControlLabelInput): string {
const rawLabel = (option.label ?? option.id).trim();
const compactId = option.id.replace(/[\s_-]+/g, "").toLowerCase();
const compactLabel = rawLabel.replace(/[\s_-]+/g, "").toLowerCase();
if (compactId === "xhigh" || compactLabel === "xhigh") {
return "Extra high";
}
return formatControlLabel(option, true);
}
function findModelById(
models: AgentModelDefinition[] | null,
modelId: string | null,
@@ -105,6 +149,21 @@ function resolveModelDisplay(
};
}
function resolveThinkingDisplay(
effectiveThinking: ThinkingOption | null,
selectedThinkingId: string | null,
): string {
if (effectiveThinking) {
return formatThinkingOptionLabel(effectiveThinking);
}
if (selectedThinkingId) {
return formatThinkingOptionLabel({ id: selectedThinkingId });
}
return "Unknown";
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
@@ -134,7 +193,7 @@ export function resolveAgentModelSelection(input: {
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";
const displayThinking = resolveThinkingDisplay(effectiveThinking, selectedThinkingId);
return {
selectedModel,
@@ -1,4 +1,5 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment } from "@/composer/types";
import {
isWorkspaceAttachment,
workspaceAttachmentToSubmitAttachment,
@@ -6,8 +7,6 @@ import {
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
export type ImageAttachment = AttachmentMetadata;
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
images: ImageAttachment[];
attachments: AgentAttachment[];
@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./use-draft-agent-create-flow";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "./create-flow";
describe("useDraftAgentCreateFlow", () => {
beforeEach(() => {
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useReducer } from "react";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -1,5 +1,5 @@
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
export interface DraftKeyContext {
@@ -18,12 +18,12 @@ export function resolveDraftKey(input: {
return input.draftKey;
}
export function buildDraftStatusControls(input: {
export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
}): DraftAgentStatusBarProps {
features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
}): DraftAgentControlsProps {
const { formState, features, onSetFeature, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
@@ -30,7 +30,7 @@ vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: async () => undefined,
}));
vi.mock("./use-agent-form-state", () => ({
vi.mock("@/hooks/use-agent-form-state", () => ({
useAgentFormState: () => ({
selectedServerId: "host-1",
setSelectedServerId: () => undefined,
@@ -114,7 +114,7 @@ vi.mock("./use-agent-form-state", () => ({
}),
}));
let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft;
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
beforeAll(async () => {
@@ -139,7 +139,7 @@ beforeAll(async () => {
configurable: true,
});
({ useAgentInputDraft } = await import("./use-agent-input-draft"));
({ useAgentInputDraft } = await import("./input-draft"));
});
describe("useAgentInputDraft live contract", () => {
@@ -209,7 +209,7 @@ describe("useAgentInputDraft live contract", () => {
);
});
expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.commandDraftConfig).toEqual({
provider: "codex",
cwd: "/repo",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveDraftKey } from "./use-agent-input-draft-core";
import { resolveDraftKey } from "./input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
@@ -10,11 +10,11 @@ import {
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftStatusControls,
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
type DraftKeyInput,
} from "@/hooks/use-agent-input-draft-core";
} from "@/composer/draft/input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
@@ -46,7 +46,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues: Record<string, unknown> | undefined;
statusControls: DraftAgentStatusBarProps;
agentControls: DraftAgentControlsProps;
commandDraftConfig: DraftCommandConfig | undefined;
};
@@ -278,7 +278,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
statusControls: buildDraftStatusControls({
agentControls: buildDraftAgentControls({
formState,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
@@ -314,5 +314,5 @@ export const __private__ = {
resolveEffectiveComposerThinkingOptionId,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftStatusControls,
buildDraftAgentControls,
};
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { validateDraftSubmission } from "./workspace-draft-agent-tab-core";
import { validateDraftSubmission } from "./workspace-tab-core";
const baseComposerState = {
providerDefinitions: [{ id: "deepseek-tui" }],
@@ -3,18 +3,16 @@ import { Keyboard, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import invariant from "tiny-invariant";
import { Composer } from "@/components/composer";
import { ComposerImportPill } from "@/screens/workspace/composer-import-pill";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/agent-stream/view";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { ImageAttachment } from "@/components/message-input";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import type { ImageAttachment } from "@/composer/types";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import {
useDraftAgentCreateFlow,
type DraftCreateAttempt,
} from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
@@ -26,7 +24,7 @@ import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submi
import { encodeImages } from "@/utils/encode-images";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import { validateDraftSubmission } from "@/screens/workspace/workspace-draft-agent-tab-core";
import { validateDraftSubmission } from "@/composer/draft/workspace-tab-core";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
import type { DaemonClient } from "@server/client/daemon-client";
@@ -192,7 +190,7 @@ function buildDraftAgentSnapshot(input: {
modeOptions: unknown[];
selectedMode: string;
selectedProvider: string | null;
statusControls: { features?: Agent["features"] };
agentControls: { features?: Agent["features"] };
};
}): Agent {
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
@@ -228,7 +226,7 @@ function buildDraftAgentSnapshot(input: {
title: "Agent",
cwd: workspaceDirectory,
model,
features: composerState.statusControls.features,
features: composerState.agentControls.features,
thinkingOptionId,
parentAgentId: null,
labels: {},
@@ -580,7 +578,7 @@ export function WorkspaceDraftAgentTab({
const handleSetFeatureWithFocus = useCallback(
(featureId: string, value: unknown) => {
composerState.statusControls.onSetFeature?.(featureId, value);
composerState.agentControls.onSetFeature?.(featureId, value);
focusInputRef.current?.();
},
[composerState],
@@ -595,9 +593,9 @@ export function WorkspaceDraftAgentTab({
focusInputRef.current?.();
}, []);
const importPillPress = resolveImportPillPress(onOpenImportSheet, isSubmitting);
const composerStatusControls = useMemo(
const composerAgentControls = useMemo(
() => ({
...composerState.statusControls,
...composerState.agentControls,
onSelectProvider: handleProviderSelectWithFocus,
onSelectMode: handleModeSelectWithFocus,
onSelectModel: handleModelSelectWithFocus,
@@ -608,7 +606,7 @@ export function WorkspaceDraftAgentTab({
disabled: isSubmitting,
}),
[
composerState.statusControls,
composerState.agentControls,
handleProviderSelectWithFocus,
handleModeSelectWithFocus,
handleModelSelectWithFocus,
@@ -619,6 +617,13 @@ export function WorkspaceDraftAgentTab({
isSubmitting,
],
);
const composerFooter = useMemo(
() =>
isCompact ? (
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
) : undefined,
[isCompact, composerAgentControls],
);
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
@@ -678,7 +683,8 @@ export function WorkspaceDraftAgentTab({
onAddImages={handleAddImagesCallback}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
statusControls={composerStatusControls}
agentControls={composerAgentControls}
footer={composerFooter}
/>
</View>
</View>
@@ -9,7 +9,7 @@ import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchClient } from "@/git/use-github-search-query";
import type { GitHubSearchItem, GitHubSearchResponse } from "@server/shared/messages";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
import { useComposerGithubAutoAttach } from "./auto-attach";
type GitHubSearchPayload = GitHubSearchResponse["payload"];
@@ -15,7 +15,7 @@ import {
} from "@/git/use-github-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { GitHubSearchItem } from "@server/shared/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "./composer-actions";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;
@@ -6,7 +6,16 @@ import {
Image,
type PressableStateCallbackType,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo, memo, type ReactElement } from "react";
import {
useState,
useEffect,
useRef,
useCallback,
useMemo,
memo,
type ReactElement,
type ReactNode,
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
@@ -23,20 +32,15 @@ import {
import Animated from "react-native-reanimated";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import {
AgentStatusBar,
DraftAgentStatusBar,
type DraftAgentStatusBarProps,
} from "./agent-status-bar";
import { ContextWindowMeter } from "./context-window-meter";
AgentControls,
DraftAgentControls,
type DraftAgentControlsProps,
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import {
MessageInput,
type MessagePayload,
type ImageAttachment,
type MessageInputRef,
type AttachmentMenuItem,
} from "./message-input";
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
@@ -56,7 +60,7 @@ import {
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
} from "@/components/composer-actions";
} from "@/composer/actions";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -74,12 +78,12 @@ import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/components/agent-input-submit";
import { submitAgentInput } from "@/composer/submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@server/shared/messages";
@@ -89,7 +93,7 @@ import type {
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { AttachmentPill } from "@/components/attachment-pill";
@@ -98,7 +102,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
type QueuedMessage = QueuedComposerMessage;
@@ -199,33 +203,42 @@ function renderContextWindowMeterSlot(
}
interface RenderLeftContentArgs {
statusControls: DraftAgentStatusBarProps | undefined;
agentControls: DraftAgentControlsProps | undefined;
agentId: string;
serverId: string;
focusInput: () => void;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
const { statusControls, agentId, serverId, focusInput } = args;
if (resolveStatusControlMode(statusControls) === "draft" && statusControls) {
return <DraftAgentStatusBar {...statusControls} />;
const { agentControls, agentId, serverId, focusInput } = args;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} />;
}
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
return <AgentControls agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
}
interface RenderAttachmentPreviewListArgs {
interface RenderAttachmentTrayArgs {
selectedAttachments: ComposerAttachment[];
isComposerLocked: boolean;
handleOpenAttachment: (attachment: ComposerAttachment) => void;
handleRemoveAttachment: (index: number) => void;
}
function renderAttachmentPreviewList(args: RenderAttachmentPreviewListArgs): ReactElement | null {
function renderComposerFooter(footer: ReactNode): ReactElement | null {
if (!footer) return null;
return (
<View style={styles.footer}>
<View style={styles.footerContent}>{footer}</View>
</View>
);
}
function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null {
const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } =
args;
if (selectedAttachments.length === 0) return null;
return (
<View style={styles.attachmentPreviewContainer} testID="composer-attachment-pills">
<View style={styles.attachmentTray} testID="composer-attachment-tray">
{selectedAttachments.map((attachment, index) =>
renderComposerAttachmentPill({
attachment,
@@ -239,17 +252,17 @@ function renderAttachmentPreviewList(args: RenderAttachmentPreviewListArgs): Rea
);
}
interface RenderQueueListArgs {
interface RenderQueueTrackArgs {
queuedMessages: readonly QueuedMessage[];
handleEditQueuedMessage: (id: string) => void;
handleSendQueuedNow: (id: string) => Promise<void>;
}
function renderQueueList(args: RenderQueueListArgs): ReactElement | null {
function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null {
const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args;
if (queuedMessages.length === 0) return null;
return (
<View style={styles.queueContainer}>
<View style={styles.queueTrack}>
{queuedMessages.map((item) => (
<QueuedMessageRow
key={item.id}
@@ -629,10 +642,12 @@ interface ComposerProps {
onComposerHeightChange?: (height: number) => void;
onAttentionInputFocus?: () => void;
onAttentionPromptSend?: () => void;
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps;
/** Controlled agent controls rendered in input area (draft flows). */
agentControls?: DraftAgentControlsProps;
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
/** Rendered below the input, inside the keyboard-shifted container. */
footer?: ReactNode;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -824,8 +839,9 @@ export function Composer({
onComposerHeightChange,
onAttentionInputFocus,
onAttentionPromptSend,
statusControls,
agentControls,
inputWrapperStyle,
footer,
}: ComposerProps) {
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
@@ -1499,8 +1515,8 @@ export function Composer({
);
const leftContent = useMemo(
() => renderLeftContent({ statusControls, agentId, serverId, focusInput }),
[agentId, focusInput, serverId, statusControls],
() => renderLeftContent({ agentControls, agentId, serverId, focusInput }),
[agentId, focusInput, serverId, agentControls],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -1566,9 +1582,9 @@ export function Composer({
[isComposerLocked],
);
const attachmentPreviewList = useMemo(
const attachmentTray = useMemo(
() =>
renderAttachmentPreviewList({
renderAttachmentTray({
selectedAttachments,
isComposerLocked,
handleOpenAttachment,
@@ -1578,7 +1594,7 @@ export function Composer({
);
const queueList = useMemo(
() => renderQueueList({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }),
() => renderQueueTrack({ queuedMessages, handleEditQueuedMessage, handleSendQueuedNow }),
[handleEditQueuedMessage, handleSendQueuedNow, queuedMessages],
);
@@ -1656,7 +1672,7 @@ export function Composer({
onFocusChange={handleFocusChange}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
attachmentSlot={attachmentPreviewList}
attachmentSlot={attachmentTray}
/>
<Combobox
options={githubSearchOptions}
@@ -1677,6 +1693,7 @@ export function Composer({
</View>
</View>
</View>
{renderComposerFooter(footer)}
</Animated.View>
);
}
@@ -1708,6 +1725,23 @@ const styles = StyleSheet.create((theme: Theme) => ({
maxWidth: MAX_CONTENT_WIDTH,
gap: theme.spacing[3],
},
footer: {
width: "100%",
paddingHorizontal: theme.spacing[4],
marginTop: {
xs: -theme.spacing[4],
md: -theme.spacing[3],
},
alignItems: "center",
},
footerContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
paddingLeft: {
xs: 6,
md: 10,
},
},
messageInputContainer: {
position: "relative",
width: "100%",
@@ -1747,7 +1781,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
attachmentPreviewContainer: {
attachmentTray: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
@@ -1794,7 +1828,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
buttonDisabled: {
opacity: 0.5,
},
queueContainer: {
queueTrack: {
flexDirection: "column",
gap: theme.spacing[2],
},
+1
View File
@@ -0,0 +1 @@
export * from "./height-mirror.native";
@@ -24,8 +24,8 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
import { RealtimeVoiceOverlay } from "./realtime-voice-overlay";
import { DictationOverlay } from "@/components/dictation-controls";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
import type { DaemonClient } from "@server/client/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { useVoiceOptional } from "@/contexts/voice-context";
@@ -35,8 +35,8 @@ import {
collectImageFilesFromClipboardData,
filesToImageAttachments,
} from "@/utils/image-attachments-from-files";
import type { AttachmentMetadata } from "@/attachments/types";
import type { ComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -55,18 +55,8 @@ import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { isWeb } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerHeightMirror } from "./composer-height-mirror";
import { computeCanStartDictation } from "./message-input-state";
export type ImageAttachment = AttachmentMetadata;
export interface MessagePayload {
text: string;
attachments: ComposerAttachment[];
cwd: string;
/** When true, bypasses queue and sends immediately even if agent is running */
forceSend?: boolean;
}
import { useComposerHeightMirror } from "./height-mirror";
import { computeCanStartDictation } from "./state";
export interface AttachmentMenuItem {
id: string;
@@ -103,7 +93,7 @@ export interface MessageInputProps {
disabled?: boolean;
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
isPaneFocused?: boolean;
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
/** Content to render on the left side of the composer toolbar (e.g., AgentControls) */
leftContent?: React.ReactNode;
/** Content to render on the right side before the voice button (e.g., context window meter) */
beforeVoiceContent?: React.ReactNode;
@@ -1771,7 +1761,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
{/* Button row */}
<View style={styles.buttonRow}>
{/* Left: attachment button + leftContent slot */}
{/* Toolbar left: attachment button + agent controls */}
<View style={styles.leftButtonGroup}>
<AttachmentDropdown
isConnected={isConnected}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computeCanStartDictation } from "./message-input-state";
import { computeCanStartDictation } from "./state";
const connected = { isConnected: true } as never;
const disconnected = { isConnected: false } as never;
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { submitAgentInput } from "./agent-input-submit";
import { submitAgentInput } from "./submit";
function createDeferredPromise<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
+10
View File
@@ -0,0 +1,10 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
export type ImageAttachment = AttachmentMetadata;
export interface MessagePayload {
text: string;
attachments: ComposerAttachment[];
cwd: string;
forceSend?: boolean;
}
@@ -53,7 +53,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import {
+1 -1
View File
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from "react";
import type { ImageAttachment } from "@/components/message-input";
import type { ImageAttachment } from "@/composer/types";
import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import { isWeb } from "@/constants/platform";
+16 -6
View File
@@ -10,10 +10,11 @@ import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/components/composer";
import { Composer } from "@/composer";
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
import { FileDropZone } from "@/components/file-drop-zone";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import type { ImageAttachment } from "@/components/message-input";
import type { ImageAttachment } from "@/composer/types";
import { getProviderIcon } from "@/components/provider-icons";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
@@ -25,7 +26,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useAgentInputDraft, type AgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
import {
type AgentScreenAgent,
type AgentScreenContinuity,
@@ -50,7 +51,7 @@ import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import { usePanelStore } from "@/stores/panel-store";
@@ -58,7 +59,7 @@ import { type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { Theme } from "@/styles/theme";
import { SubagentsSection, useArchiveSubagent, useSubagentsForParent } from "@/subagents";
import { SubagentsTrack, useArchiveSubagent, useSubagentsForParent } from "@/subagents";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
@@ -1395,9 +1396,17 @@ function ActiveAgentComposer({
[insets.bottom],
);
const composerFooter = useMemo(
() =>
isCompact ? (
<AgentModeControl serverId={serverId} agentId={agentId} placement="footer" />
) : undefined,
[isCompact, serverId, agentId],
);
return (
<View style={inputAreaStyle}>
<SubagentsSection
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onArchiveSubagent={handleArchiveSubagent}
@@ -1422,6 +1431,7 @@ function ActiveAgentComposer({
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand}
footer={composerFooter}
/>
</View>
);
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { ComposerAttachment } from "@/attachments/types";
import type { MessagePayload } from "@/components/message-input";
import type { MessagePayload } from "@/composer/types";
const navigateToWorkspace = vi.hoisted(() => vi.fn());
vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace }));
@@ -1,6 +1,6 @@
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import type { normalizeWorkspaceDescriptor } from "@/stores/session-store";
import type { MessagePayload } from "@/components/message-input";
import type { MessagePayload } from "@/composer/types";
import type { AgentAttachment } from "@server/shared/messages";
export function isEmptyWorkspaceSubmission(payload: MessagePayload): boolean {
@@ -3,12 +3,12 @@ import { Pressable, Text, View } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated from "react-native-reanimated";
import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query";
import { Check, ChevronDown, GitBranch, GitPullRequest, X } from "lucide-react-native";
import { Composer } from "@/components/composer";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { FileDropZone } from "@/components/file-drop-zone";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox";
@@ -18,9 +18,8 @@ import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { generateDraftId } from "@/stores/draft-keys";
@@ -31,7 +30,7 @@ import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import type { ImageAttachment, MessagePayload } from "@/components/message-input";
import type { ImageAttachment, MessagePayload } from "@/composer/types";
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
@@ -510,9 +509,6 @@ export function NewWorkspaceScreen({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const toast = useToast();
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -846,16 +842,11 @@ export function NewWorkspaceScreen({
[isCompact, insets.bottom],
);
const optionsRowStyle = useMemo(
() => [styles.optionsRow, keyboardAnimatedStyle],
[keyboardAnimatedStyle],
);
const statusControlsWithDisabled = useMemo(
const agentControlsWithDisabled = useMemo(
() =>
composerState
? {
...composerState.statusControls,
...composerState.agentControls,
disabled: isPending,
}
: undefined,
@@ -867,6 +858,73 @@ export function NewWorkspaceScreen({
? "Searching..."
: "No matching refs.";
const composerFooter = useMemo(
() => (
<View testID="new-workspace-ref-picker-row" style={styles.optionsRow}>
<View>
<RefPickerTrigger
pickerAnchorRef={pickerAnchorRef}
onPress={openPicker}
disabled={isPending}
badgePressableStyle={badgePressableStyle}
selectedItem={selectedItem}
triggerLabel={triggerLabel}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={options}
value={selectedOptionId}
onSelect={handleSelectOption}
searchable
searchPlaceholder="Search branches and PRs"
title="Start from"
open={pickerOpen}
onOpenChange={handlePickerOpenChange}
onSearchQueryChange={setPickerSearchQuery}
desktopPlacement="bottom-start"
anchorRef={pickerAnchorRef}
emptyText={pickerEmptyText}
renderOption={renderPickerOption}
/>
</View>
{agentControlsWithDisabled ? (
<DraftAgentModeControl placement="footer" {...agentControlsWithDisabled} />
) : null}
{checkoutHintPrAttachment ? (
<CheckoutHintBadge
prNumber={checkoutHintPrAttachment.item.number}
onAccept={acceptCheckoutHint}
onDismiss={dismissCheckoutHint}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
) : null}
</View>
),
[
acceptCheckoutHint,
badgePressableStyle,
checkoutHintPrAttachment,
dismissCheckoutHint,
handlePickerOpenChange,
handleSelectOption,
isPending,
openPicker,
options,
pickerEmptyText,
pickerOpen,
renderPickerOption,
selectedItem,
selectedOptionId,
setPickerSearchQuery,
agentControlsWithDisabled,
theme.colors.foregroundMuted,
theme.iconSize.sm,
triggerLabel,
],
);
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
@@ -909,47 +967,10 @@ export function NewWorkspaceScreen({
clearDraft={handleClearDraft}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={statusControlsWithDisabled}
agentControls={agentControlsWithDisabled}
onAddImages={handleAddImagesCallback}
footer={composerFooter}
/>
<Animated.View testID="new-workspace-ref-picker-row" style={optionsRowStyle}>
<View>
<RefPickerTrigger
pickerAnchorRef={pickerAnchorRef}
onPress={openPicker}
disabled={isPending}
badgePressableStyle={badgePressableStyle}
selectedItem={selectedItem}
triggerLabel={triggerLabel}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
<Combobox
options={options}
value={selectedOptionId}
onSelect={handleSelectOption}
searchable
searchPlaceholder="Search branches and PRs"
title="Start from"
open={pickerOpen}
onOpenChange={handlePickerOpenChange}
onSearchQueryChange={setPickerSearchQuery}
desktopPlacement="bottom-start"
anchorRef={pickerAnchorRef}
emptyText={pickerEmptyText}
renderOption={renderPickerOption}
/>
</View>
{checkoutHintPrAttachment ? (
<CheckoutHintBadge
prNumber={checkoutHintPrAttachment.item.number}
onAccept={acceptCheckoutHint}
onDismiss={dismissCheckoutHint}
iconColor={theme.colors.foregroundMuted}
iconSize={theme.iconSize.sm}
/>
) : null}
</Animated.View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</View>
@@ -1014,8 +1035,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
flexWrap: "wrap",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4] + theme.spacing[4] - 6,
marginTop: -theme.spacing[2],
},
badge: {
flexDirection: "row",
+2 -2
View File
@@ -1,5 +1,5 @@
export { SubagentsSection } from "./section";
export type { SubagentsSectionProps } from "./section";
export { SubagentsTrack } from "./track";
export type { SubagentsTrackProps } from "./track";
export type { SubagentRow } from "./select";
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
@@ -5,7 +5,7 @@ import React from "react";
import { act } from "@testing-library/react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SubagentsSection } from "./section";
import { SubagentsTrack } from "./track";
import type { SubagentRow } from "./select";
const { theme } = vi.hoisted(() => ({
@@ -98,8 +98,10 @@ vi.mock("@/components/synced-loader", () => ({
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
const createIcon = (name: string) =>
function IconStub(props: Record<string, unknown>) {
return React.createElement("span", { ...props, "data-icon": name });
};
return {
Archive: createIcon("Archive"),
Check: createIcon("Check"),
@@ -131,13 +133,13 @@ function queryByTestId(testID: string): HTMLElement | null {
function queryRowIds(): string[] {
return Array.from(
document.querySelectorAll<HTMLElement>('[data-testid^="subagents-section-row-"]'),
).map((node) => node.getAttribute("data-testid")?.replace("subagents-section-row-", "") ?? "");
document.querySelectorAll<HTMLElement>('[data-testid^="subagents-track-row-"]'),
).map((node) => node.getAttribute("data-testid")?.replace("subagents-track-row-", "") ?? "");
}
type SubagentAction = (id: string) => void;
describe("SubagentsSection", () => {
describe("SubagentsTrack", () => {
let container: HTMLElement | null = null;
let root: Root | null = null;
@@ -176,7 +178,7 @@ describe("SubagentsSection", () => {
): SubagentAction {
act(() => {
root?.render(
<SubagentsSection
<SubagentsTrack
rows={rows}
onOpenSubagent={onOpenSubagent}
onArchiveSubagent={onArchiveSubagent}
@@ -188,27 +190,27 @@ describe("SubagentsSection", () => {
it("renders nothing when rows is empty", () => {
render([]);
expect(queryByTestId("subagents-section")).toBeNull();
expect(queryByTestId("subagents-section-header")).toBeNull();
expect(queryByTestId("subagents-track")).toBeNull();
expect(queryByTestId("subagents-track-header")).toBeNull();
expect(queryRowIds()).toEqual([]);
});
it("shows only the collapsed header and no rows initially", () => {
render([row({ id: "child-a" }), row({ id: "child-b" })]);
expect(queryByTestId("subagents-section-header")).not.toBeNull();
expect(queryByTestId("subagents-track-header")).not.toBeNull();
expect(queryRowIds()).toEqual([]);
});
it("expands rows in the given order when the header is pressed", () => {
render([row({ id: "child-b" }), row({ id: "child-a" }), row({ id: "child-c" })]);
click(queryByTestId("subagents-section-header")!);
click(queryByTestId("subagents-track-header")!);
expect(queryRowIds()).toEqual(["child-b", "child-a", "child-c"]);
});
it("calls onOpenSubagent with the row id when a row is pressed", () => {
const onOpenSubagent = render([row({ id: "child-a" }), row({ id: "child-b" })]);
click(queryByTestId("subagents-section-header")!);
click(queryByTestId("subagents-section-row-child-b")!);
click(queryByTestId("subagents-track-header")!);
click(queryByTestId("subagents-track-row-child-b")!);
expect(onOpenSubagent).toHaveBeenCalledTimes(1);
expect(onOpenSubagent).toHaveBeenCalledWith("child-b");
});
@@ -216,7 +218,7 @@ describe("SubagentsSection", () => {
describe("header copy", () => {
it("renders '2 subagents' when two rows are not running", () => {
render([row({ id: "child-a" }), row({ id: "child-b" })]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe("2 subagents");
expect(queryByTestId("subagents-track-header")?.textContent).toBe("2 subagents");
});
it("renders '3 subagents · 1 running' with a single running row", () => {
@@ -225,14 +227,12 @@ describe("SubagentsSection", () => {
row({ id: "child-b" }),
row({ id: "child-c" }),
]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe(
"3 subagents · 1 running",
);
expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents · 1 running");
});
it("renders '1 subagent' for a finished row that still requires attention upstream", () => {
render([row({ id: "child-a", requiresAttention: true })]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe("1 subagent");
expect(queryByTestId("subagents-track-header")?.textContent).toBe("1 subagent");
});
it("renders '5 subagents · 2 running' when finished rows require attention upstream", () => {
@@ -243,9 +243,7 @@ describe("SubagentsSection", () => {
row({ id: "d" }),
row({ id: "e" }),
]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe(
"5 subagents · 2 running",
);
expect(queryByTestId("subagents-track-header")?.textContent).toBe("5 subagents · 2 running");
});
});
@@ -255,7 +253,7 @@ describe("SubagentsSection", () => {
row({ id: "b", status: "idle", requiresAttention: false }),
row({ id: "c", status: "idle", requiresAttention: true }),
]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe("3 subagents");
expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents");
});
it("still counts running rows even when they require attention", () => {
@@ -264,7 +262,7 @@ describe("SubagentsSection", () => {
row({ id: "b", status: "running", requiresAttention: true }),
row({ id: "c", status: "idle", requiresAttention: true }),
]);
expect(queryByTestId("subagents-section-header")?.textContent).toBe("3 subagents · 1 running");
expect(queryByTestId("subagents-track-header")?.textContent).toBe("3 subagents · 1 running");
});
it("renders each row through the shared workspace tab icon primitives", () => {
@@ -272,14 +270,14 @@ describe("SubagentsSection", () => {
row({ id: "idle-child", status: "idle", provider: "codex" }),
row({ id: "running-child", status: "running", provider: "claude-code" }),
]);
click(queryByTestId("subagents-section-header")!);
click(queryByTestId("subagents-track-header")!);
const idleRow = queryByTestId("subagents-section-row-idle-child");
const idleRow = queryByTestId("subagents-track-row-idle-child");
expect(idleRow).not.toBeNull();
expect(idleRow!.querySelectorAll('[data-testid="subagents-provider-icon"]').length).toBe(1);
expect(idleRow!.querySelectorAll('[data-testid="subagents-synced-loader"]').length).toBe(0);
const runningRow = queryByTestId("subagents-section-row-running-child");
const runningRow = queryByTestId("subagents-track-row-running-child");
expect(runningRow).not.toBeNull();
expect(runningRow!.querySelectorAll('[data-testid="subagents-synced-loader"]').length).toBe(1);
expect(runningRow!.querySelectorAll('[data-testid="subagents-provider-icon"]').length).toBe(0);
@@ -23,7 +23,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export interface SubagentsSectionProps {
export interface SubagentsTrackProps {
rows: SubagentRow[];
onOpenSubagent: (id: string) => void;
onArchiveSubagent: (id: string) => void;
@@ -76,11 +76,11 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
};
}
export function SubagentsSection({
export function SubagentsTrack({
rows,
onOpenSubagent,
onArchiveSubagent,
}: SubagentsSectionProps): ReactElement | null {
}: SubagentsTrackProps): ReactElement | null {
const [expanded, setExpanded] = useState(false);
const toggleExpanded = useCallback(() => {
@@ -108,13 +108,13 @@ export function SubagentsSection({
const headerLabel = formatHeaderLabel(rows);
return (
<View style={styles.outer} testID="subagents-section">
<View style={styles.outer} testID="subagents-track">
<View style={styles.track}>
<View style={surfaceStyle}>
<Pressable
accessibilityRole="button"
accessibilityLabel={headerLabel}
testID="subagents-section-header"
testID="subagents-track-header"
onPress={toggleExpanded}
style={headerStyle}
>
@@ -135,7 +135,7 @@ export function SubagentsSection({
nestedScrollEnabled
>
{rows.map((row) => (
<SubagentsSectionRow
<SubagentsTrackRow
key={row.id}
row={row}
onOpenSubagent={onOpenSubagent}
@@ -150,17 +150,17 @@ export function SubagentsSection({
);
}
interface SubagentsSectionRowProps {
interface SubagentsTrackRowProps {
row: SubagentRow;
onOpenSubagent: (id: string) => void;
onArchiveSubagent: (id: string) => void;
}
function SubagentsSectionRow({
function SubagentsTrackRow({
row,
onOpenSubagent,
onArchiveSubagent,
}: SubagentsSectionRowProps): ReactElement {
}: SubagentsTrackRowProps): ReactElement {
const isCompact = useIsCompactFormFactor();
const [hovered, setHovered] = useState(false);
const presentation = useMemo(() => buildRowPresentation(row), [row]);
@@ -184,7 +184,7 @@ function SubagentsSectionRow({
<Pressable
accessibilityRole="button"
accessibilityLabel={displayLabel}
testID={`subagents-section-row-${row.id}`}
testID={`subagents-track-row-${row.id}`}
onPress={handlePress}
>
{({ pressed }) => (
@@ -227,7 +227,7 @@ function SubagentArchiveButton({
<Pressable
accessibilityRole="button"
accessibilityLabel={`Archive ${displayLabel}`}
testID={`subagents-section-archive-${rowId}`}
testID={`subagents-track-archive-${rowId}`}
onPress={onPress}
style={styles.archiveButton}
hitSlop={8}
@@ -23,8 +23,8 @@ vi.mock("@/utils/confirm-dialog", () => ({
confirmDialog: confirmDialogMock,
}));
vi.mock("./section", () => ({
SubagentsSection: () => null,
vi.mock("./track", () => ({
SubagentsTrack: () => null,
}));
const SERVER_ID = "server-1";