refactor app draft status controls into input area
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user