Files
paseo/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx
T

301 lines
9.0 KiB
TypeScript

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 { 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 DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
}
type WorkspaceDraftAgentTabProps = {
serverId: string
workspaceId: string
tabId: string
draftId: string
onCreated: (snapshot: AgentSnapshotPayload) => void
onOpenWorkspaceFile: (input: { filePath: string }) => void
}
export function WorkspaceDraftAgentTab({
serverId,
workspaceId,
tabId,
draftId,
onCreated,
onOpenWorkspaceFile,
}: WorkspaceDraftAgentTabProps) {
const { client, isConnected } = useHostRuntimeSession(serverId)
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
const {
selectedProvider,
setProviderFromUser,
selectedMode,
setModeFromUser,
selectedModel,
setModelFromUser,
selectedThinkingOptionId,
setThinkingOptionFromUser,
workingDir,
setWorkingDir,
providerDefinitions,
modeOptions,
availableModels,
availableThinkingOptions,
isModelLoading,
persistFormPreferences,
} = useAgentFormState({
initialServerId: serverId,
initialValues: { workingDir: workspaceId },
isVisible: true,
isCreateFlow: true,
onlineServerIds: isConnected ? [serverId] : [],
})
// Lock working directory to workspace.
useEffect(() => {
if (workingDir.trim() === workspaceId.trim()) {
return
}
setWorkingDir(workspaceId)
}, [setWorkingDir, workingDir, workspaceId])
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) {
return 'No available providers on the selected host'
}
if (!client) {
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 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 } : {}),
}
const imagesData = await encodeImages(images)
const result = await client.createAgent({
config,
initialPrompt: text,
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
})
return {
agentId: result.id,
result,
}
},
onCreateSuccess: ({ result }) => {
onCreated(result)
},
})
const draftCommandConfig = useMemo(() => {
return {
provider: selectedProvider,
cwd: workspaceId,
...(modeOptions.length > 0 && selectedMode !== '' ? { modeId: selectedMode } : {}),
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
...(selectedThinkingOptionId.trim()
? { thinkingOptionId: selectedThinkingOptionId.trim() }
: {}),
}
}, [
modeOptions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
workspaceId,
])
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files)
}, [])
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages
}, [])
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={styles.inputAreaWrapper}>
<AgentInputArea
agentId={tabId}
serverId={serverId}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={promptValue}
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%',
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,
},
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,
},
}))