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