diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 1cf9ba242..24177b5dc 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 4033dcd7f..e525c1193 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) diff --git a/docs/glossary.md b/docs/glossary.md index 31fc812c9..76c3ff09a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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 ` 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) diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index a4e7772ce..caa71c179 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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, diff --git a/packages/app/src/components/composer-height-mirror.d.ts b/packages/app/src/components/composer-height-mirror.d.ts deleted file mode 100644 index 5e1a1c639..000000000 --- a/packages/app/src/components/composer-height-mirror.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./composer-height-mirror.native"; diff --git a/packages/app/src/components/composer.status-controls.ts b/packages/app/src/components/composer.status-controls.ts deleted file mode 100644 index c791d558f..000000000 --- a/packages/app/src/components/composer.status-controls.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { DraftAgentStatusBarProps } from "./agent-status-bar"; - -export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) { - return statusControls ? "draft" : "ready"; -} diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx index 70ce8c92a..4025aeab0 100644 --- a/packages/app/src/components/file-drop-zone.tsx +++ b/packages/app/src/components/file-drop-zone.tsx @@ -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 { diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 5797881a8..7f23ed3ec 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -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 ? ( + + ) : undefined, + [isCompact, agentControlsWithDisabled], + ); + const subtitleContent = useMemo( () => ( @@ -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} /> diff --git a/packages/app/src/components/composer-actions.test.ts b/packages/app/src/composer/actions.test.ts similarity index 99% rename from packages/app/src/components/composer-actions.test.ts rename to packages/app/src/composer/actions.test.ts index 9c385cee2..84e677950 100644 --- a/packages/app/src/components/composer-actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -26,7 +26,7 @@ import { type ComposerSendClient, type QueueWriter, type QueuedComposerMessage, -} from "./composer-actions"; +} from "./actions"; const imageMetadata: AttachmentMetadata = { id: "img-1", diff --git a/packages/app/src/components/composer-actions.ts b/packages/app/src/composer/actions.ts similarity index 99% rename from packages/app/src/components/composer-actions.ts rename to packages/app/src/composer/actions.ts index 539717419..62924128c 100644 --- a/packages/app/src/components/composer-actions.ts +++ b/packages/app/src/composer/actions.ts @@ -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, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/composer/agent-controls/index.tsx similarity index 74% rename from packages/app/src/components/agent-status-bar.tsx rename to packages/app/src/composer/agent-controls/index.tsx index a4a4dde80..a5a060cb6 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type ReactElement, + type ReactNode, type RefObject, } from "react"; import { @@ -18,18 +19,7 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; -import { useStoreWithEqualityFn } from "zustand/traditional"; -import { - Brain, - ChevronDown, - ListTodo, - Settings2, - ShieldAlert, - ShieldCheck, - ShieldOff, - ShieldQuestionMark, - Zap, -} from "lucide-react-native"; +import { Brain, ChevronDown, ListTodo, Settings2, Zap } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; import { @@ -52,6 +42,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { DraftAgentModeControl, AgentModeControl } from "@/composer/agent-controls/mode-control"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { @@ -61,42 +52,38 @@ import type { AgentProvider, } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; -import { getModeVisuals, type AgentModeColorTier } from "@server/server/agent/provider-manifest"; import { getFeatureHighlightColor, getFeatureTooltip, - getStatusSelectorHint, + getAgentControlHint, + formatThinkingOptionLabel, resolveAgentModelSelection, -} from "@/components/agent-status-bar.utils"; +} from "@/composer/agent-controls/utils"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; -interface StatusOption { +interface AgentControlOption { id: string; label: string; } -type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; +type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; -interface ControlledAgentStatusBarProps { +interface ControlledAgentControlsProps { provider: string; - providerOptions?: StatusOption[]; + providerOptions?: AgentControlOption[]; selectedProviderId?: string; onSelectProvider?: (providerId: string) => void; - modeOptions?: StatusOption[]; - selectedModeId?: string; - onSelectMode?: (modeId: string) => void; - modelOptions?: StatusOption[]; + modelOptions?: AgentControlOption[]; selectedModelId?: string; onSelectModel?: (modelId: string) => void; onSelectProviderAndModel?: (provider: string, modelId: string) => void; - thinkingOptions?: StatusOption[]; + thinkingOptions?: AgentControlOption[]; selectedThinkingOptionId?: string; onSelectThinkingOption?: (thinkingOptionId: string) => void; disabled?: boolean; isModelLoading?: boolean; - providerDefinitions: AgentProviderDefinition[]; modelSelectorProviders?: ProviderSelectorProvider[]; favoriteKeys?: Set; onToggleFavoriteModel?: (provider: string, modelId: string) => void; @@ -104,9 +91,11 @@ interface ControlledAgentStatusBarProps { onSetFeature?: (featureId: string, value: unknown) => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; + /** Extra elements rendered inline with the agent controls (desktop only). */ + desktopExtras?: ReactNode; } -export interface DraftAgentStatusBarProps { +export interface DraftAgentControlsProps { providerDefinitions: AgentProviderDefinition[]; selectedProvider: AgentProvider | null; onSelectProvider: (provider: AgentProvider) => void; @@ -130,14 +119,14 @@ export interface DraftAgentStatusBarProps { disabled?: boolean; } -interface AgentStatusBarProps { +interface AgentControlsProps { agentId: string; serverId: string; onDropdownClose?: () => void; } function findOptionLabel( - options: StatusOption[] | undefined, + options: AgentControlOption[] | undefined, selectedId: string | undefined, fallback: string, ) { @@ -180,16 +169,9 @@ function getFeatureIconColor( } } -const MODE_ICONS = { - ShieldCheck, - ShieldAlert, - ShieldOff, - ShieldQuestionMark, -} as const; - function resolveDisplayModel( isModelLoading: boolean, - modelOptions: StatusOption[] | undefined, + modelOptions: AgentControlOption[] | undefined, selectedModelId: string | undefined, ) { if (isModelLoading && (!modelOptions || modelOptions.length === 0)) { @@ -198,7 +180,7 @@ function resolveDisplayModel( return findOptionLabel(modelOptions, selectedModelId, "Select model"); } -// Mobile status bar only — strip namespace prefix so providers like OpenCode +// Mobile agent controls only — strip namespace prefix so providers like OpenCode // show "gpt-5.5" instead of "openrouter/gpt-5.5". Full label still appears in // the model picker. function shortModelLabel(label: string): string { @@ -206,37 +188,41 @@ function shortModelLabel(label: string): string { return i === -1 ? label : label.slice(i + 1); } -type ActiveSheet = "thinking" | "mode" | "features" | null; +type ActiveSheet = "thinking" | "features" | null; function resolveHasAnyControl({ providerOptions, - modeOptions, canSelectModel, thinkingOptions, features, }: { - providerOptions: StatusOption[] | undefined; - modeOptions: StatusOption[] | undefined; + providerOptions: AgentControlOption[] | undefined; canSelectModel: boolean; - thinkingOptions: StatusOption[] | undefined; + thinkingOptions: AgentControlOption[] | undefined; features: AgentFeature[] | undefined; }) { return ( Boolean(providerOptions?.length) || - Boolean(modeOptions?.length) || canSelectModel || Boolean(thinkingOptions?.length) || Boolean(features?.length) ); } -function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[] { +function toComboboxOptions(options: AgentControlOption[] | undefined): ComboboxOption[] { return (options ?? []).map((o) => ({ id: o.id, label: o.label })); } +function toThinkingControlOptions(options: AgentControlOption[] | undefined): AgentControlOption[] { + return (options ?? []).map((option) => ({ + id: option.id, + label: formatThinkingOptionLabel(option), + })); +} + function buildFallbackModelSelectorProviders( provider: string, - modelOptions: StatusOption[] | undefined, + modelOptions: AgentControlOption[] | undefined, ): ProviderSelectorProvider[] { if (!modelOptions || modelOptions.length === 0) { return []; @@ -314,20 +300,6 @@ function pickDesktopModel({ } } -function resolveModeVisualsForProvider( - provider: string, - selectedModeId: string | undefined, - providerDefinitions: AgentProviderDefinition[], - palette: Parameters[1], -) { - const modeVisuals = selectedModeId - ? getModeVisuals(provider, selectedModeId, providerDefinitions) - : undefined; - const icon = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; - const color = getModeIconColor(modeVisuals?.colorTier, palette); - return { icon, color }; -} - function resolveProviderIcon(provider: string) { if (provider.trim().length === 0) { return null; @@ -335,10 +307,9 @@ function resolveProviderIcon(provider: string) { return getProviderIcon(provider); } -type AgentStatusBarSlice = { +type AgentControlsSlice = { provider: string; cwd: string | null; - currentModeId: string | null | undefined; runtimeModelId: string | null; model: string | null | undefined; features: AgentFeature[] | undefined; @@ -346,11 +317,11 @@ type AgentStatusBarSlice = { lastUsage: unknown; } | null; -function selectAgentStatusBarSlice( +function selectAgentControlsSlice( state: ReturnType, serverId: string, agentId: string, -): AgentStatusBarSlice { +): AgentControlsSlice { const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null; if (!currentAgent) { return null; @@ -358,7 +329,6 @@ function selectAgentStatusBarSlice( return { provider: currentAgent.provider, cwd: currentAgent.cwd, - currentModeId: currentAgent.currentModeId, runtimeModelId: currentAgent.runtimeInfo?.model ?? null, model: currentAgent.model, features: currentAgent.features, @@ -398,21 +368,9 @@ function buildAgentProviderModels( return map; } -function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean { - return a === b || JSON.stringify(a) === JSON.stringify(b); -} - -function resolveAgentDisplayMode( - availableModes: AgentMode[], - currentModeId: string | null | undefined, -): string { - const found = availableModes.find((mode) => mode.id === currentModeId); - return found?.label || currentModeId || "default"; -} - function buildOpenChangeHandler( - selector: StatusSelector, - setOpenSelector: (next: StatusSelector | null) => void, + selector: AgentControlSelector, + setOpenSelector: (next: AgentControlSelector | null) => void, onDropdownClose?: () => void, ) { return (nextOpen: boolean) => { @@ -423,37 +381,11 @@ function buildOpenChangeHandler( }; } -function getModeIconColor( - colorTier: AgentModeColorTier | undefined, - palette: { - blue: { 500: string }; - green: { 500: string }; - red: { 500: string }; - purple: { 500: string }; - }, -): string { - 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]; - } -} - -function ControlledStatusBar({ +function ControlledAgentControls({ provider, providerOptions, selectedProviderId, onSelectProvider, - modeOptions, - selectedModeId, - onSelectMode, modelOptions, selectedModelId, onSelectModel, @@ -463,7 +395,6 @@ function ControlledStatusBar({ onSelectThinkingOption, disabled = false, isModelLoading = false, - providerDefinitions, modelSelectorProviders, favoriteKeys = new Set(), onToggleFavoriteModel, @@ -471,21 +402,20 @@ function ControlledStatusBar({ onSetFeature, onDropdownClose, onModelSelectorOpen, -}: ControlledAgentStatusBarProps) { + desktopExtras, +}: ControlledAgentControlsProps) { const { theme } = useUnistyles(); const isCompact = useIsCompactFormFactor(); const [activeSheet, setActiveSheet] = useState(null); - const [openSelector, setOpenSelector] = useState(null); + const [openSelector, setOpenSelector] = useState(null); const providerAnchorRef = useRef(null); - const modeAnchorRef = useRef(null); const _modelAnchorRef = useRef(null); const thinkingAnchorRef = useRef(null); const canSelectProvider = Boolean( onSelectProvider && providerOptions && providerOptions.length > 0, ); - const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0); const canSelectModel = Boolean(onSelectModel); const canSelectThinking = Boolean( onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0, @@ -493,23 +423,20 @@ function ControlledStatusBar({ const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider"); const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId); + const formattedThinkingOptions = useMemo( + () => toThinkingControlOptions(thinkingOptions), + [thinkingOptions], + ); const displayThinking = findOptionLabel( - thinkingOptions, + formattedThinkingOptions, selectedThinkingOptionId, - thinkingOptions?.[0]?.label ?? "Unknown", + formattedThinkingOptions[0]?.label ?? "Unknown", ); - const { icon: ModeIconComponent, color: modeIconColor } = resolveModeVisualsForProvider( - provider, - selectedModeId, - providerDefinitions, - theme.colors.palette, - ); const ProviderIcon = resolveProviderIcon(provider); const hasAnyControl = resolveHasAnyControl({ providerOptions, - modeOptions, canSelectModel, thinkingOptions, features, @@ -521,34 +448,16 @@ function ControlledStatusBar({ () => toComboboxOptions(providerOptions), [providerOptions], ); - const comboboxModeOptions = useMemo( - () => toComboboxOptions(modeOptions), - [modeOptions], - ); const fallbackModelSelectorProviders = useMemo( () => buildFallbackModelSelectorProviders(provider, modelOptions), [modelOptions, provider], ); const effectiveModelSelectorProviders = modelSelectorProviders ?? fallbackModelSelectorProviders; const comboboxThinkingOptions = useMemo( - () => toComboboxOptions(thinkingOptions), - [thinkingOptions], + () => toComboboxOptions(formattedThinkingOptions), + [formattedThinkingOptions], ); - const renderModeOption = useCallback( - (args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => ( - - ), - [provider, providerDefinitions, theme.colors.foreground], - ); const renderThinkingOption = useCallback( (args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => ( + (selector: AgentControlSelector) => buildOpenChangeHandler(selector, setOpenSelector, onDropdownClose), [onDropdownClose], ); @@ -576,13 +485,8 @@ function ControlledStatusBar({ handleOpenChange("thinking")(openSelector !== "thinking"); }, [handleOpenChange, openSelector]); - const handleModePress = useCallback(() => { - handleOpenChange("mode")(openSelector !== "mode"); - }, [handleOpenChange, openSelector]); - const handleProviderOpenChange = useMemo(() => handleOpenChange("provider"), [handleOpenChange]); const handleThinkingOpenChange = useMemo(() => handleOpenChange("thinking"), [handleOpenChange]); - const handleModeOpenChange = useMemo(() => handleOpenChange("mode"), [handleOpenChange]); const handleProviderSelect = useCallback( (id: string) => onSelectProvider?.(id), @@ -592,7 +496,6 @@ function ControlledStatusBar({ (id: string) => onSelectThinkingOption?.(id), [onSelectThinkingOption], ); - const handleModeSelect = useCallback((id: string) => onSelectMode?.(id), [onSelectMode]); const handleDesktopModelSelect = useCallback( (nextProviderId: string, modelId: string) => { @@ -623,17 +526,6 @@ function ControlledStatusBar({ [canSelectThinking, disabled, openSelector], ); - const modePressableStyle = useMemo( - () => - makeBadgePressableStyle( - styles.modeIconBadge, - styles.disabledBadge, - disabled || !canSelectMode, - openSelector === "mode", - ), - [canSelectMode, disabled, openSelector], - ); - const handleOpenSheet = useCallback((sheet: Exclude) => { Keyboard.dismiss(); setActiveSheet(sheet); @@ -651,14 +543,6 @@ function ControlledStatusBar({ [onSelectThinkingOption], ); - const handleSelectModeAndClose = useCallback( - (modeId: string) => { - onSelectMode?.(modeId); - setActiveSheet(null); - }, - [onSelectMode], - ); - const handleSheetModelSelect = useCallback( (nextProviderId: string, modelId: string) => { pickSheetModel({ @@ -680,64 +564,51 @@ function ControlledStatusBar({ return ( {!isCompact ? ( - ) : ( - )} @@ -774,81 +639,62 @@ function ControlledStatusBar({ ); } -interface DesktopStatusBarContentProps { +interface DesktopAgentControlsContentProps { provider: string; - providerOptions?: StatusOption[]; + providerOptions?: AgentControlOption[]; selectedProviderId?: string; - modeOptions?: StatusOption[]; - selectedModeId?: string; - modelOptions?: StatusOption[]; + modelOptions?: AgentControlOption[]; selectedModelId?: string; - thinkingOptions?: StatusOption[]; + thinkingOptions?: AgentControlOption[]; selectedThinkingOptionId?: string; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; onToggleFavoriteModel?: (provider: string, modelId: string) => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; - providerDefinitions: AgentProviderDefinition[]; favoriteKeys: Set; disabled: boolean; isModelLoading: boolean; canSelectProvider: boolean; - canSelectMode: boolean; canSelectModel: boolean; canSelectThinking: boolean; modelSelectorProviders: ProviderSelectorProvider[]; modelDisabled: boolean; comboboxProviderOptions: ComboboxOption[]; - comboboxModeOptions: ComboboxOption[]; comboboxThinkingOptions: ComboboxOption[]; displayProvider: string; displayModel: string; displayThinking: string; - ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null; - modeIconColor: string; - openSelector: StatusSelector | null; + openSelector: AgentControlSelector | null; providerAnchorRef: RefObject; thinkingAnchorRef: RefObject; - modeAnchorRef: RefObject; providerPressableStyle: (state: PressableStateCallbackType) => StyleProp; thinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp; - modePressableStyle: (state: PressableStateCallbackType) => StyleProp; handleProviderPress: () => void; handleThinkingPress: () => void; - handleModePress: () => void; handleProviderSelect: (id: string) => void; handleThinkingSelect: (id: string) => void; - handleModeSelect: (id: string) => void; handleDesktopModelSelect: (providerId: string, modelId: string) => void; handleProviderOpenChange: (open: boolean) => void; handleThinkingOpenChange: (open: boolean) => void; - handleModeOpenChange: (open: boolean) => void; - handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void; - renderModeOption: (args: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ReactElement; + handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; renderThinkingOption: (args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void; }) => ReactElement; + extras?: ReactNode; } const DESKTOP_SEARCH_THRESHOLD = 6; -function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { +function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { const { theme } = useUnistyles(); const { provider, providerOptions, selectedProviderId, - modeOptions, - selectedModeId, selectedModelId, thinkingOptions, selectedThinkingOptionId, @@ -861,39 +707,30 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { disabled, isModelLoading, canSelectProvider, - canSelectMode, canSelectModel, canSelectThinking, modelSelectorProviders, modelDisabled, comboboxProviderOptions, - comboboxModeOptions, comboboxThinkingOptions, displayProvider, displayModel, displayThinking, - ModeIconComponent, - modeIconColor, openSelector, providerAnchorRef, thinkingAnchorRef, - modeAnchorRef, providerPressableStyle, thinkingPressableStyle, - modePressableStyle, handleProviderPress, handleThinkingPress, - handleModePress, handleProviderSelect, handleThinkingSelect, - handleModeSelect, handleDesktopModelSelect, handleProviderOpenChange, handleThinkingOpenChange, - handleModeOpenChange, handleOpenChange, - renderModeOption, renderThinkingOption, + extras, } = props; return ( @@ -950,7 +787,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { - {getStatusSelectorHint("model")} + {getAgentControlHint("model")} ) : null} @@ -975,7 +812,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { - {getStatusSelectorHint("thinking")} + {getAgentControlHint("thinking")} ) : null} - {modeOptions && modeOptions.length > 0 ? ( - <> - - - - {ModeIconComponent ? ( - - ) : ( - - )} - - - - {getStatusSelectorHint("mode")} - - - DESKTOP_SEARCH_THRESHOLD} - open={openSelector === "mode"} - onOpenChange={handleModeOpenChange} - anchorRef={modeAnchorRef} - desktopPlacement="top-start" - renderOption={renderModeOption} - /> - - ) : null} + {extras} {features?.map((feature) => ( ; disabled: boolean; isModelLoading: boolean; - canSelectMode: boolean; canSelectModel: boolean; canSelectThinking: boolean; modelSelectorProviders: ProviderSelectorProvider[]; modelDisabled: boolean; - comboboxModeOptions: ComboboxOption[]; comboboxThinkingOptions: ComboboxOption[]; - ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null; - modeIconColor: string; - openSelector: StatusSelector | null; + openSelector: AgentControlSelector | null; ProviderIcon: ReturnType | null; activeSheet: ActiveSheet; handleOpenSheet: (sheet: Exclude) => void; handleCloseSheet: () => void; handleSheetModelSelect: (providerId: string, modelId: string) => void; handleSelectThinkingAndClose: (thinkingOptionId: string) => void; - handleSelectModeAndClose: (modeId: string) => void; - handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void; - renderModeOption: (args: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ReactElement; + handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; renderThinkingOption: (args: { option: ComboboxOption; selected: boolean; @@ -1090,11 +878,10 @@ interface SheetStatusBarContentProps { }) => ReactElement; } -function SheetStatusBarContent(props: SheetStatusBarContentProps) { +function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { const { theme } = useUnistyles(); const { provider, - selectedModeId, selectedModelId, selectedThinkingOptionId, features, @@ -1105,15 +892,11 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { favoriteKeys, disabled, isModelLoading, - canSelectMode, canSelectModel, canSelectThinking, modelSelectorProviders, modelDisabled, - comboboxModeOptions, comboboxThinkingOptions, - ModeIconComponent, - modeIconColor, openSelector, ProviderIcon, activeSheet, @@ -1121,21 +904,16 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { handleCloseSheet, handleSheetModelSelect, handleSelectThinkingAndClose, - handleSelectModeAndClose, handleOpenChange, - renderModeOption, renderThinkingOption, } = props; const thinkingAnchorRef = useRef(null); - const modeAnchorRef = useRef(null); const hasThinking = comboboxThinkingOptions.length > 0; - const hasMode = Boolean(canSelectMode && comboboxModeOptions.length > 0); const hasFeatures = Boolean(features && features.length > 0); const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]); - const handleOpenMode = useCallback(() => handleOpenSheet("mode"), [handleOpenSheet]); const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); const handleThinkingSheetOpenChange = useCallback( (nextOpen: boolean) => { @@ -1147,16 +925,6 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { }, [handleCloseSheet, handleOpenSheet], ); - const handleModeSheetOpenChange = useCallback( - (nextOpen: boolean) => { - if (nextOpen) { - handleOpenSheet("mode"); - } else { - handleCloseSheet(); - } - }, - [handleCloseSheet, handleOpenSheet], - ); const renderModelTrigger = useCallback( ({ @@ -1167,7 +935,7 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { disabled: boolean; isOpen: boolean; }) => ( - + {ProviderIcon ? ( ) : null} @@ -1185,12 +953,6 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { disabled || !canSelectThinking, activeSheet === "thinking", ); - const modeButtonStyle = makeBadgePressableStyle( - styles.modeIconBadge, - styles.disabledBadge, - disabled || !canSelectMode, - activeSheet === "mode", - ); const featuresButtonStyle = makeBadgePressableStyle( styles.modeIconBadge, styles.disabledBadge, @@ -1224,30 +986,12 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { style={thinkingButtonStyle} accessibilityRole="button" accessibilityLabel="Select thinking option" - testID="agent-status-bar-thinking" + testID="agent-controls-thinking" > ) : null} - {hasMode ? ( - - {ModeIconComponent ? ( - - ) : ( - - )} - - ) : null} - {hasFeatures ? ( @@ -1275,20 +1019,6 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { /> ) : null} - {hasMode ? ( - - ) : null} - (nextOpen: boolean) => void; + openSelector: AgentControlSelector | null; + handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; onSetFeature?: (featureId: string, value: unknown) => void; }) { const { theme } = useUnistyles(); - const featureSelector: StatusSelector = `feature-${feature.id}`; + const featureSelector: AgentControlSelector = `feature-${feature.id}`; const handleFeatureOpenChange = useMemo( () => handleOpenChange(featureSelector), @@ -1444,12 +1174,12 @@ function SheetFeatureItem({ }: { feature: AgentFeature; disabled: boolean; - openSelector: StatusSelector | null; - handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void; + openSelector: AgentControlSelector | null; + handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; onSetFeature?: (featureId: string, value: unknown) => void; }) { const { theme } = useUnistyles(); - const featureSelector: StatusSelector = `feature-${feature.id}`; + const featureSelector: AgentControlSelector = `feature-${feature.id}`; const handleFeatureOpenChange = useMemo( () => handleOpenChange(featureSelector), @@ -1587,56 +1317,16 @@ function ThinkingComboboxOption({ ); } -function ModeComboboxOption({ - option, - selected, - active, - onPress, - provider, - providerDefinitions, - iconColor, -}: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - provider: string; - providerDefinitions: AgentProviderDefinition[]; - iconColor: string; -}) { - const visuals = getModeVisuals(provider, option.id, providerDefinitions); - const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; - const leadingSlot = useMemo( - () => , - [IconComponent, iconColor], - ); - return ( - - ); -} - -const EMPTY_MODES: AgentMode[] = []; const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" }; -export const AgentStatusBar = memo(function AgentStatusBar({ +export const AgentControls = memo(function AgentControls({ agentId, serverId, onDropdownClose, -}: AgentStatusBarProps) { +}: AgentControlsProps) { const { preferences, updatePreferences } = useFormPreferences(); const agent = useSessionStore( - useShallow((state) => selectAgentStatusBarSlice(state, serverId, agentId)), - ); - const availableModes = useStoreWithEqualityFn( - useSessionStore, - (state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES, - compareAvailableModes, + useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)), ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const toast = useToast(); @@ -1673,8 +1363,6 @@ export const AgentStatusBar = memo(function AgentStatusBar({ [agentProviderDefinitions, agentProviderModels], ); - const displayMode = resolveAgentDisplayMode(availableModes, agent?.currentModeId); - const modelSelection = resolveAgentModelSelection({ models, runtimeModelId: agent?.runtimeModelId, @@ -1682,14 +1370,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({ explicitThinkingOptionId: agent?.thinkingOptionId, }); - const modeOptions = useMemo(() => { - return availableModes.map((mode) => ({ - id: mode.id, - label: mode.label, - })); - }, [availableModes]); - - const modelOptions = useMemo(() => { + const modelOptions = useMemo(() => { return (models ?? []).map((model) => ({ id: model.id, label: model.label })); }, [models]); const favoriteKeys = useMemo( @@ -1700,29 +1381,16 @@ export const AgentStatusBar = memo(function AgentStatusBar({ [preferences.favoriteModels], ); - const thinkingOptions = useMemo(() => { + const thinkingOptions = useMemo(() => { return (modelSelection.thinkingOptions ?? []).map((option) => ({ id: option.id, - label: option.label, + label: formatThinkingOptionLabel(option), })); }, [modelSelection.thinkingOptions]); const agentProvider = agent?.provider; const activeModelId = modelSelection.activeModelId; - const handleSelectMode = useCallback( - (modeId: string) => { - if (!client) { - return; - } - void client.setAgentMode(agentId, modeId).catch((error) => { - console.warn("[AgentStatusBar] setAgentMode failed", error); - toast.error(toErrorMessage(error)); - }); - }, - [agentId, client, toast], - ); - const handleSelectModel = useCallback( (modelId: string) => { if (!client || !agentProvider) { @@ -1737,10 +1405,10 @@ export const AgentStatusBar = memo(function AgentStatusBar({ }, }), ).catch((error) => { - console.warn("[AgentStatusBar] persist model preference failed", error); + console.warn("[AgentControls] persist model preference failed", error); }); void client.setAgentModel(agentId, modelId).catch((error) => { - console.warn("[AgentStatusBar] setAgentModel failed", error); + console.warn("[AgentControls] setAgentModel failed", error); toast.error(toErrorMessage(error)); }); }, @@ -1752,7 +1420,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({ void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId }), ).catch((error) => { - console.warn("[AgentStatusBar] toggle favorite model failed", error); + console.warn("[AgentControls] toggle favorite model failed", error); }); }, [updatePreferences], @@ -1776,11 +1444,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({ }, }), ).catch((error) => { - console.warn("[AgentStatusBar] persist thinking preference failed", error); + console.warn("[AgentControls] persist thinking preference failed", error); }); } void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { - console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); + console.warn("[AgentControls] setAgentThinkingOption failed", error); toast.error(toErrorMessage(error)); }); }, @@ -1803,10 +1471,10 @@ export const AgentStatusBar = memo(function AgentStatusBar({ }, }), ).catch((error) => { - console.warn("[AgentStatusBar] persist feature preference failed", error); + console.warn("[AgentControls] persist feature preference failed", error); }); void client.setAgentFeature(agentId, featureId, value).catch((error) => { - console.warn("[AgentStatusBar] setAgentFeature failed", error); + console.warn("[AgentControls] setAgentFeature failed", error); toast.error(toErrorMessage(error)); }); }, @@ -1817,12 +1485,9 @@ export const AgentStatusBar = memo(function AgentStatusBar({ refetchSnapshotIfStale(agentProvider); }, [agentProvider, refetchSnapshotIfStale]); - const fallbackModeOptions = useMemo( - () => - modeOptions.length > 0 - ? modeOptions - : [{ id: agent?.currentModeId ?? "", label: displayMode }], - [agent?.currentModeId, displayMode, modeOptions], + const modeChip = useMemo( + () => , + [serverId, agentId], ); if (!agent) { @@ -1830,13 +1495,9 @@ export const AgentStatusBar = memo(function AgentStatusBar({ } return ( - ); }); -export function DraftAgentStatusBar({ +export function DraftAgentControls({ providerDefinitions, selectedProvider, onSelectProvider: _onSelectProvider, @@ -1877,22 +1539,12 @@ export function DraftAgentStatusBar({ onDropdownClose, onModelSelectorOpen, disabled = false, -}: DraftAgentStatusBarProps) { +}: DraftAgentControlsProps) { const { preferences, updatePreferences } = useFormPreferences(); const isCompact = useIsCompactFormFactor(); - const mappedModeOptions = useMemo(() => { - if (modeOptions.length === 0) { - return [{ id: "", label: "Default" }]; - } - return modeOptions.map((mode) => ({ - id: mode.id, - label: mode.label, - })); - }, [modeOptions]); - - const mappedThinkingOptions = useMemo(() => { - return thinkingOptions.map((option) => ({ id: option.id, label: option.label })); + const mappedThinkingOptions = useMemo(() => { + return toThinkingControlOptions(thinkingOptions); }, [thinkingOptions]); const favoriteKeys = useMemo( () => @@ -1902,12 +1554,10 @@ export function DraftAgentStatusBar({ [preferences.favoriteModels], ); - const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ""; const effectiveSelectedThinkingOption = selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; - const hasSelectedProvider = selectedProvider !== null; - const modelOptions = useMemo( + const modelOptions = useMemo( () => models.map((model) => ({ id: model.id, @@ -1921,12 +1571,27 @@ export function DraftAgentStatusBar({ void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId }), ).catch((error) => { - console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); + console.warn("[DraftAgentControls] toggle favorite model failed", error); }); }, [updatePreferences], ); + const draftModeChip = useMemo( + () => ( + + ), + [selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled], + ); + if (!isCompact) { return ( @@ -1943,12 +1608,8 @@ export function DraftAgentStatusBar({ onClose={onDropdownClose} /> {selectedProvider ? ( - 0 ? mappedThinkingOptions : undefined} selectedThinkingOptionId={effectiveSelectedThinkingOption} onSelectThinkingOption={onSelectThinkingOption} @@ -1956,6 +1617,7 @@ export function DraftAgentStatusBar({ onSetFeature={onSetFeature} onDropdownClose={onDropdownClose} disabled={disabled} + desktopExtras={draftModeChip} /> ) : null} @@ -1963,13 +1625,9 @@ export function DraftAgentStatusBar({ } return ( - 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, iconColor], + ); + return ( + + ); +} + +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(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( + () => modeOptions.map((m) => ({ id: m.id, label: formatAgentModeLabel(m) })), + [modeOptions], + ); + const options = useMemo(() => { + 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 => ( + + ), + [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( + () => ({ + title: "Mode", + search: { + value: searchQuery, + onChange: setSearchQuery, + placeholder: "Search modes...", + testID: "mode-search-input", + }, + }), + [searchQuery], + ); + + if (!selectedMode) return null; + + return ( + <> + + + {selectedModeLabel} + + + + + ); +} + +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(() => { + 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 ( + + ); +}); + +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 ( + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/composer.status-controls.test.ts b/packages/app/src/composer/agent-controls/mode.test.ts similarity index 64% rename from packages/app/src/components/composer.status-controls.test.ts rename to packages/app/src/composer/agent-controls/mode.test.ts index d15de6c7b..498753ad5 100644 --- a/packages/app/src/components/composer.status-controls.test.ts +++ b/packages/app/src/composer/agent-controls/mode.test.ts @@ -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, diff --git a/packages/app/src/composer/agent-controls/mode.ts b/packages/app/src/composer/agent-controls/mode.ts new file mode 100644 index 000000000..3cc81e5b7 --- /dev/null +++ b/packages/app/src/composer/agent-controls/mode.ts @@ -0,0 +1,5 @@ +import type { DraftAgentControlsProps } from "@/composer/agent-controls"; + +export function resolveAgentControlsMode(agentControls?: DraftAgentControlsProps) { + return agentControls ? "draft" : "ready"; +} diff --git a/packages/app/src/components/agent-status-bar.model-loading.test.ts b/packages/app/src/composer/agent-controls/model-loading.test.ts similarity index 91% rename from packages/app/src/components/agent-status-bar.model-loading.test.ts rename to packages/app/src/composer/agent-controls/model-loading.test.ts index d75714a85..c034a7cec 100644 --- a/packages/app/src/components/agent-status-bar.model-loading.test.ts +++ b/packages/app/src/composer/agent-controls/model-loading.test.ts @@ -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", () => { diff --git a/packages/app/src/components/agent-status-bar.model-loading.ts b/packages/app/src/composer/agent-controls/model-loading.ts similarity index 100% rename from packages/app/src/components/agent-status-bar.model-loading.ts rename to packages/app/src/composer/agent-controls/model-loading.ts diff --git a/packages/app/src/components/agent-status-bar.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts similarity index 59% rename from packages/app/src/components/agent-status-bar.test.ts rename to packages/app/src/composer/agent-controls/utils.test.ts index 5e4ffe505..63b25dcaa 100644 --- a/packages/app/src/components/agent-status-bar.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -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: [ diff --git a/packages/app/src/components/agent-status-bar.utils.ts b/packages/app/src/composer/agent-controls/utils.ts similarity index 71% rename from packages/app/src/components/agent-status-bar.utils.ts rename to packages/app/src/composer/agent-controls/utils.ts index 0cb7dd904..f81293567 100644 --- a/packages/app/src/components/agent-status-bar.utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -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, diff --git a/packages/app/src/components/composer-attachments.ts b/packages/app/src/composer/attachments/submit.ts similarity index 89% rename from packages/app/src/components/composer-attachments.ts rename to packages/app/src/composer/attachments/submit.ts index e3279d25d..2ae391589 100644 --- a/packages/app/src/components/composer-attachments.ts +++ b/packages/app/src/composer/attachments/submit.ts @@ -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[]; diff --git a/packages/app/src/attachments/composer-workspace-attachments.tsx b/packages/app/src/composer/attachments/workspace.tsx similarity index 100% rename from packages/app/src/attachments/composer-workspace-attachments.tsx rename to packages/app/src/composer/attachments/workspace.tsx diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts similarity index 98% rename from packages/app/src/hooks/use-draft-agent-create-flow.test.ts rename to packages/app/src/composer/draft/create-flow.test.ts index ec79331c6..b8e662b5f 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -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(() => { diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/composer/draft/create-flow.ts similarity index 99% rename from packages/app/src/hooks/use-draft-agent-create-flow.ts rename to packages/app/src/composer/draft/create-flow.ts index e59dd13a3..9049912db 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -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 { diff --git a/packages/app/src/screens/workspace/composer-import-pill.tsx b/packages/app/src/composer/draft/import-pill.tsx similarity index 100% rename from packages/app/src/screens/workspace/composer-import-pill.tsx rename to packages/app/src/composer/draft/import-pill.tsx diff --git a/packages/app/src/hooks/use-agent-input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts similarity index 86% rename from packages/app/src/hooks/use-agent-input-draft-core.ts rename to packages/app/src/composer/draft/input-draft-core.ts index 45d2b37fb..ac3b1087f 100644 --- a/packages/app/src/hooks/use-agent-input-draft-core.ts +++ b/packages/app/src/composer/draft/input-draft-core.ts @@ -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, diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/composer/draft/input-draft.live.test.tsx similarity index 98% rename from packages/app/src/hooks/use-agent-input-draft.live.test.tsx rename to packages/app/src/composer/draft/input-draft.live.test.tsx index 90c30756f..f04d469e2 100644 --- a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx +++ b/packages/app/src/composer/draft/input-draft.live.test.tsx @@ -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["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", diff --git a/packages/app/src/hooks/use-agent-input-draft.test.ts b/packages/app/src/composer/draft/input-draft.test.ts similarity index 98% rename from packages/app/src/hooks/use-agent-input-draft.test.ts rename to packages/app/src/composer/draft/input-draft.test.ts index f34c5ff2a..31af4f468 100644 --- a/packages/app/src/hooks/use-agent-input-draft.test.ts +++ b/packages/app/src/composer/draft/input-draft.test.ts @@ -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, diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/composer/draft/input-draft.ts similarity index 96% rename from packages/app/src/hooks/use-agent-input-draft.ts rename to packages/app/src/composer/draft/input-draft.ts index 796c519dd..2309ad445 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -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 | 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, }; diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts similarity index 100% rename from packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts rename to packages/app/src/composer/draft/workspace-tab-core.ts diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts b/packages/app/src/composer/draft/workspace-tab.test.ts similarity index 94% rename from packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts rename to packages/app/src/composer/draft/workspace-tab.test.ts index 0d7d5dff8..9332536eb 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.test.ts +++ b/packages/app/src/composer/draft/workspace-tab.test.ts @@ -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" }], diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx similarity index 95% rename from packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx rename to packages/app/src/composer/draft/workspace-tab.tsx index 9754320ba..974e802c6 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -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 ? ( + + ) : undefined, + [isCompact, composerAgentControls], + ); return ( @@ -678,7 +683,8 @@ export function WorkspaceDraftAgentTab({ onAddImages={handleAddImagesCallback} onFocusInput={handleFocusInputCallback} commandDraftConfig={composerState.commandDraftConfig} - statusControls={composerStatusControls} + agentControls={composerAgentControls} + footer={composerFooter} /> diff --git a/packages/app/src/components/use-composer-github-auto-attach.test.tsx b/packages/app/src/composer/github/auto-attach.test.tsx similarity index 98% rename from packages/app/src/components/use-composer-github-auto-attach.test.tsx rename to packages/app/src/composer/github/auto-attach.test.tsx index 773dfe273..f904d23d8 100644 --- a/packages/app/src/components/use-composer-github-auto-attach.test.tsx +++ b/packages/app/src/composer/github/auto-attach.test.tsx @@ -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"]; diff --git a/packages/app/src/components/use-composer-github-auto-attach.ts b/packages/app/src/composer/github/auto-attach.ts similarity index 99% rename from packages/app/src/components/use-composer-github-auto-attach.ts rename to packages/app/src/composer/github/auto-attach.ts index 77f5b18bc..8ca9be4c4 100644 --- a/packages/app/src/components/use-composer-github-auto-attach.ts +++ b/packages/app/src/composer/github/auto-attach.ts @@ -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; diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/composer/index.tsx similarity index 95% rename from packages/app/src/components/composer.tsx rename to packages/app/src/composer/index.tsx index 946b88328..df180e40d 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/composer/index.tsx @@ -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 ; + const { agentControls, agentId, serverId, focusInput } = args; + if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) { + return ; } - return ; + return ; } -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 ( + + {footer} + + ); +} + +function renderAttachmentTray(args: RenderAttachmentTrayArgs): ReactElement | null { const { selectedAttachments, isComposerLocked, handleOpenAttachment, handleRemoveAttachment } = args; if (selectedAttachments.length === 0) return null; return ( - + {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; } -function renderQueueList(args: RenderQueueListArgs): ReactElement | null { +function renderQueueTrack(args: RenderQueueTrackArgs): ReactElement | null { const { queuedMessages, handleEditQueuedMessage, handleSendQueuedNow } = args; if (queuedMessages.length === 0) return null; return ( - + {queuedMessages.map((item) => ( 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} /> + {renderComposerFooter(footer)} ); } @@ -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], }, diff --git a/packages/app/src/composer/input/height-mirror.d.ts b/packages/app/src/composer/input/height-mirror.d.ts new file mode 100644 index 000000000..17ba03459 --- /dev/null +++ b/packages/app/src/composer/input/height-mirror.d.ts @@ -0,0 +1 @@ +export * from "./height-mirror.native"; diff --git a/packages/app/src/components/composer-height-mirror.native.ts b/packages/app/src/composer/input/height-mirror.native.ts similarity index 100% rename from packages/app/src/components/composer-height-mirror.native.ts rename to packages/app/src/composer/input/height-mirror.native.ts diff --git a/packages/app/src/components/composer-height-mirror.web.ts b/packages/app/src/composer/input/height-mirror.web.ts similarity index 100% rename from packages/app/src/components/composer-height-mirror.web.ts rename to packages/app/src/composer/input/height-mirror.web.ts diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/composer/input/input.tsx similarity index 98% rename from packages/app/src/components/message-input.tsx rename to packages/app/src/composer/input/input.tsx index 4b5b9cf38..e6285aecf 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -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( {/* Button row */} - {/* Left: attachment button + leftContent slot */} + {/* Toolbar left: attachment button + agent controls */} () { let resolve!: (value: T | PromiseLike) => void; diff --git a/packages/app/src/components/agent-input-submit.ts b/packages/app/src/composer/submit.ts similarity index 100% rename from packages/app/src/components/agent-input-submit.ts rename to packages/app/src/composer/submit.ts diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts new file mode 100644 index 000000000..c266d8cba --- /dev/null +++ b/packages/app/src/composer/types.ts @@ -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; +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 6d54f6fb0..e82d5ca71 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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 { diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index 23ebfc69b..552b71cf4 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -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"; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 33bce0da6..f1249c8d7 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 ? ( + + ) : undefined, + [isCompact, serverId, agentId], + ); + return ( - ); diff --git a/packages/app/src/screens/new-workspace-empty.test.ts b/packages/app/src/screens/new-workspace-empty.test.ts index c73166648..3b56e184e 100644 --- a/packages/app/src/screens/new-workspace-empty.test.ts +++ b/packages/app/src/screens/new-workspace-empty.test.ts @@ -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 })); diff --git a/packages/app/src/screens/new-workspace-empty.ts b/packages/app/src/screens/new-workspace-empty.ts index c293d0e2b..bd1b7d522 100644 --- a/packages/app/src/screens/new-workspace-empty.ts +++ b/packages/app/src/screens/new-workspace-empty.ts @@ -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 { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 00ba9125a..d4d8bb79e 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -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(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( + () => ( + + + + + + {agentControlsWithDisabled ? ( + + ) : null} + {checkoutHintPrAttachment ? ( + + ) : null} + + ), + [ + acceptCheckoutHint, + badgePressableStyle, + checkoutHintPrAttachment, + dismissCheckoutHint, + handlePickerOpenChange, + handleSelectOption, + isPending, + openPicker, + options, + pickerEmptyText, + pickerOpen, + renderPickerOption, + selectedItem, + selectedOptionId, + setPickerSearchQuery, + agentControlsWithDisabled, + theme.colors.foregroundMuted, + theme.iconSize.sm, + triggerLabel, + ], + ); + return ( @@ -909,47 +967,10 @@ export function NewWorkspaceScreen({ clearDraft={handleClearDraft} autoFocus commandDraftConfig={composerState?.commandDraftConfig} - statusControls={statusControlsWithDisabled} + agentControls={agentControlsWithDisabled} onAddImages={handleAddImagesCallback} + footer={composerFooter} /> - - - - - - {checkoutHintPrAttachment ? ( - - ) : null} - {errorMessage ? {errorMessage} : null} @@ -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", diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 86f48c3be..c368f0b0d 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -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"; diff --git a/packages/app/src/subagents/section.test.tsx b/packages/app/src/subagents/track.test.tsx similarity index 84% rename from packages/app/src/subagents/section.test.tsx rename to packages/app/src/subagents/track.test.tsx index e1f154b42..a9ed48957 100644 --- a/packages/app/src/subagents/section.test.tsx +++ b/packages/app/src/subagents/track.test.tsx @@ -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) => - React.createElement("span", { ...props, "data-icon": name }); + const createIcon = (name: string) => + function IconStub(props: Record) { + 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('[data-testid^="subagents-section-row-"]'), - ).map((node) => node.getAttribute("data-testid")?.replace("subagents-section-row-", "") ?? ""); + document.querySelectorAll('[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( - { 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); diff --git a/packages/app/src/subagents/section.tsx b/packages/app/src/subagents/track.tsx similarity index 95% rename from packages/app/src/subagents/section.tsx rename to packages/app/src/subagents/track.tsx index bfe9ddb13..48ca2804b 100644 --- a/packages/app/src/subagents/section.tsx +++ b/packages/app/src/subagents/track.tsx @@ -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 ( - + @@ -135,7 +135,7 @@ export function SubagentsSection({ nestedScrollEnabled > {rows.map((row) => ( - 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({ {({ pressed }) => ( @@ -227,7 +227,7 @@ function SubagentArchiveButton({ ({ confirmDialog: confirmDialogMock, })); -vi.mock("./section", () => ({ - SubagentsSection: () => null, +vi.mock("./track", () => ({ + SubagentsTrack: () => null, })); const SERVER_ID = "server-1";