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