From 885b575d14ee8767abf513f84d265eaba3dc9816 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Mar 2026 15:07:52 +0700 Subject: [PATCH] refactor app draft status controls into input area --- ...lobal-draft-create-status-controls.spec.ts | 47 ++ packages/app/e2e/helpers/app.ts | 16 +- ...space-draft-create-status-controls.spec.ts | 107 +++ packages/app/src/attachments/store.ts | 6 +- .../agent-input-area.status-controls.test.ts | 28 + .../agent-input-area.status-controls.ts | 5 + .../app/src/components/agent-input-area.tsx | 13 +- .../src/components/agent-status-bar.test.ts | 60 ++ .../app/src/components/agent-status-bar.tsx | 706 ++++++++++++------ .../src/components/agent-status-bar.utils.ts | 45 ++ .../src/hooks/use-draft-agent-create-flow.ts | 239 ++++++ .../src/screens/agent/draft-agent-screen.tsx | 524 +++++-------- .../workspace/workspace-draft-agent-tab.tsx | 397 ++++------ packages/app/src/stores/draft-store.ts | 71 +- 14 files changed, 1370 insertions(+), 894 deletions(-) create mode 100644 packages/app/e2e/global-draft-create-status-controls.spec.ts create mode 100644 packages/app/e2e/workspace-draft-create-status-controls.spec.ts create mode 100644 packages/app/src/components/agent-input-area.status-controls.test.ts create mode 100644 packages/app/src/components/agent-input-area.status-controls.ts create mode 100644 packages/app/src/components/agent-status-bar.test.ts create mode 100644 packages/app/src/components/agent-status-bar.utils.ts create mode 100644 packages/app/src/hooks/use-draft-agent-create-flow.ts diff --git a/packages/app/e2e/global-draft-create-status-controls.spec.ts b/packages/app/e2e/global-draft-create-status-controls.spec.ts new file mode 100644 index 000000000..f3737331d --- /dev/null +++ b/packages/app/e2e/global-draft-create-status-controls.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from './fixtures' +import { createTempGitRepo } from './helpers/workspace' +import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app' + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +test('global draft create uses input status controls and preserves optimistic flow', async ({ page }) => { + const repo = await createTempGitRepo('paseo-e2e-global-draft-') + const prompt = `global draft prompt ${Date.now()}` + + try { + await gotoHome(page) + await ensureHostSelected(page) + await setWorkingDirectory(page, repo.path) + + await expect(page.getByTestId('working-directory-select').first()).toBeVisible({ timeout: 30_000 }) + + const providerSelector = page.getByTestId('agent-provider-selector').first() + const modeSelector = page.getByTestId('agent-mode-selector').first() + const modelSelector = page.getByTestId('agent-model-selector').first() + const thinkingSelector = page.getByTestId('agent-thinking-selector').first() + + await expect(providerSelector).toBeVisible({ timeout: 30_000 }) + await expect(modeSelector).toBeVisible({ timeout: 30_000 }) + await expect(modelSelector).toBeVisible({ timeout: 30_000 }) + await expect(thinkingSelector).toBeVisible({ timeout: 30_000 }) + + const selectedMode = ((await modeSelector.innerText()) ?? '').trim() + const selectedModel = ((await modelSelector.innerText()) ?? '').trim() + const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim() + + const composer = page.getByRole('textbox', { name: 'Message agent...' }).first() + await composer.fill(prompt) + await composer.press('Enter') + + await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 }) + await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }) + + await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i')) + await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i')) + await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i')) + } finally { + await repo.cleanup() + } +}) diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index bb4c77547..29bc83140 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -379,7 +379,9 @@ export const createAgent = async (page: Page, message: string) => { }; async function preferFastThinkingOption(page: Page): Promise { - const providerTrigger = page.getByTestId('draft-provider-select').first(); + const providerTrigger = page + .locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible') + .first(); if (await providerTrigger.isVisible().catch(() => false)) { const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim(); if (!/codex/i.test(providerText)) { @@ -458,7 +460,9 @@ export const selectProvider = async (page: Page, provider: string) => { throw new Error('Provider must be a non-empty string.'); } - const providerTrigger = page.getByTestId('draft-provider-select').first(); + const providerTrigger = page + .locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible') + .first(); if ( await providerTrigger .getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i')) @@ -496,7 +500,9 @@ export const selectModel = async (page: Page, model: string) => { throw new Error('Model must be a non-empty string.'); } - const modelTrigger = page.getByTestId('draft-model-select').first(); + const modelTrigger = page + .locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible') + .first(); if ( await modelTrigger .getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i')) @@ -543,7 +549,9 @@ export const selectModel = async (page: Page, model: string) => { }; export const selectMode = async (page: Page, mode: string) => { - const modeTrigger = page.getByTestId('draft-mode-select').first(); + const modeTrigger = page + .locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible') + .first(); if (await modeTrigger.isVisible().catch(() => false)) { await modeTrigger.click(); } else { diff --git a/packages/app/e2e/workspace-draft-create-status-controls.spec.ts b/packages/app/e2e/workspace-draft-create-status-controls.spec.ts new file mode 100644 index 000000000..4ff913605 --- /dev/null +++ b/packages/app/e2e/workspace-draft-create-status-controls.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from './fixtures' +import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app' +import { createTempGitRepo } from './helpers/workspace' +import { + ensureWorkspaceAgentPaneVisible, + getWorkspaceTabTestIds, + waitForWorkspaceTabsVisible, +} from './helpers/workspace-tabs' +import { switchWorkspaceViaSidebar } from './helpers/workspace-ui' + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +test('workspace draft tab uses input status controls with optimistic create and in-place transition', async ({ + page, +}) => { + const serverId = process.env.E2E_SERVER_ID + if (!serverId) { + throw new Error('E2E_SERVER_ID is not set.') + } + + const repo = await createTempGitRepo('paseo-e2e-workspace-draft-') + const seedPrompt = `seed workspace ${Date.now()}` + const createPrompt = `workspace draft prompt ${Date.now()}` + + try { + await gotoHome(page) + await ensureHostSelected(page) + + // Force the setup onto the global draft surface. In slow runs the app can + // still be on a previously opened agent/workspace view where the placement + // form is not rendered. + await page.goto(`/h/${serverId}/new-agent`) + await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({ + timeout: 30_000, + }) + + await setWorkingDirectory(page, repo.path) + const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first() + await seedComposer.fill(seedPrompt) + await page.getByRole('button', { name: /send message/i }).first().click() + await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }) + await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 }) + + await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path }) + const workspaceRouteToken = page.url().match(/\/workspace\/([^/?#]+)/)?.[1] ?? null + expect(workspaceRouteToken).toBeTruthy() + await waitForWorkspaceTabsVisible(page) + await ensureWorkspaceAgentPaneVisible(page) + + const beforeDraftIds = await getWorkspaceTabTestIds(page) + await page.getByTestId('workspace-new-agent-tab').first().click() + await ensureWorkspaceAgentPaneVisible(page) + + const withDraftIds = await getWorkspaceTabTestIds(page) + const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id)) + if (!draftTabTestId) { + throw new Error('Expected a draft workspace tab to be created.') + } + + const draftId = draftTabTestId.replace('workspace-tab-', '') + const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first() + await expect(draftCloseButton).toBeVisible({ timeout: 30_000 }) + + await expect(page.getByTestId('working-directory-select').first()).not.toBeVisible() + await expect(page.getByTestId('worktree-select-trigger').first()).not.toBeVisible() + + const providerSelector = page.getByTestId('agent-provider-selector').first() + const modeSelector = page.getByTestId('agent-mode-selector').first() + const modelSelector = page.getByTestId('agent-model-selector').first() + const thinkingSelector = page.getByTestId('agent-thinking-selector').first() + + await expect(providerSelector).toBeVisible({ timeout: 30_000 }) + await expect(modeSelector).toBeVisible({ timeout: 30_000 }) + await expect(modelSelector).toBeVisible({ timeout: 30_000 }) + await expect(thinkingSelector).toBeVisible({ timeout: 30_000 }) + + const selectedMode = ((await modeSelector.innerText()) ?? '').trim() + const selectedModel = ((await modelSelector.innerText()) ?? '').trim() + const selectedThinking = ((await thinkingSelector.innerText()) ?? '').trim() + + const composer = page.getByRole('textbox', { name: 'Message agent...' }).first() + await composer.fill(createPrompt) + await composer.press('Enter') + + await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ timeout: 5_000 }) + await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 }) + + const finalIds = await getWorkspaceTabTestIds(page) + expect(finalIds).toContain(draftTabTestId) + + await expect(modelSelector).toContainText(new RegExp(escapeRegex(selectedModel), 'i')) + await expect(modeSelector).toContainText(new RegExp(escapeRegex(selectedMode), 'i')) + await expect(thinkingSelector).toContainText(new RegExp(escapeRegex(selectedThinking), 'i')) + + const currentUrl = page.url() + if (!workspaceRouteToken) { + throw new Error('Expected workspace route token to be present.') + } + expect(currentUrl).toContain(`/workspace/${workspaceRouteToken}`) + } finally { + await repo.cleanup() + } +}) diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts index 611e03bc9..218b22d9e 100644 --- a/packages/app/src/attachments/store.ts +++ b/packages/app/src/attachments/store.ts @@ -7,19 +7,19 @@ let attachmentStorePromise: Promise | null = null; async function createAttachmentStore(): Promise { if (Platform.OS === "web") { if (isTauriEnvironment()) { - const { createDesktopAttachmentStore } = await import( + const { createDesktopAttachmentStore } = require( "@/desktop/attachments/desktop-attachment-store" ); return createDesktopAttachmentStore(); } - const { createIndexedDbAttachmentStore } = await import( + const { createIndexedDbAttachmentStore } = require( "@/attachments/web/indexeddb-attachment-store" ); return createIndexedDbAttachmentStore(); } - const { createNativeFileAttachmentStore } = await import( + const { createNativeFileAttachmentStore } = require( "@/attachments/native/native-file-attachment-store" ); return createNativeFileAttachmentStore(); diff --git a/packages/app/src/components/agent-input-area.status-controls.test.ts b/packages/app/src/components/agent-input-area.status-controls.test.ts new file mode 100644 index 000000000..9a24de7fa --- /dev/null +++ b/packages/app/src/components/agent-input-area.status-controls.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { resolveStatusControlMode } from './agent-input-area.status-controls' + +describe('resolveStatusControlMode', () => { + it('uses ready mode when no controlled status controls are provided', () => { + expect(resolveStatusControlMode(undefined)).toBe('ready') + }) + + it('uses draft mode when controlled status controls are provided', () => { + expect( + resolveStatusControlMode({ + providerDefinitions: [], + selectedProvider: 'codex', + onSelectProvider: () => undefined, + modeOptions: [], + selectedMode: '', + onSelectMode: () => undefined, + models: [], + selectedModel: '', + onSelectModel: () => undefined, + isModelLoading: false, + thinkingOptions: [], + selectedThinkingOptionId: '', + onSelectThinkingOption: () => undefined, + }) + ).toBe('draft') + }) +}) diff --git a/packages/app/src/components/agent-input-area.status-controls.ts b/packages/app/src/components/agent-input-area.status-controls.ts new file mode 100644 index 000000000..ba51e1ca7 --- /dev/null +++ b/packages/app/src/components/agent-input-area.status-controls.ts @@ -0,0 +1,5 @@ +import type { DraftAgentStatusBarProps } from './agent-status-bar' + +export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) { + return statusControls ? 'draft' : 'ready' +} diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index ef170df30..6b8e18a72 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useIsFocused } from '@react-navigation/native' import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout' import { generateMessageId, type StreamItem } from '@/types/stream' -import { AgentStatusBar } from './agent-status-bar' +import { AgentStatusBar, DraftAgentStatusBar, type DraftAgentStatusBarProps } from './agent-status-bar' import { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker' import { useSessionStore } from '@/stores/session-store' import { useDraftStore } from '@/stores/draft-store' @@ -34,6 +34,7 @@ import { deleteAttachments, persistAttachmentFromFileUri, } from '@/attachments/service' +import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls' import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard' import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation' import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' @@ -63,6 +64,8 @@ interface AgentInputAreaProps { commandDraftConfig?: DraftCommandConfig /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ onMessageSent?: () => void + /** Controlled status controls rendered in input area (draft flows). */ + statusControls?: DraftAgentStatusBarProps } const EMPTY_ARRAY: readonly QueuedMessage[] = [] @@ -80,6 +83,7 @@ export function AgentInputArea({ onAddImages, commandDraftConfig, onMessageSent, + statusControls, }: AgentInputAreaProps) { markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) const { theme } = useUnistyles() @@ -710,7 +714,12 @@ export function AgentInputArea({ ) - const leftContent = + const leftContent = + resolveStatusControlMode(statusControls) === 'draft' && statusControls ? ( + + ) : ( + + ) return ( { + it('treats empty and default values as unset', () => { + expect(normalizeModelId('')).toBeNull() + expect(normalizeModelId(' default ')).toBeNull() + expect(normalizeModelId(undefined)).toBeNull() + }) + + it('returns trimmed model ids', () => { + expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex') + }) +}) + +describe('resolveAgentModelSelection', () => { + it('prefers runtime model over configured model', () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: 'a', + provider: 'codex', + label: 'Model A', + thinkingOptions: [{ id: 'low', label: 'Low' }], + defaultThinkingOptionId: 'low', + }, + ], + runtimeModelId: 'a', + configuredModelId: 'b', + explicitThinkingOptionId: null, + }) + + expect(selection.activeModelId).toBe('a') + expect(selection.displayModel).toBe('Model A') + expect(selection.selectedThinkingId).toBe('low') + }) + + it('uses explicit thinking option when provided', () => { + const selection = resolveAgentModelSelection({ + models: [ + { + id: 'a', + provider: 'codex', + label: 'Model A', + thinkingOptions: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' }, + ], + defaultThinkingOptionId: 'low', + }, + ], + runtimeModelId: 'a', + configuredModelId: null, + explicitThinkingOptionId: 'high', + }) + + expect(selection.selectedThinkingId).toBe('high') + expect(selection.displayThinking).toBe('High') + }) +}) diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 1da91015f..73c1e5a3f 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -1,162 +1,208 @@ -import { View, Text, Platform, Pressable } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Brain, ChevronDown, SlidersHorizontal } from "lucide-react-native"; -import { useSessionStore } from "@/stores/session-store"; +import { useMemo, useState } from 'react' +import { View, Text, Platform, Pressable } from 'react-native' +import { StyleSheet, useUnistyles } from 'react-native-unistyles' +import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native' +import { useQuery } from '@tanstack/react-query' +import { useSessionStore } from '@/stores/session-store' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; -import { useMemo, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +} from '@/components/ui/dropdown-menu' +import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet' +import type { + AgentMode, + AgentModelDefinition, + AgentProvider, +} from '@server/server/agent/agent-sdk-types' +import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest' +import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils' + +type StatusOption = { + id: string + label: string +} + +type ControlledAgentStatusBarProps = { + providerOptions?: StatusOption[] + selectedProviderId?: string + onSelectProvider?: (providerId: string) => void + modeOptions?: StatusOption[] + selectedModeId?: string + onSelectMode?: (modeId: string) => void + modelOptions?: StatusOption[] + selectedModelId?: string + onSelectModel?: (modelId: string) => void + thinkingOptions?: StatusOption[] + selectedThinkingOptionId?: string + onSelectThinkingOption?: (thinkingOptionId: string) => void + disabled?: boolean + isModelLoading?: boolean +} + +export interface DraftAgentStatusBarProps { + providerDefinitions: AgentProviderDefinition[] + selectedProvider: AgentProvider + onSelectProvider: (provider: AgentProvider) => void + modeOptions: AgentMode[] + selectedMode: string + onSelectMode: (modeId: string) => void + models: AgentModelDefinition[] + selectedModel: string + onSelectModel: (modelId: string) => void + isModelLoading: boolean + thinkingOptions: NonNullable + selectedThinkingOptionId: string + onSelectThinkingOption: (thinkingOptionId: string) => void + disabled?: boolean +} interface AgentStatusBarProps { - agentId: string; - serverId: string; + agentId: string + serverId: string } -function normalizeModelId(modelId: string | null | undefined): string | null { - const normalized = typeof modelId === "string" ? modelId.trim() : ""; - if (!normalized || normalized.toLowerCase() === "default") { - return null; +function findOptionLabel(options: StatusOption[] | undefined, selectedId: string | undefined, fallback: string) { + if (!options || options.length === 0) { + return fallback } - return normalized; + const selected = options.find((option) => option.id === selectedId) + return selected?.label ?? fallback } -export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { - const { theme } = useUnistyles(); - const IS_WEB = Platform.OS === "web"; - const [prefsOpen, setPrefsOpen] = useState(false); - const dropdownMaxWidth = IS_WEB ? 360 : undefined; +function ControlledStatusBar({ + providerOptions, + selectedProviderId, + onSelectProvider, + modeOptions, + selectedModeId, + onSelectMode, + modelOptions, + selectedModelId, + onSelectModel, + thinkingOptions, + selectedThinkingOptionId, + onSelectThinkingOption, + disabled = false, + isModelLoading = false, +}: ControlledAgentStatusBarProps) { + const { theme } = useUnistyles() + const isWeb = Platform.OS === 'web' + const [prefsOpen, setPrefsOpen] = useState(false) + const dropdownMaxWidth = isWeb ? 360 : undefined - // Select only the specific agent (not all agents) - const agent = useSessionStore((state) => - state.sessions[serverId]?.agents?.get(agentId) - ); + const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0) + const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0) + const canSelectModel = Boolean(onSelectModel) + const canSelectThinking = Boolean( + onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0 + ) - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider') + const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default') + const displayModel = + isModelLoading && (!modelOptions || modelOptions.length === 0) + ? 'Loading models...' + : findOptionLabel(modelOptions, selectedModelId, 'Auto') + const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto') - const canFetchModels = - Boolean(client) && Boolean(agent?.provider) && (IS_WEB || prefsOpen); - const modelsQuery = useQuery({ - queryKey: [ - "providerModels", - serverId, - agent?.provider ?? "__missing_provider__", - agent?.cwd ?? "__missing_cwd__", - ], - enabled: canFetchModels, - staleTime: 5 * 60 * 1000, - queryFn: async () => { - if (!client || !agent) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }); - if (payload.error) { - throw new Error(payload.error); - } - return payload.models ?? []; - }, - }); - const models = modelsQuery.data ?? null; + const hasAnyControl = + Boolean(providerOptions?.length) || + Boolean(modeOptions?.length) || + canSelectModel || + Boolean(thinkingOptions?.length) - function handleModeChange(modeId: string) { - if (!client || !agent) { - return; - } - void client.setAgentMode(agentId, modeId).catch((error) => { - console.warn("[AgentStatusBar] setAgentMode failed", error); - }); + if (!hasAnyControl) { + return null } - const normalizedRuntimeModelId = normalizeModelId(agent?.runtimeInfo?.model); - const normalizedConfiguredModelId = normalizeModelId(agent?.model); - const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId; - const selectedModel = useMemo(() => { - if (!models || !preferredModelId) return null; - return models.find((m) => m.id === preferredModelId) ?? null; - }, [models, preferredModelId]); - - const activeModelId = selectedModel?.id ?? preferredModelId ?? null; - const displayModel = selectedModel ? selectedModel.label : preferredModelId ?? "Auto"; - - const thinkingOptions = selectedModel?.thinkingOptions ?? null; - const explicitThinkingId = - agent?.thinkingOptionId && agent.thinkingOptionId !== "default" - ? agent.thinkingOptionId - : null; - const selectedThinkingId = - explicitThinkingId ?? selectedModel?.defaultThinkingOptionId ?? null; - const selectedThinking = thinkingOptions?.find((o) => o.id === selectedThinkingId) ?? null; - const displayThinking = - selectedThinking?.label ?? - (selectedThinkingId === "default" ? "Model default" : selectedThinkingId ?? "auto"); - - const displayMode = - agent?.availableModes?.find((m) => m.id === agent.currentModeId)?.label || - agent?.currentModeId || - "default"; - - if (!agent) { - return null; - } + const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0 return ( - - {/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */} - {IS_WEB && agent.availableModes && agent.availableModes.length > 0 && ( - - [ - styles.modeBadge, - hovered && styles.modeBadgeHovered, - (pressed || open) && styles.modeBadgePressed, - ]} - accessibilityRole="button" - accessibilityLabel="Select agent mode" - testID="agent-mode-selector" - > - - {agent.availableModes?.find((m) => m.id === agent.currentModeId) - ?.label || - agent.currentModeId || - "default"} - - - - - {agent.availableModes.map((mode) => { - const isActive = mode.id === agent.currentModeId; - return ( - handleModeChange(mode.id)} - > - {mode.label} - - ); - })} - - - )} - - {/* Desktop: inline dropdowns for model/thinking */} - {IS_WEB && ( + + {isWeb ? ( <> + {providerOptions && providerOptions.length > 0 ? ( + + [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || open) && styles.modeBadgePressed, + (disabled || !canSelectProvider) && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent provider" + testID="agent-provider-selector" + > + {displayProvider} + + + + {providerOptions.map((provider) => ( + onSelectProvider?.(provider.id)} + > + {provider.label} + + ))} + + + ) : null} + + {modeOptions && modeOptions.length > 0 ? ( + + [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || open) && styles.modeBadgePressed, + (disabled || !canSelectMode) && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent mode" + testID="agent-mode-selector" + > + {displayMode} + + + + {modeOptions.map((mode) => ( + onSelectMode?.(mode.id)} + > + {mode.label} + + ))} + + + ) : null} + [ styles.modeBadge, hovered && styles.modeBadgeHovered, (pressed || open) && styles.modeBadgePressed, + modelDisabled && styles.disabledBadge, ]} accessibilityRole="button" accessibilityLabel="Select agent model" @@ -171,35 +217,27 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { maxWidth={dropdownMaxWidth} testID="agent-model-menu" > - {models?.map((model) => { - const isActive = model.id === activeModelId; - return ( - { - if (!client) { - return; - } - void client.setAgentModel(agentId, model.id).catch((error) => { - console.warn("[AgentStatusBar] setAgentModel failed", error); - }); - }} - > - {model.label} - - ); - })} + {(modelOptions ?? []).map((model) => ( + onSelectModel?.(model.id)} + > + {model.label} + + ))} - {thinkingOptions && thinkingOptions.length > 1 && ( + {thinkingOptions && thinkingOptions.length > 0 ? ( [ styles.modeBadge, hovered && styles.modeBadgeHovered, (pressed || open) && styles.modeBadgePressed, + (disabled || !canSelectThinking) && styles.disabledBadge, ]} accessibilityRole="button" accessibilityLabel="Select thinking option" @@ -219,35 +257,20 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { maxWidth={dropdownMaxWidth} testID="agent-thinking-menu" > - {thinkingOptions.map((opt) => { - const isActive = opt.id === selectedThinkingId; - return ( - { - if (!client) { - return; - } - void client - .setAgentThinkingOption(agentId, opt.id) - .catch((error) => { - console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); - }); - }} - > - {opt.label} - - ); - })} + {thinkingOptions.map((thinking) => ( + onSelectThinkingOption?.(thinking.id)} + > + {thinking.label} + + ))} - )} + ) : null} - )} - - {/* Mobile: preferences button opens a bottom sheet */} - {!IS_WEB && ( + ) : ( <> setPrefsOpen(true)} @@ -268,13 +291,47 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { onClose={() => setPrefsOpen(false)} testID="agent-preferences-sheet" > - {agent.availableModes && agent.availableModes.length > 0 && ( + {providerOptions && providerOptions.length > 0 ? ( [ styles.sheetSelect, pressed && styles.sheetSelectPressed, + (disabled || !canSelectProvider) && styles.disabledSheetSelect, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent provider" + testID="agent-preferences-provider" + > + {displayProvider} + + + + {providerOptions.map((provider) => ( + onSelectProvider?.(provider.id)} + > + {provider.label} + + ))} + + + + ) : null} + + {modeOptions && modeOptions.length > 0 ? ( + + + [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + (disabled || !canSelectMode) && styles.disabledSheetSelect, ]} accessibilityRole="button" accessibilityLabel="Select agent mode" @@ -284,29 +341,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { - {agent.availableModes.map((mode) => { - const isActive = mode.id === agent.currentModeId; - return ( - handleModeChange(mode.id)} - > - {mode.label} - - ); - })} + {modeOptions.map((mode) => ( + onSelectMode?.(mode.id)} + > + {mode.label} + + ))} - )} + ) : null} [ styles.sheetSelect, pressed && styles.sheetSelectPressed, + modelDisabled && styles.disabledSheetSelect, ]} accessibilityRole="button" accessibilityLabel="Select agent model" @@ -316,36 +372,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { - {models?.map((model) => { - const isActive = model.id === activeModelId; - return ( - { - if (!client) { - return; - } - void client.setAgentModel(agentId, model.id).catch((error) => { - console.warn("[AgentStatusBar] setAgentModel failed", error); - }); - }} - > - {model.label} - - ); - })} + {(modelOptions ?? []).map((model) => ( + onSelectModel?.(model.id)} + > + {model.label} + + ))} - {thinkingOptions && thinkingOptions.length > 1 && ( + {thinkingOptions && thinkingOptions.length > 0 ? ( [ styles.sheetSelect, pressed && styles.sheetSelectPressed, + (disabled || !canSelectThinking) && styles.disabledSheetSelect, ]} accessibilityRole="button" accessibilityLabel="Select thinking option" @@ -355,53 +403,209 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { - {thinkingOptions.map((opt) => { - const isActive = opt.id === selectedThinkingId; - return ( - { - if (!client) { - return; - } - void client - .setAgentThinkingOption(agentId, opt.id) - .catch((error) => { - console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); - }); - }} - > - {opt.label} - - ); - })} + {thinkingOptions.map((thinking) => ( + onSelectThinkingOption?.(thinking.id)} + > + {thinking.label} + + ))} - )} - + ) : null} )} - ); + ) +} + +export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { + const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) + + const modelsQuery = useQuery({ + queryKey: [ + 'providerModels', + serverId, + agent?.provider ?? '__missing_provider__', + agent?.cwd ?? '__missing_cwd__', + ], + enabled: Boolean(client && agent?.provider), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client || !agent) { + throw new Error('Daemon client unavailable') + } + const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }) + if (payload.error) { + throw new Error(payload.error) + } + return payload.models ?? [] + }, + }) + + const models = modelsQuery.data ?? null + + const displayMode = + agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label || + agent?.currentModeId || + 'default' + + const modelSelection = resolveAgentModelSelection({ + models, + runtimeModelId: agent?.runtimeInfo?.model, + configuredModelId: agent?.model, + explicitThinkingOptionId: agent?.thinkingOptionId, + }) + + const modeOptions = useMemo(() => { + return (agent?.availableModes ?? []).map((mode) => ({ + id: mode.id, + label: mode.label, + })) + }, [agent?.availableModes]) + + const modelOptions = useMemo(() => { + return (models ?? []).map((model) => ({ id: model.id, label: model.label })) + }, [models]) + + const thinkingOptions = useMemo(() => { + return (modelSelection.thinkingOptions ?? []).map((option) => ({ + id: option.id, + label: option.label, + })) + }, [modelSelection.thinkingOptions]) + + if (!agent) { + return null + } + + return ( + 0 + ? modeOptions + : [{ id: agent.currentModeId ?? '', label: displayMode }] + } + selectedModeId={agent.currentModeId ?? undefined} + onSelectMode={(modeId) => { + if (!client) { + return + } + void client.setAgentMode(agentId, modeId).catch((error) => { + console.warn('[AgentStatusBar] setAgentMode failed', error) + }) + }} + modelOptions={modelOptions} + selectedModelId={modelSelection.activeModelId ?? undefined} + onSelectModel={(modelId) => { + if (!client) { + return + } + void client.setAgentModel(agentId, modelId).catch((error) => { + console.warn('[AgentStatusBar] setAgentModel failed', error) + }) + }} + thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined} + selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} + onSelectThinkingOption={(thinkingOptionId) => { + if (!client) { + return + } + void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { + console.warn('[AgentStatusBar] setAgentThinkingOption failed', error) + }) + }} + isModelLoading={modelsQuery.isPending || modelsQuery.isFetching} + disabled={!client} + /> + ) +} + +export function DraftAgentStatusBar({ + providerDefinitions, + selectedProvider, + onSelectProvider, + modeOptions, + selectedMode, + onSelectMode, + models, + selectedModel, + onSelectModel, + isModelLoading, + thinkingOptions, + selectedThinkingOptionId, + onSelectThinkingOption, + disabled = false, +}: DraftAgentStatusBarProps) { + const providerOptions = useMemo(() => { + return providerDefinitions.map((definition) => ({ + id: definition.id, + label: definition.label, + })) + }, [providerDefinitions]) + + const mappedModeOptions = useMemo(() => { + if (modeOptions.length === 0) { + return [{ id: '', label: 'Default' }] + } + return modeOptions.map((mode) => ({ id: mode.id, label: mode.label })) + }, [modeOptions]) + + const modelOptions = useMemo(() => { + const options: StatusOption[] = [{ id: '', label: 'Auto' }] + for (const model of models) { + options.push({ id: model.id, label: model.label }) + } + return options + }, [models]) + + const mappedThinkingOptions = useMemo(() => { + return thinkingOptions.map((option) => ({ id: option.id, label: option.label })) + }, [thinkingOptions]) + + const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || '' + const effectiveSelectedThinkingOption = + selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined + + return ( + onSelectProvider(providerId as AgentProvider)} + modeOptions={mappedModeOptions} + selectedModeId={effectiveSelectedMode} + onSelectMode={onSelectMode} + modelOptions={modelOptions} + selectedModelId={selectedModel} + onSelectModel={onSelectModel} + isModelLoading={isModelLoading} + thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} + selectedThinkingOptionId={effectiveSelectedThinkingOption} + onSelectThinkingOption={onSelectThinkingOption} + disabled={disabled} + /> + ) } const styles = StyleSheet.create((theme) => ({ container: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', gap: theme.spacing[1], }, modeBadge: { - flexDirection: "row", - alignItems: "center", - backgroundColor: "transparent", + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'transparent', gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius["2xl"], + borderRadius: theme.borderRadius['2xl'], }, modeBadgeHovered: { backgroundColor: theme.colors.surface2, @@ -409,6 +613,9 @@ const styles = StyleSheet.create((theme) => ({ modeBadgePressed: { backgroundColor: theme.colors.surface0, }, + disabledBadge: { + opacity: 0.5, + }, modeBadgeText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, @@ -418,8 +625,8 @@ const styles = StyleSheet.create((theme) => ({ width: 34, height: 34, borderRadius: theme.borderRadius.full, - alignItems: "center", - justifyContent: "center", + alignItems: 'center', + justifyContent: 'center', }, prefsButtonPressed: { backgroundColor: theme.colors.surface0, @@ -428,9 +635,9 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, sheetSelect: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', gap: theme.spacing[3], paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], @@ -442,10 +649,13 @@ const styles = StyleSheet.create((theme) => ({ sheetSelectPressed: { backgroundColor: theme.colors.surface2, }, + disabledSheetSelect: { + opacity: 0.5, + }, sheetSelectText: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, -})); +})) diff --git a/packages/app/src/components/agent-status-bar.utils.ts b/packages/app/src/components/agent-status-bar.utils.ts new file mode 100644 index 000000000..000b0cadb --- /dev/null +++ b/packages/app/src/components/agent-status-bar.utils.ts @@ -0,0 +1,45 @@ +import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types' + +export function normalizeModelId(modelId: string | null | undefined): string | null { + const normalized = typeof modelId === 'string' ? modelId.trim() : '' + if (!normalized || normalized.toLowerCase() === 'default') { + return null + } + return normalized +} + +export function resolveAgentModelSelection(input: { + models: AgentModelDefinition[] | null + runtimeModelId: string | null | undefined + configuredModelId: string | null | undefined + explicitThinkingOptionId: string | null | undefined +}) { + const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input + const normalizedRuntimeModelId = normalizeModelId(runtimeModelId) + const normalizedConfiguredModelId = normalizeModelId(configuredModelId) + const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId + const selectedModel = + models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null + + const activeModelId = selectedModel?.id ?? preferredModelId ?? null + const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto' + + const thinkingOptions = selectedModel?.thinkingOptions ?? null + const selectedThinkingId = + explicitThinkingOptionId && explicitThinkingOptionId !== 'default' + ? explicitThinkingOptionId + : selectedModel?.defaultThinkingOptionId ?? null + const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null + const displayThinking = + selectedThinking?.label ?? + (selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto') + + return { + selectedModel, + activeModelId, + displayModel, + thinkingOptions, + selectedThinkingId, + displayThinking, + } +} diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/hooks/use-draft-agent-create-flow.ts new file mode 100644 index 000000000..d2eb633af --- /dev/null +++ b/packages/app/src/hooks/use-draft-agent-create-flow.ts @@ -0,0 +1,239 @@ +import { useCallback, useMemo, useReducer } from 'react' +import { useCreateFlowStore } from '@/stores/create-flow-store' +import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from '@/types/stream' + +const EMPTY_STREAM_ITEMS: StreamItem[] = [] + +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)}`) +} + +function reducer(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) + } +} + +type CreateRequestResult = { + agentId: string | null + result: TCreateResult +} + +type SubmitContext = { + text: string + images?: UserMessageImageAttachment[] +} + +type CreateRequestContext = { + attempt: CreateAttempt + text: string + images?: UserMessageImageAttachment[] +} + +interface UseDraftAgentCreateFlowOptions { + draftId: string + getPendingServerId: () => string | null + validateBeforeSubmit?: (ctx: SubmitContext) => string | null + onBeforeSubmit?: (ctx: CreateRequestContext) => void + onCreateStart?: () => void + createRequest: (ctx: CreateRequestContext) => Promise> + buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent + onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise | void + onCreateError?: (error: Error) => void +} + +export function useDraftAgentCreateFlow({ + draftId, + getPendingServerId, + validateBeforeSubmit, + onBeforeSubmit, + onCreateStart, + createRequest, + buildDraftAgent, + onCreateSuccess, + onCreateError, +}: UseDraftAgentCreateFlowOptions) { + const [machine, dispatch] = useReducer(reducer, { + tag: 'draft', + promptText: '', + errorMessage: '', + } as DraftAgentMachineState) + + 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 promptValue = machine.tag === 'draft' ? machine.promptText : '' + const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : '' + const isSubmitting = machine.tag === 'creating' + + const optimisticStreamItems = useMemo(() => { + 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(() => { + if (machine.tag !== 'creating') { + return null + } + return buildDraftAgent(machine.attempt) + }, [buildDraftAgent, machine]) + + const setPromptText = useCallback((text: string) => { + dispatch({ type: 'DRAFT_SET_PROMPT', text }) + }, []) + + const handleCreateFromInput = useCallback( + async ({ text, images }: SubmitContext) => { + if (isSubmitting) { + throw new Error('Already loading') + } + + dispatch({ type: 'DRAFT_SET_ERROR', message: '' }) + + const trimmedPrompt = text.trim() + if (!trimmedPrompt) { + const error = new Error('Initial prompt is required') + dispatch({ type: 'DRAFT_SET_ERROR', message: error.message }) + throw error + } + + const validationError = validateBeforeSubmit?.({ text: trimmedPrompt, images }) + 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 } : {}), + } + + 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 } : {}), + }) + + onBeforeSubmit?.({ attempt, text: trimmedPrompt, images }) + dispatch({ type: 'SUBMIT', attempt }) + onCreateStart?.() + + try { + const createResult = await createRequest({ + attempt, + text: trimmedPrompt, + images, + }) + + if (createResult.agentId) { + updatePendingAgentId({ draftId, agentId: createResult.agentId }) + } + + 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 + } + }, + [ + clearPendingCreateAttempt, + createRequest, + draftId, + getPendingServerId, + isSubmitting, + markPendingCreateLifecycle, + onBeforeSubmit, + onCreateError, + onCreateStart, + onCreateSuccess, + setPendingCreateAttempt, + updatePendingAgentId, + validateBeforeSubmit, + ] + ) + + return { + machine, + promptValue, + formErrorMessage, + isSubmitting, + optimisticStreamItems, + draftAgent, + setPromptText, + handleCreateFromInput, + } +} + +export type { CreateAttempt as DraftCreateAttempt } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 2fd9c64a4..9c6427650 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -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(null) const branchAnchorRef = useRef(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(() => { - 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(() => { - if (machine.tag !== 'creating') { + const { + promptValue, + formErrorMessage, + isSubmitting, + optimisticStreamItems, + draftAgent, + setPromptText, + handleCreateFromInput, + } = useDraftAgentCreateFlow({ + 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 ( - {machine.tag === 'creating' && draftAgent && selectedServerId ? ( + {isSubmitting && draftAgent && selectedServerId ? ( )} - {isMobile ? : null} - {isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( ) : 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, + }} /> diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index b93aa2797..c42edcd49 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -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(() => { - 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(() => { - 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({ + 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 ( - {machine.tag === "creating" && draftAgent ? ( + {isSubmitting && draftAgent ? ( - - {formErrorMessage ? ( {formErrorMessage} @@ -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, + }} /> - ); + ) } 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, }, -})); +})) diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store.ts index 5ecc76c43..d0255f7ca 100644 --- a/packages/app/src/stores/draft-store.ts +++ b/packages/app/src/stores/draft-store.ts @@ -7,6 +7,8 @@ import { persistAttachmentFromDataUrl, persistAttachmentFromFileUri, } from "@/attachments/service"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { useSessionStore } from "@/stores/session-store"; const DRAFT_STORE_VERSION = 2; const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; @@ -245,56 +247,47 @@ async function runAttachmentGc(): Promise { referencedIds.add(id); } - try { - const [{ useCreateFlowStore }, { useSessionStore }] = await Promise.all([ - import("@/stores/create-flow-store"), - import("@/stores/session-store"), - ]); + const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId; + for (const pendingCreate of Object.values(pendingByDraftId)) { + if (pendingCreate.lifecycle !== "active" || !pendingCreate.images) { + continue; + } + for (const image of pendingCreate.images) { + referencedIds.add(image.id); + } + } - const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId; - for (const pendingCreate of Object.values(pendingByDraftId)) { - if (pendingCreate.lifecycle !== "active" || !pendingCreate.images) { - continue; - } - for (const image of pendingCreate.images) { - referencedIds.add(image.id); + const sessions = useSessionStore.getState().sessions; + for (const session of Object.values(sessions)) { + for (const queue of session.queuedMessages.values()) { + for (const queuedMessage of queue) { + for (const image of queuedMessage.images ?? []) { + referencedIds.add(image.id); + } } } - const sessions = useSessionStore.getState().sessions; - for (const session of Object.values(sessions)) { - for (const queue of session.queuedMessages.values()) { - for (const queuedMessage of queue) { - for (const image of queuedMessage.images ?? []) { - referencedIds.add(image.id); - } + for (const stream of session.agentStreamTail.values()) { + for (const item of stream) { + if (item.kind !== "user_message") { + continue; } + for (const image of item.images ?? []) { + referencedIds.add(image.id); + } } + } - for (const stream of session.agentStreamTail.values()) { - for (const item of stream) { - if (item.kind !== "user_message") { - continue; - } - for (const image of item.images ?? []) { - referencedIds.add(image.id); - } + for (const stream of session.agentStreamHead.values()) { + for (const item of stream) { + if (item.kind !== "user_message") { + continue; } - } - - for (const stream of session.agentStreamHead.values()) { - for (const item of stream) { - if (item.kind !== "user_message") { - continue; - } - for (const image of item.images ?? []) { - referencedIds.add(image.id); - } + for (const image of item.images ?? []) { + referencedIds.add(image.id); } } } - } catch (error) { - console.warn("[DraftStore] Failed to collect attachment GC references", error); } try {