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
@@ -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()
}
})
+12 -4
View File
@@ -379,7 +379,9 @@ export const createAgent = async (page: Page, message: string) => {
}; };
async function preferFastThinkingOption(page: Page): Promise<void> { async function preferFastThinkingOption(page: Page): Promise<void> {
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)) { if (await providerTrigger.isVisible().catch(() => false)) {
const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim(); const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim();
if (!/codex/i.test(providerText)) { 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.'); 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 ( if (
await providerTrigger await providerTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i')) .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.'); 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 ( if (
await modelTrigger await modelTrigger
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i')) .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) => { 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)) { if (await modeTrigger.isVisible().catch(() => false)) {
await modeTrigger.click(); await modeTrigger.click();
} else { } else {
@@ -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()
}
})
+3 -3
View File
@@ -7,19 +7,19 @@ let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> { async function createAttachmentStore(): Promise<AttachmentStore> {
if (Platform.OS === "web") { if (Platform.OS === "web") {
if (isTauriEnvironment()) { if (isTauriEnvironment()) {
const { createDesktopAttachmentStore } = await import( const { createDesktopAttachmentStore } = require(
"@/desktop/attachments/desktop-attachment-store" "@/desktop/attachments/desktop-attachment-store"
); );
return createDesktopAttachmentStore(); return createDesktopAttachmentStore();
} }
const { createIndexedDbAttachmentStore } = await import( const { createIndexedDbAttachmentStore } = require(
"@/attachments/web/indexeddb-attachment-store" "@/attachments/web/indexeddb-attachment-store"
); );
return createIndexedDbAttachmentStore(); return createIndexedDbAttachmentStore();
} }
const { createNativeFileAttachmentStore } = await import( const { createNativeFileAttachmentStore } = require(
"@/attachments/native/native-file-attachment-store" "@/attachments/native/native-file-attachment-store"
); );
return createNativeFileAttachmentStore(); return createNativeFileAttachmentStore();
@@ -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')
})
})
@@ -0,0 +1,5 @@
import type { DraftAgentStatusBarProps } from './agent-status-bar'
export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) {
return statusControls ? 'draft' : 'ready'
}
@@ -7,7 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useIsFocused } from '@react-navigation/native' import { useIsFocused } from '@react-navigation/native'
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout' import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
import { generateMessageId, type StreamItem } from '@/types/stream' 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 { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker'
import { useSessionStore } from '@/stores/session-store' import { useSessionStore } from '@/stores/session-store'
import { useDraftStore } from '@/stores/draft-store' import { useDraftStore } from '@/stores/draft-store'
@@ -34,6 +34,7 @@ import {
deleteAttachments, deleteAttachments,
persistAttachmentFromFileUri, persistAttachmentFromFileUri,
} from '@/attachments/service' } from '@/attachments/service'
import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls'
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard' import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation' import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
@@ -63,6 +64,8 @@ interface AgentInputAreaProps {
commandDraftConfig?: DraftCommandConfig commandDraftConfig?: DraftCommandConfig
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void onMessageSent?: () => void
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps
} }
const EMPTY_ARRAY: readonly QueuedMessage[] = [] const EMPTY_ARRAY: readonly QueuedMessage[] = []
@@ -80,6 +83,7 @@ export function AgentInputArea({
onAddImages, onAddImages,
commandDraftConfig, commandDraftConfig,
onMessageSent, onMessageSent,
statusControls,
}: AgentInputAreaProps) { }: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles() const { theme } = useUnistyles()
@@ -710,7 +714,12 @@ export function AgentInputArea({
</View> </View>
) )
const leftContent = <AgentStatusBar agentId={agentId} serverId={serverId} /> const leftContent =
resolveStatusControlMode(statusControls) === 'draft' && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
) : (
<AgentStatusBar agentId={agentId} serverId={serverId} />
)
return ( return (
<Animated.View <Animated.View
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { normalizeModelId, resolveAgentModelSelection } from './agent-status-bar.utils'
describe('normalizeModelId', () => {
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')
})
})
+458 -248
View File
@@ -1,162 +1,208 @@
import { View, Text, Platform, Pressable } from "react-native"; import { useMemo, useState } from 'react'
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { View, Text, Platform, Pressable } from 'react-native'
import { Brain, ChevronDown, SlidersHorizontal } from "lucide-react-native"; import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { useSessionStore } from "@/stores/session-store"; import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
import { useQuery } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from '@/components/ui/dropdown-menu'
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import { useMemo, useState } from "react"; import type {
import { useQuery } from "@tanstack/react-query"; 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<AgentModelDefinition['thinkingOptions']>
selectedThinkingOptionId: string
onSelectThinkingOption: (thinkingOptionId: string) => void
disabled?: boolean
}
interface AgentStatusBarProps { interface AgentStatusBarProps {
agentId: string; agentId: string
serverId: string; serverId: string
} }
function normalizeModelId(modelId: string | null | undefined): string | null { function findOptionLabel(options: StatusOption[] | undefined, selectedId: string | undefined, fallback: string) {
const normalized = typeof modelId === "string" ? modelId.trim() : ""; if (!options || options.length === 0) {
if (!normalized || normalized.toLowerCase() === "default") { return fallback
return null;
} }
return normalized; const selected = options.find((option) => option.id === selectedId)
return selected?.label ?? fallback
} }
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { function ControlledStatusBar({
const { theme } = useUnistyles(); providerOptions,
const IS_WEB = Platform.OS === "web"; selectedProviderId,
const [prefsOpen, setPrefsOpen] = useState(false); onSelectProvider,
const dropdownMaxWidth = IS_WEB ? 360 : undefined; 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 canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0)
const agent = useSessionStore((state) => const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0)
state.sessions[serverId]?.agents?.get(agentId) 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 = const hasAnyControl =
Boolean(client) && Boolean(agent?.provider) && (IS_WEB || prefsOpen); Boolean(providerOptions?.length) ||
const modelsQuery = useQuery({ Boolean(modeOptions?.length) ||
queryKey: [ canSelectModel ||
"providerModels", Boolean(thinkingOptions?.length)
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;
function handleModeChange(modeId: string) { if (!hasAnyControl) {
if (!client || !agent) { return null
return;
}
void client.setAgentMode(agentId, modeId).catch((error) => {
console.warn("[AgentStatusBar] setAgentMode failed", error);
});
} }
const normalizedRuntimeModelId = normalizeModelId(agent?.runtimeInfo?.model); const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0
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;
}
return ( return (
<View style={[styles.container, IS_WEB && { marginBottom: -theme.spacing[1] }]}> <View style={[styles.container, isWeb && { marginBottom: -theme.spacing[1] }]}>
{/* Agent Mode Badge (desktop only — on mobile, mode is in the preferences sheet) */} {isWeb ? (
{IS_WEB && agent.availableModes && agent.availableModes.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-mode-selector"
>
<Text style={styles.modeBadgeText}>
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
?.label ||
agent.currentModeId ||
"default"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-mode-menu"
>
{agent.availableModes.map((mode) => {
const isActive = mode.id === agent.currentModeId;
return (
<DropdownMenuItem
key={mode.id}
selected={isActive}
onSelect={() => handleModeChange(mode.id)}
>
{mode.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Desktop: inline dropdowns for model/thinking */}
{IS_WEB && (
<> <>
{providerOptions && providerOptions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
(disabled || !canSelectProvider) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-provider-selector"
>
<Text style={styles.modeBadgeText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-provider-menu"
>
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed, hovered, open }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-mode-selector"
>
<Text style={styles.modeBadgeText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="start"
maxWidth={dropdownMaxWidth}
testID="agent-mode-menu"
>
{modeOptions.map((mode) => (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
>
{mode.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed, hovered, open }) => [ style={({ pressed, hovered, open }) => [
styles.modeBadge, styles.modeBadge,
hovered && styles.modeBadgeHovered, hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed, (pressed || open) && styles.modeBadgePressed,
modelDisabled && styles.disabledBadge,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select agent model" accessibilityLabel="Select agent model"
@@ -171,35 +217,27 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
maxWidth={dropdownMaxWidth} maxWidth={dropdownMaxWidth}
testID="agent-model-menu" testID="agent-model-menu"
> >
{models?.map((model) => { {(modelOptions ?? []).map((model) => (
const isActive = model.id === activeModelId; <DropdownMenuItem
return ( key={model.id}
<DropdownMenuItem selected={model.id === selectedModelId}
key={model.id} onSelect={() => onSelectModel?.(model.id)}
selected={isActive} >
onSelect={() => { {model.label}
if (!client) { </DropdownMenuItem>
return; ))}
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{thinkingOptions && thinkingOptions.length > 1 && ( {thinkingOptions && thinkingOptions.length > 0 ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={({ pressed, hovered, open }) => [ style={({ pressed, hovered, open }) => [
styles.modeBadge, styles.modeBadge,
hovered && styles.modeBadgeHovered, hovered && styles.modeBadgeHovered,
(pressed || open) && styles.modeBadgePressed, (pressed || open) && styles.modeBadgePressed,
(disabled || !canSelectThinking) && styles.disabledBadge,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select thinking option" accessibilityLabel="Select thinking option"
@@ -219,35 +257,20 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
maxWidth={dropdownMaxWidth} maxWidth={dropdownMaxWidth}
testID="agent-thinking-menu" testID="agent-thinking-menu"
> >
{thinkingOptions.map((opt) => { {thinkingOptions.map((thinking) => (
const isActive = opt.id === selectedThinkingId; <DropdownMenuItem
return ( key={thinking.id}
<DropdownMenuItem selected={thinking.id === selectedThinkingOptionId}
key={opt.id} onSelect={() => onSelectThinkingOption?.(thinking.id)}
selected={isActive} >
onSelect={() => { {thinking.label}
if (!client) { </DropdownMenuItem>
return; ))}
}
void client
.setAgentThinkingOption(agentId, opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} ) : null}
</> </>
)} ) : (
{/* Mobile: preferences button opens a bottom sheet */}
{!IS_WEB && (
<> <>
<Pressable <Pressable
onPress={() => setPrefsOpen(true)} onPress={() => setPrefsOpen(true)}
@@ -268,13 +291,47 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
onClose={() => setPrefsOpen(false)} onClose={() => setPrefsOpen(false)}
testID="agent-preferences-sheet" testID="agent-preferences-sheet"
> >
{agent.availableModes && agent.availableModes.length > 0 && ( {providerOptions && providerOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectProvider}
style={({ pressed }) => [ style={({ pressed }) => [
styles.sheetSelect, styles.sheetSelect,
pressed && styles.sheetSelectPressed, pressed && styles.sheetSelectPressed,
(disabled || !canSelectProvider) && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel="Select agent provider"
testID="agent-preferences-provider"
>
<Text style={styles.sheetSelectText}>{displayProvider}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{providerOptions.map((provider) => (
<DropdownMenuItem
key={provider.id}
selected={provider.id === selectedProviderId}
onSelect={() => onSelectProvider?.(provider.id)}
>
{provider.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
(disabled || !canSelectMode) && styles.disabledSheetSelect,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select agent mode" accessibilityLabel="Select agent mode"
@@ -284,29 +341,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{agent.availableModes.map((mode) => { {modeOptions.map((mode) => (
const isActive = mode.id === agent.currentModeId; <DropdownMenuItem
return ( key={mode.id}
<DropdownMenuItem selected={mode.id === selectedModeId}
key={mode.id} onSelect={() => onSelectMode?.(mode.id)}
selected={isActive} >
onSelect={() => handleModeChange(mode.id)} {mode.label}
> </DropdownMenuItem>
{mode.label} ))}
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</View> </View>
)} ) : null}
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={modelDisabled}
style={({ pressed }) => [ style={({ pressed }) => [
styles.sheetSelect, styles.sheetSelect,
pressed && styles.sheetSelectPressed, pressed && styles.sheetSelectPressed,
modelDisabled && styles.disabledSheetSelect,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select agent model" accessibilityLabel="Select agent model"
@@ -316,36 +372,28 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{models?.map((model) => { {(modelOptions ?? []).map((model) => (
const isActive = model.id === activeModelId; <DropdownMenuItem
return ( key={model.id}
<DropdownMenuItem selected={model.id === selectedModelId}
key={model.id} onSelect={() => onSelectModel?.(model.id)}
selected={isActive} >
onSelect={() => { {model.label}
if (!client) { </DropdownMenuItem>
return; ))}
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</View> </View>
{thinkingOptions && thinkingOptions.length > 1 && ( {thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}> <View style={styles.sheetSection}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={({ pressed }) => [ style={({ pressed }) => [
styles.sheetSelect, styles.sheetSelect,
pressed && styles.sheetSelectPressed, pressed && styles.sheetSelectPressed,
(disabled || !canSelectThinking) && styles.disabledSheetSelect,
]} ]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="Select thinking option" accessibilityLabel="Select thinking option"
@@ -355,53 +403,209 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> <ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start"> <DropdownMenuContent side="top" align="start">
{thinkingOptions.map((opt) => { {thinkingOptions.map((thinking) => (
const isActive = opt.id === selectedThinkingId; <DropdownMenuItem
return ( key={thinking.id}
<DropdownMenuItem selected={thinking.id === selectedThinkingOptionId}
key={opt.id} onSelect={() => onSelectThinkingOption?.(thinking.id)}
selected={isActive} >
onSelect={() => { {thinking.label}
if (!client) { </DropdownMenuItem>
return; ))}
}
void client
.setAgentThinkingOption(agentId, opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</View> </View>
)} ) : null}
</AdaptiveModalSheet> </AdaptiveModalSheet>
</> </>
)} )}
</View> </View>
); )
}
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<StatusOption[]>(() => {
return (agent?.availableModes ?? []).map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [agent?.availableModes])
const modelOptions = useMemo<StatusOption[]>(() => {
return (models ?? []).map((model) => ({ id: model.id, label: model.label }))
}, [models])
const thinkingOptions = useMemo<StatusOption[]>(() => {
return (modelSelection.thinkingOptions ?? []).map((option) => ({
id: option.id,
label: option.label,
}))
}, [modelSelection.thinkingOptions])
if (!agent) {
return null
}
return (
<ControlledStatusBar
modeOptions={
modeOptions.length > 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<StatusOption[]>(() => {
return providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
}))
}, [providerDefinitions])
const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }]
}
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
}, [modeOptions])
const modelOptions = useMemo<StatusOption[]>(() => {
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<StatusOption[]>(() => {
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 (
<ControlledStatusBar
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => 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) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
modeBadge: { modeBadge: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
backgroundColor: "transparent", backgroundColor: 'transparent',
gap: theme.spacing[1], gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius["2xl"], borderRadius: theme.borderRadius['2xl'],
}, },
modeBadgeHovered: { modeBadgeHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -409,6 +613,9 @@ const styles = StyleSheet.create((theme) => ({
modeBadgePressed: { modeBadgePressed: {
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
disabledBadge: {
opacity: 0.5,
},
modeBadgeText: { modeBadgeText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -418,8 +625,8 @@ const styles = StyleSheet.create((theme) => ({
width: 34, width: 34,
height: 34, height: 34,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}, },
prefsButtonPressed: { prefsButtonPressed: {
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
@@ -428,9 +635,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
sheetSelect: { sheetSelect: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
justifyContent: "space-between", justifyContent: 'space-between',
gap: theme.spacing[3], gap: theme.spacing[3],
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
@@ -442,10 +649,13 @@ const styles = StyleSheet.create((theme) => ({
sheetSelectPressed: { sheetSelectPressed: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
disabledSheetSelect: {
opacity: 0.5,
},
sheetSelectText: { sheetSelectText: {
flex: 1, flex: 1,
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
})); }))
@@ -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,
}
}
@@ -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<TCreateResult> = {
agentId: string | null
result: TCreateResult
}
type SubmitContext = {
text: string
images?: UserMessageImageAttachment[]
}
type CreateRequestContext = {
attempt: CreateAttempt
text: string
images?: UserMessageImageAttachment[]
}
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
draftId: string
getPendingServerId: () => string | null
validateBeforeSubmit?: (ctx: SubmitContext) => string | null
onBeforeSubmit?: (ctx: CreateRequestContext) => void
onCreateStart?: () => void
createRequest: (ctx: CreateRequestContext) => Promise<CreateRequestResult<TCreateResult>>
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent
onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise<void> | void
onCreateError?: (error: Error) => void
}
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
validateBeforeSubmit,
onBeforeSubmit,
onCreateStart,
createRequest,
buildDraftAgent,
onCreateSuccess,
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
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<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<TDraftAgent | null>(() => {
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 }
@@ -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 { createNameId } from 'mnemonic-id'
import type { ImageAttachment } from '@/components/message-input' import type { ImageAttachment } from '@/components/message-input'
import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native' 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 { HeaderToggleButton } from '@/components/headers/header-toggle-button'
import { AgentInputArea } from '@/components/agent-input-area' import { AgentInputArea } from '@/components/agent-input-area'
import { AgentStreamView } from '@/components/agent-stream-view' 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 { ExplorerSidebar } from '@/components/explorer-sidebar'
import { Combobox } from '@/components/ui/combobox' import { Combobox } from '@/components/ui/combobox'
import { FileDropZone } from '@/components/file-drop-zone' 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 { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions'
import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture' import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
import { useSessionStore } from '@/stores/session-store' import { useSessionStore } from '@/stores/session-store'
import { useCreateFlowStore } from '@/stores/create-flow-store'
import { generateDraftId } from '@/stores/draft-keys' import { generateDraftId } from '@/stores/draft-keys'
import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime' import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime'
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' 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 { MAX_CONTENT_WIDTH } from '@/constants/layout'
import { WelcomeScreen } from '@/components/welcome-screen' import { WelcomeScreen } from '@/components/welcome-screen'
import type { Agent } from '@/contexts/session-context' import type { Agent } from '@/contexts/session-context'
import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from '@/types/stream'
import { encodeImages } from '@/utils/encode-images' import { encodeImages } from '@/utils/encode-images'
import type { import type {
AgentProvider, AgentProvider,
@@ -52,9 +50,9 @@ import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes'
import { useTauriDragHandlers } from '@/utils/tauri-window' import { useTauriDragHandlers } from '@/utils/tauri-window'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots' import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
const EMPTY_PENDING_PERMISSIONS = new Map() const EMPTY_PENDING_PERMISSIONS = new Map()
const EMPTY_STREAM_ITEMS: StreamItem[] = []
const MAX_INITIAL_AGENT_TITLE_CHARS = 60 const MAX_INITIAL_AGENT_TITLE_CHARS = 60
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
@@ -268,10 +266,6 @@ function DraftAgentScreenContent({
const worktreeAnchorRef = useRef<View>(null) const worktreeAnchorRef = useRef<View>(null)
const branchAnchorRef = useRef<View>(null) const branchAnchorRef = useRef<View>(null)
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(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(() => { useEffect(() => {
const trimmed = branchSearchQuery.trim() const trimmed = branchSearchQuery.trim()
@@ -285,58 +279,6 @@ function DraftAgentScreenContent({
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [workingDirSearchQuery]) }, [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[]) => { const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files) addImagesRef.current?.(files)
}, []) }, [])
@@ -830,79 +772,179 @@ function DraftAgentScreenContent({
workingDir, workingDir,
]) ])
const promptValue = machine.tag === 'draft' ? machine.promptText : '' const {
const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : '' promptValue,
const isSubmitting = machine.tag === 'creating' formErrorMessage,
isSubmitting,
const optimisticStreamItems = useMemo<StreamItem[]>(() => { optimisticStreamItems,
if (machine.tag !== 'creating') { draftAgent,
return EMPTY_STREAM_ITEMS setPromptText,
} handleCreateFromInput,
return [ } = useDraftAgentCreateFlow<Agent, { id: string; cwd: string }>({
{ draftId: draftIdRef.current,
kind: 'user_message', getPendingServerId: () => selectedServerId,
id: machine.attempt.clientMessageId, validateBeforeSubmit: ({ text }) => {
text: machine.attempt.text, const trimmedPath = workingDir.trim()
timestamp: machine.attempt.timestamp, if (!trimmedPath) {
...(machine.attempt.images && machine.attempt.images.length > 0 return 'Working directory is required'
? { images: machine.attempt.images } }
: {}), if (isDirectoryNotExists) {
}, return 'Working directory does not exist on the selected host'
] }
}, [machine]) if (!text.trim()) {
return 'Initial prompt is required'
const draftAgent = useMemo<Agent | null>(() => { }
if (machine.tag !== 'creating') { 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 return null
} },
const serverId = selectedServerId ?? '' onBeforeSubmit: () => {
const now = machine.attempt.timestamp void persistFormPreferences()
const cwd = if (Platform.OS === 'web') {
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || '.' ;(document.activeElement as HTMLElement | null)?.blur?.()
const provider = selectedProvider }
const model = selectedModel.trim() || null Keyboard.dismiss()
const thinkingOptionId = selectedThinkingOptionId.trim() || null },
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : null 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 { return {
serverId, serverId,
id: draftAgentIdRef.current, id: draftAgentIdRef.current,
provider,
status: 'running',
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: DRAFT_CAPABILITIES,
currentModeId: modeId,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider, 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, model,
modeId, thinkingOptionId,
}, labels: {},
title: 'New agent', }
cwd, },
model, createRequest: async ({ attempt, text, images }) => {
thinkingOptionId, const trimmedPath = workingDir.trim()
labels: {}, const resolvedWorkingDir =
} isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath
}, [
machine.tag, const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
machine.tag === 'creating' ? machine.attempt.timestamp : null, const trimmedModel = selectedModel.trim()
isAttachWorktree, const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
modeOptions.length, const derivedTitle = deriveInitialAgentTitle(text)
selectedMode, const config: AgentSessionConfig = {
selectedModel, provider: selectedProvider,
selectedThinkingOptionId, cwd: resolvedWorkingDir,
selectedProvider, ...(derivedTitle ? { title: derivedTitle } : {}),
selectedServerId, ...(modeId ? { modeId } : {}),
selectedWorktreePath, ...(trimmedModel ? { model: trimmedModel } : {}),
workingDir, ...(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(() => { useEffect(() => {
if (!isFocused) { if (!isFocused) {
return return
@@ -927,190 +969,6 @@ function DraftAgentScreenContent({
} }
}, [setActiveExplorerCheckout]) }, [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) { if (daemons.length === 0) {
return ( return (
<WelcomeScreen <WelcomeScreen
@@ -1157,7 +1015,7 @@ function DraftAgentScreenContent({
</View> </View>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}> <Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
{machine.tag === 'creating' && draftAgent && selectedServerId ? ( {isSubmitting && draftAgent && selectedServerId ? (
<View style={styles.streamContainer}> <View style={styles.streamContainer}>
<AgentStreamView <AgentStreamView
agentId={draftAgentIdRef.current} agentId={draftAgentIdRef.current}
@@ -1194,22 +1052,6 @@ function DraftAgentScreenContent({
</Text> </Text>
</View> </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 ? ( {isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<View style={styles.formSeparator} /> <View style={styles.formSeparator} />
) : null} ) : null}
@@ -1352,11 +1194,27 @@ function DraftAgentScreenContent({
isSubmitLoading={isSubmitting} isSubmitLoading={isSubmitting}
blurOnSubmit={true} blurOnSubmit={true}
value={promptValue} value={promptValue}
onChangeText={(next) => dispatch({ type: 'DRAFT_SET_PROMPT', text: next })} onChangeText={setPromptText}
autoFocus={machine.tag === 'draft'} autoFocus={!isSubmitting}
onAddImages={handleAddImagesCallback} onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig} commandDraftConfig={draftCommandConfig}
draftId={draftIdRef.current} 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>
</View> </View>
@@ -1,23 +1,20 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from 'react'
import { Keyboard, Platform, ScrollView, Text, View } from "react-native"; import { Keyboard, Platform, ScrollView, Text, View } from 'react-native'
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from 'react-native-unistyles'
import { AgentInputArea } from "@/components/agent-input-area"; import { AgentInputArea } from '@/components/agent-input-area'
import { AgentConfigRow } from "@/components/agent-form/agent-form-dropdowns"; import { FileDropZone } from '@/components/file-drop-zone'
import { FileDropZone } from "@/components/file-drop-zone"; import { AgentStreamView } from '@/components/agent-stream-view'
import { AgentStreamView } from "@/components/agent-stream-view"; import type { ImageAttachment } from '@/components/message-input'
import type { ImageAttachment } from "@/components/message-input"; import { MAX_CONTENT_WIDTH } from '@/constants/layout'
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { useAgentFormState } from '@/hooks/use-agent-form-state'
import { useAgentFormState } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from '@/runtime/host-runtime'
import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { Agent } from '@/stores/session-store'
import type { Agent } from "@/stores/session-store"; import { encodeImages } from '@/utils/encode-images'
import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from "@/types/stream"; import type { AgentCapabilityFlags, AgentSessionConfig } from '@server/server/agent/agent-sdk-types'
import { encodeImages } from "@/utils/encode-images"; import type { AgentSnapshotPayload } from '@server/shared/messages'
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_PENDING_PERMISSIONS = new Map()
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: false, supportsSessionPersistence: false,
@@ -25,35 +22,14 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false, supportsMcpServers: false,
supportsReasoningStream: false, supportsReasoningStream: false,
supportsToolInvocations: false, supportsToolInvocations: false,
}; }
type WorkspaceDraftAgentTabProps = { type WorkspaceDraftAgentTabProps = {
serverId: string; serverId: string
workspaceId: string; workspaceId: string
tabId: string; tabId: string
draftId: string; draftId: string
onCreated: (snapshot: AgentSnapshotPayload) => void; 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)}`);
} }
export function WorkspaceDraftAgentTab({ export function WorkspaceDraftAgentTab({
@@ -63,13 +39,8 @@ export function WorkspaceDraftAgentTab({
draftId, draftId,
onCreated, onCreated,
}: WorkspaceDraftAgentTabProps) { }: WorkspaceDraftAgentTabProps) {
const { client, isConnected } = useHostRuntimeSession(serverId); const { client, isConnected } = useHostRuntimeSession(serverId)
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(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);
const { const {
selectedProvider, selectedProvider,
@@ -94,220 +65,117 @@ export function WorkspaceDraftAgentTab({
isVisible: true, isVisible: true,
isCreateFlow: true, isCreateFlow: true,
onlineServerIds: isConnected ? [serverId] : [], onlineServerIds: isConnected ? [serverId] : [],
}); })
// Lock working directory to workspace. // Lock working directory to workspace.
useEffect(() => { useEffect(() => {
if (workingDir.trim() === workspaceId.trim()) { if (workingDir.trim() === workspaceId.trim()) {
return; return
} }
setWorkingDir(workspaceId); setWorkingDir(workspaceId)
}, [setWorkingDir, workingDir, workspaceId]); }, [setWorkingDir, workingDir, workspaceId])
const [machine, dispatch] = useReducer( const {
(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState => { promptValue,
switch (event.type) { formErrorMessage,
case "DRAFT_SET_PROMPT": { isSubmitting,
if (state.tag !== "draft") { optimisticStreamItems,
return state; draftAgent,
} setPromptText,
return { ...state, promptText: event.text }; handleCreateFromInput,
} } = useDraftAgentCreateFlow<Agent, AgentSnapshotPayload>({
case "DRAFT_SET_ERROR": { draftId,
if (state.tag !== "draft") { getPendingServerId: () => serverId,
return state; validateBeforeSubmit: ({ text }) => {
} if (!text.trim()) {
return { ...state, errorMessage: event.message }; return 'Initial prompt is required'
}
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");
} }
if (providerDefinitions.length === 0) { if (providerDefinitions.length === 0) {
dispatch({ return 'No available providers on the selected host'
type: "DRAFT_SET_ERROR",
message: "No available providers on the selected host",
});
throw new Error("No available providers on the selected host");
} }
if (!client) { if (!client) {
dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" }); return 'Host is not connected'
throw new Error("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 = { const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
clientMessageId: generateMessageId(), const trimmedModel = selectedModel.trim()
text: trimmedPrompt, const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
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 config: AgentSessionConfig = { const config: AgentSessionConfig = {
provider: selectedProvider, provider: selectedProvider,
cwd: workspaceId, cwd: workspaceId,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}), ...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}), ...(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 imagesData = await encodeImages(images); const result = await client.createAgent({
const result = await client.createAgent({ config,
config, initialPrompt: text,
initialPrompt: trimmedPrompt, clientMessageId: attempt.clientMessageId,
clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), })
});
const agentId = result.id; return {
updatePendingAgentId({ draftId, agentId }); agentId: result.id,
result,
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;
} }
}, },
[ onCreateSuccess: ({ result }) => {
clearPendingCreateAttempt, onCreated(result)
client, },
draftId, })
isSubmitting,
markPendingCreateLifecycle,
modeOptions.length,
onCreated,
persistFormPreferences,
providerDefinitions.length,
selectedMode,
selectedModel,
selectedProvider,
selectedThinkingOptionId,
serverId,
setPendingCreateAttempt,
updatePendingAgentId,
workspaceId,
]
);
const draftCommandConfig = useMemo(() => { const draftCommandConfig = useMemo(() => {
return { return {
provider: selectedProvider, provider: selectedProvider,
cwd: workspaceId, cwd: workspaceId,
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), ...(modeOptions.length > 0 && selectedMode !== '' ? { modeId: selectedMode } : {}),
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), ...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
...(selectedThinkingOptionId.trim() ...(selectedThinkingOptionId.trim()
? { thinkingOptionId: selectedThinkingOptionId.trim() } ? { thinkingOptionId: selectedThinkingOptionId.trim() }
: {}), : {}),
}; }
}, [ }, [
modeOptions.length, modeOptions.length,
selectedMode, selectedMode,
@@ -315,21 +183,21 @@ export function WorkspaceDraftAgentTab({
selectedProvider, selectedProvider,
selectedThinkingOptionId, selectedThinkingOptionId,
workspaceId, workspaceId,
]); ])
const handleFilesDropped = useCallback((files: ImageAttachment[]) => { const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files); addImagesRef.current?.(files)
}, []); }, [])
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages; addImagesRef.current = addImages
}, []); }, [])
return ( return (
<FileDropZone onFilesDropped={handleFilesDropped}> <FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}> <View style={styles.container}>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
{machine.tag === "creating" && draftAgent ? ( {isSubmitting && draftAgent ? (
<View style={styles.streamContainer}> <View style={styles.streamContainer}>
<AgentStreamView <AgentStreamView
agentId={tabId} agentId={tabId}
@@ -342,23 +210,6 @@ export function WorkspaceDraftAgentTab({
) : ( ) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}> <ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}> <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 ? ( {formErrorMessage ? (
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text> <Text style={styles.errorText}>{formErrorMessage}</Text>
@@ -377,23 +228,39 @@ export function WorkspaceDraftAgentTab({
isSubmitLoading={isSubmitting} isSubmitLoading={isSubmitting}
blurOnSubmit={true} blurOnSubmit={true}
value={promptValue} value={promptValue}
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })} onChangeText={setPromptText}
autoFocus={machine.tag === "draft"} autoFocus={!isSubmitting}
onAddImages={handleAddImagesCallback} onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig} commandDraftConfig={draftCommandConfig}
draftId={draftId} 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>
</View> </View>
</FileDropZone> </FileDropZone>
); )
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flex: 1, flex: 1,
width: "100%", width: '100%',
alignSelf: "center", alignSelf: 'center',
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
@@ -415,7 +282,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
inputAreaWrapper: { inputAreaWrapper: {
width: "100%", width: '100%',
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
errorContainer: { errorContainer: {
@@ -430,4 +297,4 @@ const styles = StyleSheet.create((theme) => ({
errorText: { errorText: {
color: theme.colors.destructive, color: theme.colors.destructive,
}, },
})); }))
+32 -39
View File
@@ -7,6 +7,8 @@ import {
persistAttachmentFromDataUrl, persistAttachmentFromDataUrl,
persistAttachmentFromFileUri, persistAttachmentFromFileUri,
} from "@/attachments/service"; } from "@/attachments/service";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
const DRAFT_STORE_VERSION = 2; const DRAFT_STORE_VERSION = 2;
const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
@@ -245,56 +247,47 @@ async function runAttachmentGc(): Promise<void> {
referencedIds.add(id); referencedIds.add(id);
} }
try { const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId;
const [{ useCreateFlowStore }, { useSessionStore }] = await Promise.all([ for (const pendingCreate of Object.values(pendingByDraftId)) {
import("@/stores/create-flow-store"), if (pendingCreate.lifecycle !== "active" || !pendingCreate.images) {
import("@/stores/session-store"), continue;
]); }
for (const image of pendingCreate.images) {
referencedIds.add(image.id);
}
}
const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId; const sessions = useSessionStore.getState().sessions;
for (const pendingCreate of Object.values(pendingByDraftId)) { for (const session of Object.values(sessions)) {
if (pendingCreate.lifecycle !== "active" || !pendingCreate.images) { for (const queue of session.queuedMessages.values()) {
continue; for (const queuedMessage of queue) {
} for (const image of queuedMessage.images ?? []) {
for (const image of pendingCreate.images) { referencedIds.add(image.id);
referencedIds.add(image.id); }
} }
} }
const sessions = useSessionStore.getState().sessions; for (const stream of session.agentStreamTail.values()) {
for (const session of Object.values(sessions)) { for (const item of stream) {
for (const queue of session.queuedMessages.values()) { if (item.kind !== "user_message") {
for (const queuedMessage of queue) { continue;
for (const image of queuedMessage.images ?? []) {
referencedIds.add(image.id);
}
} }
for (const image of item.images ?? []) {
referencedIds.add(image.id);
}
} }
}
for (const stream of session.agentStreamTail.values()) { for (const stream of session.agentStreamHead.values()) {
for (const item of stream) { for (const item of stream) {
if (item.kind !== "user_message") { if (item.kind !== "user_message") {
continue; continue;
}
for (const image of item.images ?? []) {
referencedIds.add(image.id);
}
} }
} 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 image of item.images ?? []) {
referencedIds.add(image.id);
}
} }
} }
} }
} catch (error) {
console.warn("[DraftStore] Failed to collect attachment GC references", error);
} }
try { try {