Organize composer into its own module
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user