refactor app draft status controls into input area

This commit is contained in:
Mohamed Boudra
2026-03-05 15:07:56 +07:00
parent 8371ea0c7d
commit 885b575d14
14 changed files with 1370 additions and 894 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createNameId } from 'mnemonic-id'
import type { ImageAttachment } from '@/components/message-input'
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native'
@@ -13,7 +13,7 @@ import { SidebarMenuToggle } from '@/components/headers/menu-header'
import { HeaderToggleButton } from '@/components/headers/header-toggle-button'
import { AgentInputArea } from '@/components/agent-input-area'
import { AgentStreamView } from '@/components/agent-stream-view'
import { AgentConfigRow, FormSelectTrigger } from '@/components/agent-form/agent-form-dropdowns'
import { FormSelectTrigger } from '@/components/agent-form/agent-form-dropdowns'
import { ExplorerSidebar } from '@/components/explorer-sidebar'
import { Combobox } from '@/components/ui/combobox'
import { FileDropZone } from '@/components/file-drop-zone'
@@ -32,7 +32,6 @@ import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-d
import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions'
import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
import { useSessionStore } from '@/stores/session-store'
import { useCreateFlowStore } from '@/stores/create-flow-store'
import { generateDraftId } from '@/stores/draft-keys'
import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime'
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
@@ -40,7 +39,6 @@ import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-stor
import { MAX_CONTENT_WIDTH } from '@/constants/layout'
import { WelcomeScreen } from '@/components/welcome-screen'
import type { Agent } from '@/contexts/session-context'
import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from '@/types/stream'
import { encodeImages } from '@/utils/encode-images'
import type {
AgentProvider,
@@ -52,9 +50,9 @@ import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
import { useTauriDragHandlers } from '@/utils/tauri-window'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
const EMPTY_PENDING_PERMISSIONS = new Map()
const EMPTY_STREAM_ITEMS: StreamItem[] = []
const MAX_INITIAL_AGENT_TITLE_CHARS = 60
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -268,10 +266,6 @@ function DraftAgentScreenContent({
const worktreeAnchorRef = useRef<View>(null)
const branchAnchorRef = useRef<View>(null)
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending)
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId)
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle)
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear)
useEffect(() => {
const trimmed = branchSearchQuery.trim()
@@ -285,58 +279,6 @@ function DraftAgentScreenContent({
return () => clearTimeout(timer)
}, [workingDirSearchQuery])
type CreateAttempt = {
clientMessageId: string
text: string
timestamp: Date
images?: UserMessageImageAttachment[]
}
type DraftAgentMachineState =
| { tag: 'draft'; promptText: string; errorMessage: string }
| { tag: 'creating'; attempt: CreateAttempt }
type DraftAgentMachineEvent =
| { type: 'DRAFT_SET_PROMPT'; text: string }
| { 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)}`)
}
const [machine, dispatch] = useReducer(
(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState => {
switch (event.type) {
case 'DRAFT_SET_PROMPT': {
if (state.tag !== 'draft') {
return state
}
return { ...state, promptText: event.text }
}
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', promptText: state.attempt.text, errorMessage: event.message }
}
default:
return assertNever(event)
}
},
{ tag: 'draft', promptText: '', errorMessage: '' }
)
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files)
}, [])
@@ -830,79 +772,179 @@ function DraftAgentScreenContent({
workingDir,
])
const promptValue = machine.tag === 'draft' ? machine.promptText : ''
const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : ''
const isSubmitting = machine.tag === 'creating'
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== 'creating') {
return EMPTY_STREAM_ITEMS
}
return [
{
kind: 'user_message',
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
...(machine.attempt.images && machine.attempt.images.length > 0
? { images: machine.attempt.images }
: {}),
},
]
}, [machine])
const draftAgent = useMemo<Agent | null>(() => {
if (machine.tag !== 'creating') {
const {
promptValue,
formErrorMessage,
isSubmitting,
optimisticStreamItems,
draftAgent,
setPromptText,
handleCreateFromInput,
} = useDraftAgentCreateFlow<Agent, { id: string; cwd: string }>({
draftId: draftIdRef.current,
getPendingServerId: () => selectedServerId,
validateBeforeSubmit: ({ text }) => {
const trimmedPath = workingDir.trim()
if (!trimmedPath) {
return 'Working directory is required'
}
if (isDirectoryNotExists) {
return 'Working directory does not exist on the selected host'
}
if (!text.trim()) {
return 'Initial prompt is required'
}
if (!selectedServerId) {
return 'No host selected'
}
if (providerDefinitions.length === 0) {
return 'No available providers on the selected host'
}
if (gitBlockingError) {
return gitBlockingError
}
if (isAttachWorktree && !selectedWorktreePath) {
return 'Select a worktree to attach'
}
if (baseBranchError) {
return baseBranchError
}
if (!createAgentClient) {
return 'Host is not connected'
}
return null
}
const serverId = selectedServerId ?? ''
const now = machine.attempt.timestamp
const cwd =
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || '.'
const provider = selectedProvider
const model = selectedModel.trim() || null
const thinkingOptionId = selectedThinkingOptionId.trim() || null
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : null
},
onBeforeSubmit: () => {
void persistFormPreferences()
if (Platform.OS === 'web') {
;(document.activeElement as HTMLElement | null)?.blur?.()
}
Keyboard.dismiss()
},
onCreateStart: () => {
onCreateFlowActiveChange?.(true)
},
onCreateError: () => {
onCreateFlowActiveChange?.(false)
},
buildDraftAgent: (attempt) => {
const serverId = selectedServerId ?? ''
const now = attempt.timestamp
const cwd =
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || '.'
const provider = selectedProvider
const model = selectedModel.trim() || null
const thinkingOptionId = selectedThinkingOptionId.trim() || null
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : null
return {
serverId,
id: draftAgentIdRef.current,
provider,
status: 'running',
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: DRAFT_CAPABILITIES,
currentModeId: modeId,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
return {
serverId,
id: draftAgentIdRef.current,
provider,
sessionId: null,
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: 'New agent',
cwd,
model,
modeId,
},
title: 'New agent',
cwd,
model,
thinkingOptionId,
labels: {},
}
}, [
machine.tag,
machine.tag === 'creating' ? machine.attempt.timestamp : null,
isAttachWorktree,
modeOptions.length,
selectedMode,
selectedModel,
selectedThinkingOptionId,
selectedProvider,
selectedServerId,
selectedWorktreePath,
workingDir,
])
thinkingOptionId,
labels: {},
}
},
createRequest: async ({ attempt, text, images }) => {
const trimmedPath = workingDir.trim()
const resolvedWorkingDir =
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
const trimmedModel = selectedModel.trim()
const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
const derivedTitle = deriveInitialAgentTitle(text)
const config: AgentSessionConfig = {
provider: selectedProvider,
cwd: resolvedWorkingDir,
...(derivedTitle ? { title: derivedTitle } : {}),
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
}
const effectiveBaseBranch = baseBranch.trim()
const effectiveWorktreeSlug =
isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug
if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
setWorktreeSlug(effectiveWorktreeSlug)
}
const gitOptions =
isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug
? {
createWorktree: true,
createNewBranch: true,
newBranchName: effectiveWorktreeSlug,
worktreeSlug: effectiveWorktreeSlug,
baseBranch: effectiveBaseBranch,
}
: undefined
const client = createAgentClient
if (!client) {
throw new Error('Host is not connected')
}
const imagesData = await encodeImages(images)
const result = await client.createAgent({
config,
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
git: gitOptions,
})
if (!result.id || !selectedServerId) {
throw new Error('Failed to create agent')
}
useSessionStore.getState().setAgents(selectedServerId, (prev) => {
const next = new Map(prev)
next.set(result.id, normalizeAgentSnapshot(result, selectedServerId))
return next
})
const createdWorkingDir = typeof result.cwd === 'string' ? result.cwd.trim() : ''
const configuredWorkingDir = config.cwd.trim()
const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir
return {
agentId: result.id,
result: {
id: result.id,
cwd: workspaceId,
},
}
},
onCreateSuccess: ({ result }) => {
const route: Href = buildHostWorkspaceAgentRoute(
selectedServerId as string,
result.cwd,
result.id
) as Href
router.replace(route)
},
})
useEffect(() => {
if (!isFocused) {
return
@@ -927,190 +969,6 @@ function DraftAgentScreenContent({
}
}, [setActiveExplorerCheckout])
const handleCreateFromInput = useCallback(
async ({ text, images }: { text: string; images?: UserMessageImageAttachment[] }) => {
if (isSubmitting) {
throw new Error('Already loading')
}
dispatch({ type: 'DRAFT_SET_ERROR', message: '' })
const trimmedPath = workingDir.trim()
const trimmedPrompt = text.trim()
const resolvedWorkingDir =
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath
if (!trimmedPath) {
dispatch({ type: 'DRAFT_SET_ERROR', message: 'Working directory is required' })
throw new Error('Working directory is required')
}
if (isDirectoryNotExists) {
dispatch({
type: 'DRAFT_SET_ERROR',
message: 'Working directory does not exist on the selected host',
})
throw new Error('Working directory does not exist on the selected host')
}
if (!trimmedPrompt) {
dispatch({ type: 'DRAFT_SET_ERROR', message: 'Initial prompt is required' })
throw new Error('Initial prompt is required')
}
if (!selectedServerId) {
dispatch({ type: 'DRAFT_SET_ERROR', message: 'No host selected' })
throw new Error('No host selected')
}
if (providerDefinitions.length === 0) {
dispatch({
type: 'DRAFT_SET_ERROR',
message: 'No available providers on the selected host',
})
throw new Error('No available providers on the selected host')
}
if (gitBlockingError) {
dispatch({ type: 'DRAFT_SET_ERROR', message: gitBlockingError })
throw new Error(gitBlockingError)
}
if (isAttachWorktree && !selectedWorktreePath) {
const message = 'Select a worktree to attach'
dispatch({ type: 'DRAFT_SET_ERROR', message })
throw new Error(message)
}
if (baseBranchError) {
dispatch({ type: 'DRAFT_SET_ERROR', message: baseBranchError })
throw new Error(baseBranchError)
}
if (!createAgentClient) {
dispatch({ type: 'DRAFT_SET_ERROR', message: 'Host is not connected' })
throw new Error('Host is not connected')
}
const attempt = {
clientMessageId: generateMessageId(),
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
}
setPendingCreateAttempt({
draftId: draftIdRef.current,
serverId: selectedServerId,
agentId: null,
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
})
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
const trimmedModel = selectedModel.trim()
const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
const derivedTitle = deriveInitialAgentTitle(trimmedPrompt)
const config: AgentSessionConfig = {
provider: selectedProvider,
cwd: resolvedWorkingDir,
...(derivedTitle ? { title: derivedTitle } : {}),
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
}
const effectiveBaseBranch = baseBranch.trim()
const effectiveWorktreeSlug =
isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug
if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
setWorktreeSlug(effectiveWorktreeSlug)
}
const gitOptions =
isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug
? {
createWorktree: true,
createNewBranch: true,
newBranchName: effectiveWorktreeSlug,
worktreeSlug: effectiveWorktreeSlug,
baseBranch: effectiveBaseBranch,
}
: undefined
void persistFormPreferences()
if (Platform.OS === 'web') {
;(document.activeElement as HTMLElement | null)?.blur?.()
}
Keyboard.dismiss()
dispatch({ type: 'SUBMIT', attempt })
onCreateFlowActiveChange?.(true)
try {
const imagesData = await encodeImages(images)
const result = await createAgentClient.createAgent({
config,
initialPrompt: trimmedPrompt,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
git: gitOptions,
})
const agentId = result.id
if (agentId && selectedServerId) {
updatePendingAgentId({ draftId: draftIdRef.current, agentId })
useSessionStore.getState().setAgents(selectedServerId, (prev) => {
const next = new Map(prev)
next.set(agentId, normalizeAgentSnapshot(result, selectedServerId))
return next
})
const createdWorkingDir =
typeof result.cwd === 'string' ? result.cwd.trim() : ''
const configuredWorkingDir = config.cwd.trim()
const workspaceId =
createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir
const route: Href = buildHostWorkspaceAgentRoute(
selectedServerId,
workspaceId,
agentId
) as Href
router.replace(route)
return
}
dispatch({
type: 'CREATE_FAILED',
message: 'Failed to create agent',
})
onCreateFlowActiveChange?.(false)
markPendingCreateLifecycle({ draftId: draftIdRef.current, lifecycle: 'abandoned' })
clearPendingCreateAttempt({ draftId: draftIdRef.current })
throw new Error('Failed to create agent')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create agent'
dispatch({ type: 'CREATE_FAILED', message })
onCreateFlowActiveChange?.(false)
markPendingCreateLifecycle({ draftId: draftIdRef.current, lifecycle: 'abandoned' })
clearPendingCreateAttempt({ draftId: draftIdRef.current })
throw error // Re-throw so AgentInputArea knows it failed
}
},
[
worktreeMode,
baseBranch,
worktreeSlug,
selectedWorktreePath,
gitBlockingError,
baseBranchError,
isDirectoryNotExists,
isNonGitDirectory,
modeOptions,
providerDefinitions,
persistFormPreferences,
router,
selectedMode,
selectedModel,
selectedThinkingOptionId,
selectedProvider,
selectedServerId,
createAgentClient,
setPendingCreateAttempt,
updatePendingAgentId,
markPendingCreateLifecycle,
clearPendingCreateAttempt,
workingDir,
isAttachWorktree,
isSubmitting,
onCreateFlowActiveChange,
]
)
if (daemons.length === 0) {
return (
<WelcomeScreen
@@ -1157,7 +1015,7 @@ function DraftAgentScreenContent({
</View>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
{machine.tag === 'creating' && draftAgent && selectedServerId ? (
{isSubmitting && draftAgent && selectedServerId ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={draftAgentIdRef.current}
@@ -1194,22 +1052,6 @@ function DraftAgentScreenContent({
</Text>
</View>
)}
{isMobile ? <View style={styles.formSeparator} /> : null}
<AgentConfigRow
providerDefinitions={providerDefinitions}
selectedProvider={selectedProvider}
onSelectProvider={setProviderFromUser}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelectMode={setModeFromUser}
models={availableModels}
selectedModel={selectedModel}
isModelLoading={isModelLoading}
onSelectModel={setModelFromUser}
thinkingOptions={availableThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
onSelectThinkingOption={setThinkingOptionFromUser}
/>
{isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<View style={styles.formSeparator} />
) : null}
@@ -1352,11 +1194,27 @@ function DraftAgentScreenContent({
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={promptValue}
onChangeText={(next) => dispatch({ type: 'DRAFT_SET_PROMPT', text: next })}
autoFocus={machine.tag === 'draft'}
onChangeText={setPromptText}
autoFocus={!isSubmitting}
onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
draftId={draftIdRef.current}
statusControls={{
providerDefinitions,
selectedProvider,
onSelectProvider: setProviderFromUser,
modeOptions,
selectedMode,
onSelectMode: setModeFromUser,
models: availableModels,
selectedModel,
onSelectModel: setModelFromUser,
isModelLoading,
thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser,
disabled: isSubmitting,
}}
/>
</View>
</View>
@@ -1,23 +1,20 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentConfigRow } from "@/components/agent-form/agent-form-dropdowns";
import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/components/agent-stream-view";
import type { ImageAttachment } from "@/components/message-input";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useAgentFormState } from "@/hooks/use-agent-form-state";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-store";
import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from "@/types/stream";
import { encodeImages } from "@/utils/encode-images";
import type { AgentCapabilityFlags, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Keyboard, Platform, ScrollView, Text, View } from 'react-native'
import { StyleSheet } from 'react-native-unistyles'
import { AgentInputArea } from '@/components/agent-input-area'
import { FileDropZone } from '@/components/file-drop-zone'
import { AgentStreamView } from '@/components/agent-stream-view'
import type { ImageAttachment } from '@/components/message-input'
import { MAX_CONTENT_WIDTH } from '@/constants/layout'
import { useAgentFormState } from '@/hooks/use-agent-form-state'
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
import { useHostRuntimeSession } from '@/runtime/host-runtime'
import type { Agent } from '@/stores/session-store'
import { encodeImages } from '@/utils/encode-images'
import type { AgentCapabilityFlags, AgentSessionConfig } from '@server/server/agent/agent-sdk-types'
import type { AgentSnapshotPayload } from '@server/shared/messages'
const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_PENDING_PERMISSIONS = new Map()
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: false,
@@ -25,35 +22,14 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
};
}
type WorkspaceDraftAgentTabProps = {
serverId: string;
workspaceId: string;
tabId: string;
draftId: string;
onCreated: (snapshot: AgentSnapshotPayload) => void;
};
type CreateAttempt = {
clientMessageId: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
};
type DraftAgentMachineState =
| { tag: "draft"; promptText: string; errorMessage: string }
| { tag: "creating"; attempt: CreateAttempt };
type DraftAgentMachineEvent =
| { type: "DRAFT_SET_PROMPT"; text: string }
| { 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)}`);
serverId: string
workspaceId: string
tabId: string
draftId: string
onCreated: (snapshot: AgentSnapshotPayload) => void
}
export function WorkspaceDraftAgentTab({
@@ -63,13 +39,8 @@ export function WorkspaceDraftAgentTab({
draftId,
onCreated,
}: WorkspaceDraftAgentTabProps) {
const { client, isConnected } = useHostRuntimeSession(serverId);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
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 { client, isConnected } = useHostRuntimeSession(serverId)
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
const {
selectedProvider,
@@ -94,220 +65,117 @@ export function WorkspaceDraftAgentTab({
isVisible: true,
isCreateFlow: true,
onlineServerIds: isConnected ? [serverId] : [],
});
})
// Lock working directory to workspace.
useEffect(() => {
if (workingDir.trim() === workspaceId.trim()) {
return;
return
}
setWorkingDir(workspaceId);
}, [setWorkingDir, workingDir, workspaceId]);
setWorkingDir(workspaceId)
}, [setWorkingDir, workingDir, workspaceId])
const [machine, dispatch] = useReducer(
(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState => {
switch (event.type) {
case "DRAFT_SET_PROMPT": {
if (state.tag !== "draft") {
return state;
}
return { ...state, promptText: event.text };
}
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", promptText: state.attempt.text, errorMessage: event.message };
}
default:
return assertNever(event);
}
},
{ tag: "draft", promptText: "", errorMessage: "" }
);
const promptValue = machine.tag === "draft" ? machine.promptText : "";
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
...(machine.attempt.images && machine.attempt.images.length > 0
? { images: machine.attempt.images }
: {}),
},
];
}, [machine]);
const draftAgent = useMemo<Agent | null>(() => {
if (machine.tag !== "creating") {
return null;
}
const now = machine.attempt.timestamp;
const model = selectedModel.trim() || null;
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
return {
serverId,
id: tabId,
provider: selectedProvider,
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: DRAFT_CAPABILITIES,
currentModeId: modeId,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
title: "Agent",
cwd: workspaceId,
model,
thinkingOptionId,
labels: {},
};
}, [
machine,
modeOptions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
serverId,
tabId,
workspaceId,
]);
const handleCreateFromInput = useCallback(
async ({ text, images }: { text: string; images?: UserMessageImageAttachment[] }) => {
if (isSubmitting) {
throw new Error("Already loading");
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const trimmedPrompt = text.trim();
if (!trimmedPrompt) {
dispatch({ type: "DRAFT_SET_ERROR", message: "Initial prompt is required" });
throw new Error("Initial prompt is required");
const {
promptValue,
formErrorMessage,
isSubmitting,
optimisticStreamItems,
draftAgent,
setPromptText,
handleCreateFromInput,
} = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
draftId,
getPendingServerId: () => serverId,
validateBeforeSubmit: ({ text }) => {
if (!text.trim()) {
return 'Initial prompt is required'
}
if (providerDefinitions.length === 0) {
dispatch({
type: "DRAFT_SET_ERROR",
message: "No available providers on the selected host",
});
throw new Error("No available providers on the selected host");
return 'No available providers on the selected host'
}
if (!client) {
dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" });
throw new Error("Host is not connected");
return 'Host is not connected'
}
return null
},
onBeforeSubmit: () => {
void persistFormPreferences()
if (Platform.OS === 'web') {
;(document.activeElement as HTMLElement | null)?.blur?.()
}
Keyboard.dismiss()
},
buildDraftAgent: (attempt) => {
const now = attempt.timestamp
const model = selectedModel.trim() || null
const thinkingOptionId = selectedThinkingOptionId.trim() || null
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : null
return {
serverId,
id: tabId,
provider: selectedProvider,
status: 'running',
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: DRAFT_CAPABILITIES,
currentModeId: modeId,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
title: 'Agent',
cwd: workspaceId,
model,
thinkingOptionId,
labels: {},
}
},
createRequest: async ({ attempt, text, images }) => {
if (!client) {
throw new Error('Host is not connected')
}
const attempt: CreateAttempt = {
clientMessageId: generateMessageId(),
text: trimmedPrompt,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
};
setPendingCreateAttempt({
draftId,
serverId,
agentId: null,
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp.getTime(),
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
});
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
const trimmedModel = selectedModel.trim();
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
const trimmedModel = selectedModel.trim()
const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
const config: AgentSessionConfig = {
provider: selectedProvider,
cwd: workspaceId,
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
};
void persistFormPreferences();
if (Platform.OS === "web") {
(document.activeElement as HTMLElement | null)?.blur?.();
}
Keyboard.dismiss();
dispatch({ type: "SUBMIT", attempt });
try {
const imagesData = await encodeImages(images);
const result = await client.createAgent({
config,
initialPrompt: trimmedPrompt,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
});
const imagesData = await encodeImages(images)
const result = await client.createAgent({
config,
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
})
const agentId = result.id;
updatePendingAgentId({ draftId, agentId });
onCreated(result);
return;
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create agent";
dispatch({ type: "CREATE_FAILED", message });
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
clearPendingCreateAttempt({ draftId });
throw error;
return {
agentId: result.id,
result,
}
},
[
clearPendingCreateAttempt,
client,
draftId,
isSubmitting,
markPendingCreateLifecycle,
modeOptions.length,
onCreated,
persistFormPreferences,
providerDefinitions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
serverId,
setPendingCreateAttempt,
updatePendingAgentId,
workspaceId,
]
);
onCreateSuccess: ({ result }) => {
onCreated(result)
},
})
const draftCommandConfig = useMemo(() => {
return {
provider: selectedProvider,
cwd: workspaceId,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
...(modeOptions.length > 0 && selectedMode !== '' ? { modeId: selectedMode } : {}),
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
...(selectedThinkingOptionId.trim()
? { thinkingOptionId: selectedThinkingOptionId.trim() }
: {}),
};
}
}, [
modeOptions.length,
selectedMode,
@@ -315,21 +183,21 @@ export function WorkspaceDraftAgentTab({
selectedProvider,
selectedThinkingOptionId,
workspaceId,
]);
])
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
addImagesRef.current?.(files)
}, [])
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
addImagesRef.current = addImages
}, [])
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<View style={styles.contentContainer}>
{machine.tag === "creating" && draftAgent ? (
{isSubmitting && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
@@ -342,23 +210,6 @@ export function WorkspaceDraftAgentTab({
) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
<AgentConfigRow
providerDefinitions={providerDefinitions}
selectedProvider={selectedProvider}
onSelectProvider={setProviderFromUser}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelectMode={setModeFromUser}
models={availableModels}
selectedModel={selectedModel}
onSelectModel={setModelFromUser}
isModelLoading={isModelLoading}
thinkingOptions={availableThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
onSelectThinkingOption={setThinkingOptionFromUser}
disabled={isSubmitting}
/>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
@@ -377,23 +228,39 @@ export function WorkspaceDraftAgentTab({
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={promptValue}
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
autoFocus={machine.tag === "draft"}
onChangeText={setPromptText}
autoFocus={!isSubmitting}
onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
draftId={draftId}
statusControls={{
providerDefinitions,
selectedProvider,
onSelectProvider: setProviderFromUser,
modeOptions,
selectedMode,
onSelectMode: setModeFromUser,
models: availableModels,
selectedModel,
onSelectModel: setModelFromUser,
isModelLoading,
thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser,
disabled: isSubmitting,
}}
/>
</View>
</View>
</FileDropZone>
);
)
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
width: "100%",
alignSelf: "center",
width: '100%',
alignSelf: 'center',
maxWidth: MAX_CONTENT_WIDTH,
backgroundColor: theme.colors.surface0,
},
@@ -415,7 +282,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
},
inputAreaWrapper: {
width: "100%",
width: '100%',
backgroundColor: theme.colors.surface0,
},
errorContainer: {
@@ -430,4 +297,4 @@ const styles = StyleSheet.create((theme) => ({
errorText: {
color: theme.colors.destructive,
},
}));
}))