Organize composer into its own module

This commit is contained in:
Mohamed Boudra
2026-05-26 14:32:45 +07:00
parent 7e792e899a
commit 4231dbbba4
52 changed files with 935 additions and 706 deletions
@@ -0,0 +1,90 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from "@testing-library/react";
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 "./create-flow";
describe("useDraftAgentCreateFlow", () => {
beforeEach(() => {
useCreateFlowStore.setState({ pendingByDraftId: {} });
});
it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => {
const image: UserMessageImageAttachment = {
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-key",
createdAt: 123,
};
const attachment = {
type: "review",
cwd: "/repo",
summary: "Review",
} as unknown as AgentAttachment;
const attempt: DraftCreateAttempt = {
clientMessageId: "msg-prepared",
text: "build this",
timestamp: new Date("2026-05-25T00:00:00.000Z"),
images: [image],
attachments: [attachment],
};
const createRequest = vi.fn(
async (ctx: {
attempt: DraftCreateAttempt;
text: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
cwd: string;
}) => ({
agentId: "agent-1",
result: { id: "agent-1", ctx },
}),
);
const onCreateSuccess = vi.fn();
const { result } = renderHook(() =>
useDraftAgentCreateFlow({
draftId: "draft-1",
getPendingServerId: () => "server-1",
initialAttempt: attempt,
buildDraftAgent: (currentAttempt) => ({ currentAttempt }),
createRequest,
onCreateSuccess,
}),
);
expect(result.current.isSubmitting).toBe(true);
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
expect(result.current.optimisticStreamItems).toEqual([
{
kind: "user_message",
id: "msg-prepared",
text: "build this",
timestamp: attempt.timestamp,
optimistic: true,
images: [image],
attachments: [attachment],
},
]);
expect(createRequest).not.toHaveBeenCalled();
await act(async () => {
await result.current.continueCreateFromAttempt({ attempt, cwd: "/repo" });
});
expect(createRequest).toHaveBeenCalledTimes(1);
expect(createRequest).toHaveBeenCalledWith({
attempt,
text: "build this",
images: [image],
attachments: [attachment],
cwd: "/repo",
});
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,322 @@
import { useCallback, useMemo, useReducer } from "react";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
import type { AgentAttachment } from "@server/shared/messages";
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
interface CreateAttempt {
clientMessageId: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
type DraftAgentMachineState =
| { tag: "draft"; errorMessage: string }
| { tag: "creating"; attempt: CreateAttempt };
type DraftAgentMachineEvent =
| { type: "DRAFT_SET_ERROR"; message: string }
| { type: "SUBMIT"; attempt: CreateAttempt }
| { type: "CREATE_FAILED"; message: string };
function assertNever(value: never): never {
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
}
function reducer(
state: DraftAgentMachineState,
event: DraftAgentMachineEvent,
): DraftAgentMachineState {
switch (event.type) {
case "DRAFT_SET_ERROR": {
if (state.tag !== "draft") {
return state;
}
return { ...state, errorMessage: event.message };
}
case "SUBMIT": {
return { tag: "creating", attempt: event.attempt };
}
case "CREATE_FAILED": {
if (state.tag !== "creating") {
return state;
}
return { tag: "draft", errorMessage: event.message };
}
default:
return assertNever(event);
}
}
interface CreateRequestResult<TCreateResult> {
agentId: string | null;
result: TCreateResult;
}
interface SubmitContext {
text: string;
attachments: ComposerAttachment[];
cwd: string;
}
interface CreateRequestContext {
attempt: CreateAttempt;
text: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
cwd: string;
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string;
getPendingServerId: () => string | null;
initialAttempt?: CreateAttempt | null;
allowEmptyText?: boolean;
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
onCreateStart?: () => void;
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>;
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent;
onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise<void> | void;
onCreateError?: (error: Error) => void;
}
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
initialAttempt = null,
allowEmptyText = false,
validateBeforeSubmit,
onBeforeSubmit,
onCreateStart,
createRequest,
buildDraftAgent,
onCreateSuccess,
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const [machine, dispatch] = useReducer(
reducer,
initialAttempt,
(attempt): DraftAgentMachineState =>
attempt
? { tag: "creating", attempt }
: {
tag: "draft",
errorMessage: "",
},
);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
const appendOptimisticUserMessageToAgentStream = useSessionStore(
(state) => state.appendOptimisticUserMessageToAgentStream,
);
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
if (
!machine.attempt.text &&
(!machine.attempt.images || machine.attempt.images.length === 0) &&
(!machine.attempt.attachments || machine.attempt.attachments.length === 0)
) {
return EMPTY_STREAM_ITEMS;
}
return [
buildOptimisticUserMessage({
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
images: machine.attempt.images,
attachments: machine.attempt.attachments,
}),
];
}, [machine]);
const draftAgent = useMemo<TDraftAgent | null>(() => {
if (machine.tag !== "creating") {
return null;
}
return buildDraftAgent(machine.attempt);
}, [buildDraftAgent, machine]);
const runCreateAttempt = useCallback(
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error("No host selected");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
onBeforeSubmit?.({
attempt,
text: attempt.text,
images: attempt.images,
attachments: attempt.attachments,
cwd,
});
try {
const createResult = await createRequest({
attempt,
text: attempt.text,
images: attempt.images,
attachments: attempt.attachments,
cwd,
});
if (createResult.agentId) {
updatePendingAgentId({ draftId, agentId: createResult.agentId });
appendOptimisticUserMessageToAgentStream(
pendingServerId,
createResult.agentId,
buildOptimisticUserMessage({
id: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp,
images: attempt.images,
attachments: attempt.attachments,
}),
{ placement: "tail", skipIfUserMessageExists: true },
);
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
}
await onCreateSuccess({ result: createResult.result, attempt });
} catch (error) {
const resolved = error instanceof Error ? error : new Error("Failed to create agent");
dispatch({ type: "CREATE_FAILED", message: resolved.message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
onCreateError?.(resolved);
throw error;
}
},
[
appendOptimisticUserMessageToAgentStream,
clearPendingCreateAttempt,
createRequest,
draftId,
getPendingServerId,
markPendingCreateLifecycle,
onBeforeSubmit,
onCreateError,
onCreateSuccess,
updatePendingAgentId,
],
);
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
if (isSubmitting) {
throw new Error("Already loading");
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const images = wirePayload.images;
const trimmedPrompt = text.trim();
if (!trimmedPrompt && !allowEmptyText) {
const error = new Error("Initial prompt is required");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const validationError = validateBeforeSubmit?.({
text: trimmedPrompt,
attachments,
cwd,
});
if (validationError) {
const error = new Error(validationError);
dispatch({ type: "DRAFT_SET_ERROR", message: validationError });
throw error;
}
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error("No host selected");
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const attempt: CreateAttempt = {
clientMessageId: generateMessageId(),
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
setPendingCreateAttempt({
draftId,
serverId: pendingServerId,
agentId: null,
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
...(attempt.attachments && attempt.attachments.length > 0
? { attachments: attempt.attachments }
: {}),
});
dispatch({ type: "SUBMIT", attempt });
onCreateStart?.();
await runCreateAttempt({ attempt, cwd });
},
[
allowEmptyText,
draftId,
getPendingServerId,
isSubmitting,
onCreateStart,
runCreateAttempt,
setPendingCreateAttempt,
validateBeforeSubmit,
],
);
const continueCreateFromAttempt = useCallback(
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
if (!isSubmitting) {
dispatch({ type: "SUBMIT", attempt });
}
await runCreateAttempt({ attempt, cwd });
},
[isSubmitting, runCreateAttempt],
);
return {
machine,
formErrorMessage,
isSubmitting,
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
};
}
export type { CreateAttempt as DraftCreateAttempt };
@@ -0,0 +1,63 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Import as ImportIcon } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
const ThemedImportIcon = withUnistyles(ImportIcon);
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
interface ComposerImportPillProps {
onPress: () => void;
disabled?: boolean;
}
export function ComposerImportPill({ onPress, disabled = false }: ComposerImportPillProps) {
const [isHovered, setIsHovered] = useState(false);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const bodyStyle = useMemo(() => [styles.body, isHovered && styles.bodyHovered], [isHovered]);
return (
<View style={styles.row}>
<Pressable
testID="composer-import-agent-pill"
accessibilityRole="button"
accessibilityLabel="Import session"
onPress={onPress}
disabled={disabled}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={bodyStyle}
>
<ThemedImportIcon size={14} uniProps={iconColorMapping} />
<Text style={styles.label} numberOfLines={1}>
Import session
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
},
body: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
backgroundColor: theme.colors.surface1,
},
bodyHovered: {
backgroundColor: theme.colors.surface2,
},
label: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
}));
@@ -0,0 +1,71 @@
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
export interface DraftKeyContext {
selectedServerId: string | null;
}
export type DraftKeyInput = string | ((context: DraftKeyContext) => string);
export function resolveDraftKey(input: {
draftKey: DraftKeyInput;
selectedServerId: string | null;
}): string {
if (typeof input.draftKey === "function") {
return input.draftKey({ selectedServerId: input.selectedServerId });
}
return input.draftKey;
}
export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
}): DraftAgentControlsProps {
const { formState, features, onSetFeature, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
onSelectProvider: formState.setProviderFromUser,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
onSelectMode: formState.setModeFromUser,
models: formState.availableModels,
selectedModel: formState.selectedModel,
onSelectModel: formState.setModelFromUser,
isModelLoading: formState.isModelLoading,
modelSelectorProviders: formState.modelSelectorProviders,
isAllModelsLoading: formState.isAllModelsLoading,
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
};
}
export function hasDraftContent(input: {
text: string;
attachments: UserComposerAttachment[];
}): boolean {
return input.text.trim().length > 0 || input.attachments.length > 0;
}
export function areAttachmentsEqual(input: {
left: UserComposerAttachment[];
right: UserComposerAttachment[];
}): boolean {
if (input.left.length !== input.right.length) {
return false;
}
return input.left.every((attachment, index) => {
const other = input.right[index];
return JSON.stringify(attachment) === JSON.stringify(other);
});
}
@@ -0,0 +1,544 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
const { asyncStorage } = vi.hoisted(() => ({
asyncStorage: new Map<string, string>(),
}));
vi.hoisted(() => {
(globalThis as unknown as { __DEV__: boolean }).__DEV__ = false;
});
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async (key: string) => asyncStorage.get(key) ?? null,
setItem: async (key: string, value: string) => {
asyncStorage.set(key, value);
},
removeItem: async (key: string) => {
asyncStorage.delete(key);
},
},
}));
vi.mock("@/attachments/service", () => ({
garbageCollectAttachments: async () => undefined,
}));
vi.mock("@/hooks/use-agent-form-state", () => ({
useAgentFormState: () => ({
selectedServerId: "host-1",
setSelectedServerId: () => undefined,
setSelectedServerIdFromUser: () => undefined,
selectedProvider: "codex",
setProviderFromUser: () => undefined,
selectedMode: "auto",
setModeFromUser: () => undefined,
selectedModel: "",
setModelFromUser: () => undefined,
selectedThinkingOptionId: "",
setThinkingOptionFromUser: () => undefined,
workingDir: "/repo",
setWorkingDir: () => undefined,
setWorkingDirFromUser: () => undefined,
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
providerDefinitionMap: new Map(),
agentDefinition: undefined,
modeOptions: [{ id: "auto", label: "Auto" }],
availableModels: [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
],
allProviderModels: new Map([
[
"codex",
[
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
],
],
]),
modelSelectorProviders: [
{
id: "codex",
label: "Codex",
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "gpt-5.4",
isDefault: true,
},
],
},
},
],
isAllModelsLoading: false,
availableThinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
isModelLoading: false,
modelError: null,
refreshProviderModels: () => undefined,
setProviderAndModelFromUser: () => undefined,
workingDirIsEmpty: false,
persistFormPreferences: async () => undefined,
}),
}));
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
beforeAll(async () => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
},
},
configurable: true,
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
({ useAgentInputDraft } = await import("./input-draft"));
});
describe("useAgentInputDraft live contract", () => {
beforeEach(() => {
asyncStorage.clear();
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
url: "http://localhost",
});
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
useDraftStore.setState({ drafts: {}, createModalDraft: null });
});
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
id: "attachment-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/1",
createdAt: 1,
fileName: "image.png",
byteSize: 128,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
}
function Probe({ draftKey }: { draftKey: string }) {
latest = useAgentInputDraft({
draftKey,
composer: {
initialServerId: "host-1",
initialValues: { workingDir: "/repo" },
isVisible: true,
onlineServerIds: ["host-1"],
lockedWorkingDir: "/repo",
},
});
return null;
}
const queryClient = new QueryClient();
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
let root: Root | null = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<Probe draftKey="draft:setup" />
</QueryClientProvider>,
);
});
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.commandDraftConfig).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
});
await act(async () => {
getLatest().setText("hello world");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
await act(async () => {
root!.unmount();
});
root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Probe draftKey="draft:setup" />
</QueryClientProvider>,
);
});
expect(getLatest().text).toBe("hello world");
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
});
it("migrates legacy image drafts to image attachments on hydration", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
id: "legacy-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/legacy-image",
createdAt: 10,
fileName: "legacy.png",
byteSize: 512,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
}
useDraftStore.setState({
drafts: {
"draft:legacy": {
input: {
text: "legacy text",
images: [image],
},
lifecycle: "active",
updatedAt: Date.now(),
version: 1,
} as unknown as DraftRecordForTest,
},
createModalDraft: null,
});
function Probe() {
latest = useAgentInputDraft({ draftKey: "draft:legacy" });
return null;
}
const queryClient = new QueryClient();
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
expect(getLatest().text).toBe("legacy text");
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({
text: "legacy text",
attachments: [{ kind: "image", metadata: image }],
});
});
it("hydrates drafts saved by old builds with cwd", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const githubIssue: ComposerAttachment = {
kind: "github_issue",
item: {
kind: "issue",
number: 42,
title: "Unify attachments",
url: "https://github.com/paseo/paseo/issues/42",
state: "open",
body: "body",
labels: ["composer"],
},
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
}
useDraftStore.setState({
drafts: {
"draft:new-shape": {
input: {
text: "new text",
attachments: [githubIssue],
cwd: "/persisted",
},
lifecycle: "active",
updatedAt: Date.now(),
version: 1,
} as unknown as DraftRecordForTest,
},
createModalDraft: null,
});
function Probe() {
latest = useAgentInputDraft({ draftKey: "draft:new-shape" });
return null;
}
const queryClient = new QueryClient();
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
expect(getLatest().text).toBe("new text");
expect(getLatest().attachments).toEqual([githubIssue]);
await act(async () => {
root.unmount();
});
expect(useDraftStore.getState().drafts["draft:new-shape"]?.input).toEqual({
text: "new text",
attachments: [githubIssue],
cwd: "/persisted",
});
});
it("updates and persists attachments through setAttachments", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
id: "next-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/next-image",
createdAt: 11,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
}
function Probe() {
latest = useAgentInputDraft({ draftKey: "draft:attachments" });
return null;
}
const queryClient = new QueryClient();
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
await act(async () => {
getLatest().setText("with attachment");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({
text: "with attachment",
attachments: [{ kind: "image", metadata: image }],
});
});
it("clear resets text and attachments", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
id: "clear-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/clear-image",
createdAt: 12,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
}
function Probe() {
latest = useAgentInputDraft({ draftKey: "draft:clear" });
return null;
}
const queryClient = new QueryClient();
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
await act(async () => {
getLatest().setText("queued message");
getLatest().setAttachments([{ kind: "image", metadata: image }]);
});
await act(async () => {
getLatest().clear("sent");
});
expect(getLatest().text).toBe("");
expect(getLatest().attachments).toEqual([]);
expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({
text: "",
attachments: [],
});
});
it("clears drafts with sent and abandoned lifecycle tombstones", async () => {
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
const sentImage: AttachmentMetadata = {
id: "attachment-sent",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/sent",
createdAt: 2,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
}
function Probe() {
latest = useAgentInputDraft({ draftKey: "draft:lifecycle" });
return null;
}
const queryClient = new QueryClient();
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
await act(async () => {
getLatest().setText("queued message");
getLatest().setAttachments([{ kind: "image", metadata: sentImage }]);
});
await act(async () => {
getLatest().clear("sent");
});
expect(getLatest().text).toBe("");
expect(getLatest().attachments).toEqual([]);
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
lifecycle: "sent",
input: { text: "", attachments: [] },
});
await act(async () => {
getLatest().setText("draft again");
});
await act(async () => {
getLatest().clear("abandoned");
});
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
lifecycle: "abandoned",
input: { text: "", attachments: [] },
});
});
});
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import { resolveDraftKey } from "./input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "@/provider-selection/provider-selection";
describe("resolveDraftKey", () => {
it("returns a string draft key unchanged", () => {
expect(
resolveDraftKey({
draftKey: "draft:key",
selectedServerId: "host-1",
}),
).toBe("draft:key");
});
it("resolves a computed draft key from the selected server", () => {
expect(
resolveDraftKey({
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
selectedServerId: "host-1",
}),
).toBe("draft:host-1");
});
});
describe("resolveEffectiveComposerModelId", () => {
it("returns the selected model trimmed", () => {
expect(
resolveEffectiveComposerModelId({
provider: "codex",
modelId: " gpt-5.4-mini ",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
}),
).toBe("gpt-5.4-mini");
});
it("returns empty string when no model selected", () => {
expect(
resolveEffectiveComposerModelId({
provider: "codex",
modelId: "",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
}),
).toBe("");
});
});
describe("resolveEffectiveComposerThinkingOptionId", () => {
const models = [
{
provider: "codex",
id: "gpt-5.4",
label: "gpt-5.4",
isDefault: true,
defaultThinkingOptionId: "high",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
],
},
];
it("prefers the selected thinking option when present", () => {
expect(
resolveEffectiveComposerThinkingOptionId(
{
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "medium",
availableModels: models,
modeOptions: [],
},
"gpt-5.4",
),
).toBe("medium");
});
it("falls back to the model default thinking option", () => {
expect(
resolveEffectiveComposerThinkingOptionId(
{
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: models,
modeOptions: [],
},
"gpt-5.4",
),
).toBe("high");
});
});
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
},
cwd: " ",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
});
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
},
cwd: "/repo",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
});
});
});
@@ -0,0 +1,318 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
type CreateAgentInitialValues,
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftAgentControls,
hasDraftContent,
resolveDraftKey,
type DraftKeyInput,
} from "@/composer/draft/input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
type AttachmentUpdater =
| UserComposerAttachment[]
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
interface AgentInputDraftComposerOptions {
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialFeatureValues?: Record<string, unknown>;
isVisible?: boolean;
onlineServerIds?: string[];
lockedWorkingDir?: string;
}
interface UseAgentInputDraftInput {
draftKey: DraftKeyInput;
composer?: AgentInputDraftComposerOptions;
}
type DraftComposerState = UseAgentFormStateResult & {
workingDir: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps;
commandDraftConfig: DraftCommandConfig | undefined;
};
export interface AgentInputDraft {
text: string;
setText: (text: string) => void;
attachments: UserComposerAttachment[];
setAttachments: (updater: AttachmentUpdater) => void;
clear: (lifecycle: "sent" | "abandoned") => void;
isHydrated: boolean;
composerState: DraftComposerState | null;
}
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
const composerOptions = input.composer ?? null;
const formState = useAgentFormState({
initialServerId: composerOptions?.initialServerId ?? null,
initialValues: composerOptions?.initialValues,
isVisible: composerOptions?.isVisible ?? false,
isCreateFlow: true,
onlineServerIds: composerOptions?.onlineServerIds ?? [],
});
const draftKey = useMemo(
() =>
resolveDraftKey({
draftKey: input.draftKey,
selectedServerId: formState.selectedServerId,
}),
[formState.selectedServerId, input.draftKey],
);
const [text, setText] = useState("");
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
const [isHydrated, setIsHydrated] = useState(false);
const draftGenerationRef = useRef(0);
const hydratedGenerationRef = useRef(0);
const setAttachments = useCallback((updater: AttachmentUpdater) => {
setAttachmentsState((previousAttachments) => {
if (typeof updater === "function") {
return updater(previousAttachments);
}
return updater;
});
}, []);
const clear = useCallback(
(lifecycle: "sent" | "abandoned") => {
const store = useDraftStore.getState();
store.clearDraftInput({ draftKey, lifecycle });
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = generation;
setText("");
setAttachmentsState([]);
setIsHydrated(true);
},
[draftKey],
);
useEffect(() => {
const store = useDraftStore.getState();
const generation = store.beginDraftGeneration(draftKey);
draftGenerationRef.current = generation;
hydratedGenerationRef.current = 0;
setText("");
setAttachmentsState([]);
setIsHydrated(false);
let cancelled = false;
void (async () => {
const draft = await store.hydrateDraftInput({
draftKey,
});
if (cancelled) {
return;
}
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
return;
}
if (draft) {
setText(draft.text);
setAttachmentsState(draft.attachments);
}
hydratedGenerationRef.current = generation;
setIsHydrated(true);
})();
return () => {
cancelled = true;
};
}, [draftKey]);
useEffect(() => {
const currentGeneration = draftGenerationRef.current;
if (currentGeneration <= 0) {
return;
}
const store = useDraftStore.getState();
const isCurrentGeneration = store.isDraftGenerationCurrent({
draftKey,
generation: currentGeneration,
});
if (!isCurrentGeneration) {
return;
}
if (hydratedGenerationRef.current !== currentGeneration) {
return;
}
const existing = store.getDraftInput(draftKey);
const isSameDraft =
existing !== undefined &&
existing.text === text &&
areAttachmentsEqual({
left: existing.attachments,
right: attachments,
});
if (isSameDraft) {
return;
}
if (!hasDraftContent({ text, attachments })) {
if (existing) {
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
}
return;
}
store.saveDraftInput({
draftKey,
draft: {
text,
attachments,
},
});
}, [attachments, draftKey, text]);
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
useEffect(() => {
if (!composerOptions || !lockedWorkingDir) {
return;
}
if (formState.workingDir.trim() === lockedWorkingDir) {
return;
}
formState.setWorkingDir(lockedWorkingDir);
}, [composerOptions, formState, lockedWorkingDir]);
const providerSelection = useMemo<ProviderSelectionState>(
() => ({
provider: formState.selectedProvider,
modelId: formState.selectedModel,
modeId: formState.selectedMode,
thinkingOptionId: formState.selectedThinkingOptionId,
availableModels: formState.availableModels,
modeOptions: formState.modeOptions,
}),
[
formState.availableModels,
formState.modeOptions,
formState.selectedMode,
formState.selectedModel,
formState.selectedProvider,
formState.selectedThinkingOptionId,
],
);
const effectiveModelId = useMemo(
() => resolveEffectiveComposerModelId(providerSelection),
[providerSelection],
);
const effectiveThinkingOptionId = useMemo(
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
[effectiveModelId, providerSelection],
);
const workingDir = lockedWorkingDir || formState.workingDir;
const {
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
} = useDraftAgentFeatures({
serverId: formState.selectedServerId,
provider: formState.selectedProvider,
cwd: workingDir,
modeId: formState.selectedMode,
modelId: effectiveModelId,
thinkingOptionId: effectiveThinkingOptionId,
initialFeatureValues: composerOptions?.initialFeatureValues,
});
const commandDraftConfig = useMemo(
() =>
composerOptions
? buildDraftCommandConfig({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
})
: undefined,
[
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
providerSelection,
workingDir,
],
);
const composerState = useMemo<DraftComposerState | null>(() => {
if (!composerOptions) {
return null;
}
return {
...formState,
workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
agentControls: buildDraftAgentControls({
formState,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
}),
commandDraftConfig,
};
}, [
commandDraftConfig,
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
draftFeatures,
draftFeatureValues,
formState,
setDraftFeatureValue,
workingDir,
]);
return {
text,
setText,
attachments,
setAttachments,
clear,
isHydrated,
composerState,
};
}
export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftAgentControls,
};
@@ -0,0 +1,45 @@
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
export interface WorkspaceDraftAutoSubmitConfig {
provider: string;
model: string | null;
}
export function validateDraftSubmission(input: {
text: string;
allowsEmptyAutoSubmit: boolean;
composerState: {
providerDefinitions: unknown[];
selectedProvider: string | null;
isModelLoading: boolean;
effectiveModelId: string | null;
availableModels: unknown[];
};
autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null;
workspaceDirectory: string | null;
hasClient: boolean;
}): string | null {
const {
text,
allowsEmptyAutoSubmit,
composerState,
autoSubmitConfig,
workspaceDirectory,
hasClient,
} = input;
const readiness = resolveSubmissionReadiness({
text,
allowsEmptyAutoSubmit,
providerCount: composerState.providerDefinitions.length,
selection: {
provider: composerState.selectedProvider,
modelId: composerState.effectiveModelId ?? "",
availableModels: composerState.availableModels,
isModelLoading: composerState.isModelLoading,
},
autoSubmitConfig,
workspaceDirectory,
hasClient,
});
return readiness.ok ? null : (readiness.reason ?? null);
}
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { validateDraftSubmission } from "./workspace-tab-core";
const baseComposerState = {
providerDefinitions: [{ id: "deepseek-tui" }],
selectedProvider: "deepseek-tui",
isModelLoading: false,
effectiveModelId: "",
availableModels: [],
};
function validate(overrides = {}) {
return validateDraftSubmission({
text: "hello",
allowsEmptyAutoSubmit: false,
composerState: baseComposerState,
autoSubmitConfig: null,
workspaceDirectory: "/tmp/project",
hasClient: true,
...overrides,
});
}
describe("workspace draft agent model validation", () => {
test("allows a ready provider with no models to submit without a selected model", () => {
expect(validate({})).toBeNull();
});
test("keeps waiting while model defaults are loading", () => {
expect(
validate({
composerState: {
...baseComposerState,
isModelLoading: true,
},
}),
).toBe("Model defaults are still loading");
});
test("still requires a selected model when the provider exposes models", () => {
expect(
validate({
composerState: {
...baseComposerState,
availableModels: [{ id: "deepseek/deepseek-v4-pro" }],
},
}),
).toBe("No model is available for the selected provider");
});
});
@@ -0,0 +1,746 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
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 "@/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 "@/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 "@/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";
import { usePanelStore } from "@/stores/panel-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-store";
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
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 "@/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";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
useWorkspaceAttachments,
useWorkspaceAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
interface AutoSubmitConfig {
provider: string;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown>;
}
function resolveAutoSubmitConfig(
pending: {
provider: string;
modeId?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
featureValues?: Record<string, unknown>;
} | null,
): AutoSubmitConfig | null {
if (!pending) return null;
return {
provider: pending.provider,
modeId: pending.modeId ?? null,
model: pending.model ?? null,
thinkingOptionId: pending.thinkingOptionId ?? null,
featureValues: pending.featureValues ?? {},
};
}
function resolveDraftModeIdOverride(input: {
autoSubmitConfig: AutoSubmitConfig | null;
modeOptionsCount: number;
selectedMode: string;
}): { modeId: string } | Record<string, never> {
const { autoSubmitConfig, modeOptionsCount, selectedMode } = input;
if (autoSubmitConfig?.modeId) {
return { modeId: autoSubmitConfig.modeId };
}
if (modeOptionsCount > 0 && selectedMode !== "") {
return { modeId: selectedMode };
}
return {};
}
function resolveDraftModeId(input: {
autoSubmitConfig: AutoSubmitConfig | null;
modeOptionsCount: number;
selectedMode: string;
}): string | null {
const { autoSubmitConfig, modeOptionsCount, selectedMode } = input;
if (autoSubmitConfig?.modeId !== undefined) {
return autoSubmitConfig.modeId;
}
if (modeOptionsCount > 0 && selectedMode !== "") {
return selectedMode;
}
return null;
}
async function submitDraftCreateRequest(input: {
attempt: { clientMessageId: string };
text: string;
images?: UserMessageImageAttachment[];
attachments?: unknown;
client: DaemonClient | null;
workspaceDirectory: string | null;
workspaceExecutionAuthority: { workspaceId: string } | null;
autoSubmitConfig: AutoSubmitConfig | null;
composerState: {
selectedProvider: string | null;
selectedMode: string;
modeOptions: unknown[];
effectiveModelId: string | null;
effectiveThinkingOptionId: string | null;
featureValues: Record<string, unknown> | undefined;
};
}): Promise<{ agentId: string | null; result: AgentSnapshotPayload }> {
const {
attempt,
text,
images,
attachments,
client,
workspaceDirectory,
workspaceExecutionAuthority,
autoSubmitConfig,
composerState,
} = input;
invariant(workspaceDirectory, "Workspace directory is required");
invariant(workspaceExecutionAuthority, "Workspace authority is required");
if (!client) {
throw new Error("Host is not connected");
}
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
const modeIdOverride = resolveDraftModeIdOverride({
autoSubmitConfig,
modeOptionsCount: composerState.modeOptions.length,
selectedMode: composerState.selectedMode,
});
const config = buildWorkspaceDraftAgentConfig({
provider,
cwd: workspaceDirectory,
...modeIdOverride,
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId:
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const imagesData = await encodeImages(images);
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
const result = await client.createAgent({
config,
workspaceId: workspaceExecutionAuthority.workspaceId,
...(text ? { initialPrompt: text } : {}),
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
});
return {
agentId: result.id,
result,
};
}
function buildDraftAgentSnapshot(input: {
attempt: { timestamp: Date };
serverId: string;
tabId: string;
workspaceDirectory: string | null;
autoSubmitConfig: AutoSubmitConfig | null;
composerState: {
effectiveModelId: string | null;
effectiveThinkingOptionId: string | null;
modeOptions: unknown[];
selectedMode: string;
selectedProvider: string | null;
agentControls: { features?: Agent["features"] };
};
}): Agent {
const { attempt, serverId, tabId, workspaceDirectory, autoSubmitConfig, composerState } = input;
invariant(workspaceDirectory, "Workspace directory is required");
const now = attempt.timestamp;
const model = autoSubmitConfig?.model ?? (composerState.effectiveModelId || null);
const thinkingOptionId =
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || null);
const modeId = resolveDraftModeId({
autoSubmitConfig,
modeOptionsCount: composerState.modeOptions.length,
selectedMode: composerState.selectedMode,
});
const provider = autoSubmitConfig?.provider ?? composerState.selectedProvider;
if (!provider) {
throw new Error("Select a model");
}
return {
serverId,
id: tabId,
provider,
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: DRAFT_CAPABILITIES,
currentModeId: modeId,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: { provider, sessionId: null, model, modeId },
title: "Agent",
cwd: workspaceDirectory,
model,
features: composerState.agentControls.features,
thinkingOptionId,
parentAgentId: null,
labels: {},
};
}
function buildDraftInitialValues(input: {
workingDir: string | null;
initialSetup: WorkspaceDraftTabSetup | null;
}): CreateAgentInitialValues | undefined {
if (!input.workingDir) {
return undefined;
}
if (!input.initialSetup) {
return { workingDir: input.workingDir };
}
return {
workingDir: input.workingDir,
provider: input.initialSetup.provider,
modeId: input.initialSetup.modeId,
model: input.initialSetup.model,
thinkingOptionId: input.initialSetup.thinkingOptionId,
};
}
function resolveDraftWorkingDirectory(input: {
workspaceDirectory: string | null;
initialSetup: WorkspaceDraftTabSetup | null;
}): string | null {
if (input.initialSetup) {
return input.initialSetup.cwd;
}
return input.workspaceDirectory;
}
function resolveOnlineServerIds(input: { isConnected: boolean; serverId: string }): string[] {
if (!input.isConnected) {
return EMPTY_ONLINE_SERVER_IDS;
}
return [input.serverId];
}
interface WorkspaceDraftAgentTabProps {
serverId: string;
workspaceId: string;
tabId: string;
draftId: string;
initialSetup?: WorkspaceDraftTabSetup;
isPaneFocused: boolean;
onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => void;
onOpenImportSheet?: () => void;
}
function resolveImportPillPress(
onOpenImportSheet: (() => void) | undefined,
isSubmitting: boolean,
): (() => void) | null {
if (isSubmitting) {
return null;
}
return onOpenImportSheet ?? null;
}
export function WorkspaceDraftAgentTab({
serverId,
workspaceId,
tabId,
draftId,
initialSetup = undefined,
isPaneFocused,
onCreated,
onOpenWorkspaceFile,
onOpenImportSheet,
}: WorkspaceDraftAgentTabProps) {
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
const draftSetup = initialSetup ?? null;
const draftWorkingDirectory = resolveDraftWorkingDirectory({
workspaceDirectory,
initialSetup: draftSetup,
});
const draftInitialValues = buildDraftInitialValues({
workingDir: draftWorkingDirectory,
initialSetup: draftSetup,
});
const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId });
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const draftStoreKey = useMemo(
() =>
buildDraftStoreKey({
serverId,
agentId: tabId,
draftId,
}),
[draftId, serverId, tabId],
);
const draftInput = useAgentInputDraft({
draftKey: draftStoreKey,
composer: {
initialServerId: serverId,
initialValues: draftInitialValues,
initialFeatureValues: draftSetup?.featureValues,
isVisible: true,
onlineServerIds,
lockedWorkingDir: draftWorkingDirectory ?? undefined,
},
});
const composerState = draftInput.composerState;
if (!composerState) {
throw new Error("Workspace draft composer state is required");
}
const clearDraftInput = draftInput.clear;
const setDraftText = draftInput.setText;
const setDraftAttachments = draftInput.setAttachments;
const pendingAutoSubmit = useWorkspaceDraftSubmissionStore((state) => {
const pending = state.pendingByDraftId[draftId] ?? null;
return pending?.serverId === serverId && pending.workspaceId === workspaceId ? pending : null;
});
const pendingCreateAttempt = useCreateFlowStore((state) => {
const pending = state.pendingByDraftId[draftId] ?? null;
return pending?.serverId === serverId && pending.lifecycle === "active" ? pending : null;
});
const consumePendingAutoSubmit = useWorkspaceDraftSubmissionStore(
(state) => state.consumePending,
);
const autoSubmitConfig = resolveAutoSubmitConfig(pendingAutoSubmit);
const initialCreateAttempt = useMemo<DraftCreateAttempt | null>(() => {
if (!pendingAutoSubmit || !pendingCreateAttempt) {
return null;
}
if (pendingAutoSubmit.clientMessageId !== pendingCreateAttempt.clientMessageId) {
return null;
}
return {
clientMessageId: pendingCreateAttempt.clientMessageId,
text: pendingCreateAttempt.text,
timestamp: new Date(pendingCreateAttempt.timestamp),
...(pendingCreateAttempt.images && pendingCreateAttempt.images.length > 0
? { images: pendingCreateAttempt.images }
: {}),
...(pendingCreateAttempt.attachments && pendingCreateAttempt.attachments.length > 0
? { attachments: pendingCreateAttempt.attachments }
: {}),
};
}, [pendingAutoSubmit, pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompact = useIsCompactFormFactor();
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd: composerState.workingDir,
workspaceId,
});
const workspaceAttachments = useWorkspaceAttachments(workspaceAttachmentScopeKey);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const handleOpenWorkspaceAttachment = useCallback(
(attachment: WorkspaceComposerAttachment) => {
if (attachment.kind !== "review") {
return;
}
const checkout = {
serverId,
cwd: attachment.attachment.cwd,
isGit: true,
};
openFileExplorerForCheckout({
checkout,
isCompact,
});
setExplorerTabForCheckout({
...checkout,
tab: "changes",
});
},
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
);
const {
formErrorMessage,
isSubmitting,
optimisticStreamItems,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
draftId,
getPendingServerId: () => serverId,
initialAttempt: initialCreateAttempt,
allowEmptyText: allowsEmptyAutoSubmit,
validateBeforeSubmit: ({ text }) =>
validateDraftSubmission({
text,
allowsEmptyAutoSubmit,
composerState,
autoSubmitConfig,
workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client),
}),
onBeforeSubmit: () => {
void composerState.persistFormPreferences();
if (isWeb) {
(document.activeElement as HTMLElement | null)?.blur?.();
}
Keyboard.dismiss();
},
buildDraftAgent: (attempt) =>
buildDraftAgentSnapshot({
attempt,
serverId,
tabId,
workspaceDirectory: draftWorkingDirectory,
autoSubmitConfig,
composerState,
}),
createRequest: async ({ attempt, text, images, attachments }) =>
submitDraftCreateRequest({
attempt,
text,
images,
attachments,
client,
workspaceDirectory: draftWorkingDirectory,
workspaceExecutionAuthority,
autoSubmitConfig,
composerState,
}),
onCreateSuccess: ({ result }) => {
clearDraftInput("sent");
onCreated(result);
},
});
const isReadyForPendingAutoSubmit = Boolean(
pendingAutoSubmit &&
draftInput.isHydrated &&
draftWorkingDirectory &&
client &&
!composerState.isModelLoading,
);
const autoSubmitKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!isReadyForPendingAutoSubmit) {
return;
}
const submitKey = `${serverId}:${workspaceId}:${draftId}`;
if (autoSubmitKeyRef.current === submitKey) {
return;
}
const submission = consumePendingAutoSubmit({ serverId, workspaceId, draftId });
if (!submission) {
return;
}
autoSubmitKeyRef.current = submitKey;
setDraftText("");
setDraftAttachments([]);
const preparedAttempt =
initialCreateAttempt?.clientMessageId === submission.clientMessageId
? initialCreateAttempt
: null;
const createPromise = preparedAttempt
? continueCreateFromAttempt({
attempt: preparedAttempt,
cwd: submission.cwd,
})
: handleCreateFromInput({
text: submission.text,
attachments: submission.attachments,
cwd: submission.cwd,
});
void createPromise.catch(() => {
setDraftText(submission.text);
setDraftAttachments(composerWorkspaceAttachment.userAttachmentsOnly(submission.attachments));
autoSubmitKeyRef.current = null;
});
}, [
continueCreateFromAttempt,
consumePendingAutoSubmit,
draftId,
handleCreateFromInput,
initialCreateAttempt,
isReadyForPendingAutoSubmit,
serverId,
setDraftAttachments,
setDraftText,
workspaceId,
]);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const focusInputRef = useRef<(() => void) | null>(null);
const handleFocusInputCallback = useCallback((focus: () => void) => {
focusInputRef.current = focus;
}, []);
const handleProviderSelectWithFocus = useCallback(
(provider: Parameters<typeof composerState.setProviderFromUser>[0]) => {
composerState.setProviderFromUser(provider);
focusInputRef.current?.();
},
[composerState],
);
const handleModeSelectWithFocus = useCallback(
(modeId: string) => {
composerState.setModeFromUser(modeId);
focusInputRef.current?.();
},
[composerState],
);
const handleModelSelectWithFocus = useCallback(
(modelId: string) => {
composerState.setModelFromUser(modelId);
focusInputRef.current?.();
},
[composerState],
);
const handleProviderAndModelSelectWithFocus = useCallback(
(
provider: Parameters<typeof composerState.setProviderAndModelFromUser>[0],
modelId: string,
) => {
composerState.setProviderAndModelFromUser(provider, modelId);
focusInputRef.current?.();
},
[composerState],
);
const handleThinkingOptionSelectWithFocus = useCallback(
(optionId: string) => {
composerState.setThinkingOptionFromUser(optionId);
focusInputRef.current?.();
},
[composerState],
);
const handleSetFeatureWithFocus = useCallback(
(featureId: string, value: unknown) => {
composerState.agentControls.onSetFeature?.(featureId, value);
focusInputRef.current?.();
},
[composerState],
);
const inputAreaWrapperStyle = useMemo(
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
[insets.bottom],
);
const handleDropdownCloseFocus = useCallback(() => {
focusInputRef.current?.();
}, []);
const importPillPress = resolveImportPillPress(onOpenImportSheet, isSubmitting);
const composerAgentControls = useMemo(
() => ({
...composerState.agentControls,
onSelectProvider: handleProviderSelectWithFocus,
onSelectMode: handleModeSelectWithFocus,
onSelectModel: handleModelSelectWithFocus,
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
onSetFeature: handleSetFeatureWithFocus,
onDropdownClose: handleDropdownCloseFocus,
disabled: isSubmitting,
}),
[
composerState.agentControls,
handleProviderSelectWithFocus,
handleModeSelectWithFocus,
handleModelSelectWithFocus,
handleProviderAndModelSelectWithFocus,
handleThinkingOptionSelectWithFocus,
handleSetFeatureWithFocus,
handleDropdownCloseFocus,
isSubmitting,
],
);
const composerFooter = useMemo(
() =>
isCompact ? (
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
) : undefined,
[isCompact, composerAgentControls],
);
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<View style={styles.contentContainer}>
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
serverId={serverId}
agent={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.configScrollContent}
>
<View style={styles.configSection}>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View>
</ScrollView>
)}
</View>
<View style={inputAreaWrapperStyle}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
</View>
</View>
) : null}
<Composer
agentId={tabId}
serverId={serverId}
isPaneFocused={isPaneFocused}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={draftInput.text}
onChangeText={draftInput.setText}
attachments={draftInput.attachments}
workspaceAttachments={workspaceAttachments}
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
onAddImages={handleAddImagesCallback}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
/>
</View>
</View>
</FileDropZone>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
width: "100%",
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flex: 1,
},
streamContainer: {
flex: 1,
},
scrollView: {
flex: 1,
},
configScrollContent: {
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
configSection: {
gap: theme.spacing[3],
},
inputAreaWrapper: {
width: "100%",
backgroundColor: theme.colors.surface0,
},
importPillRow: {
width: "100%",
paddingHorizontal: theme.spacing[4],
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[3],
alignItems: "center",
},
importPillContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
flexDirection: "row",
},
errorContainer: {
marginTop: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.destructive,
},
errorText: {
color: theme.colors.destructive,
},
}));