diff --git a/apps/mobile/src/app/(app)/agent-chat/new.tsx b/apps/mobile/src/app/(app)/agent-chat/new.tsx index 023ec8f4a1..099ae0cf3c 100644 --- a/apps/mobile/src/app/(app)/agent-chat/new.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/new.tsx @@ -11,6 +11,7 @@ import { useNewSessionModelState, } from '@/components/agents/new-session-model-provider'; import { pickAgentAttachments } from '@/components/agents/attachment-picker'; +import { useNewSessionPrefillTargets } from '@/components/agents/use-new-session-prefill'; import { ScreenHeader } from '@/components/screen-header'; import { AGENT_ATTACHMENT_MAX_FILES } from '@/lib/agent-attachments/constants'; import { useAgentAttachmentUpload } from '@/lib/agent-attachments/use-agent-attachment-upload'; @@ -44,7 +45,6 @@ function NewSessionScreenBody() { }>(); const shareId: string | undefined = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam; - const [selectedRepo, setSelectedRepo] = useState(''); const [runOnInstance, setRunOnInstance] = useState(null); const [isCreating, setIsCreating] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -73,6 +73,13 @@ function NewSessionScreenBody() { refreshReposForceFresh, } = useNewSessionRepos({ organizationId }); + const { selectedRepo, setSelectedRepo } = useNewSessionPrefillTargets({ + repositories, + reposSettled: view === 'repos' && repositories.length > 0, + models, + modelsSettled: !isLoadingModels && !isModelsError && models.length > 0, + }); + // Keep the inline selector and picker list in sync. const { data: instancesData, diff --git a/apps/mobile/src/components/agents/new-session-model-provider.tsx b/apps/mobile/src/components/agents/new-session-model-provider.tsx index 012057d321..92e135fde1 100644 --- a/apps/mobile/src/components/agents/new-session-model-provider.tsx +++ b/apps/mobile/src/components/agents/new-session-model-provider.tsx @@ -10,6 +10,8 @@ import { } from 'react'; import { type AgentMode } from '@/components/agents/mode-selector'; +import { resolvePrefillModel } from '@/components/agents/new-session-prefill'; +import { useNewSessionPrefill } from '@/components/agents/use-new-session-prefill'; import { RemoteSpawnInheritanceProvider } from '@/components/agents/use-remote-spawn-dispatch'; import { useAvailableModels } from '@/lib/hooks/use-available-models'; import { useAutoSelectModel } from '@/lib/hooks/use-auto-select-model'; @@ -42,16 +44,18 @@ export function NewSessionModelProvider({ organizationId, children, }: Readonly) { - const [mode, setMode] = useState('code'); + const prefill = useNewSessionPrefill(); + const [mode, setMode] = useState(prefill.mode); const [model, setModel] = useState(''); const [variant, setVariant] = useState(''); const { models } = useAvailableModels(organizationId); const autoSelected = useAutoSelectModel(models, organizationId); const hasAppliedAutoSelection = useRef(false); - if (!hasAppliedAutoSelection.current && autoSelected.model && !model) { + const initialSelection = resolvePrefillModel(models, prefill) ?? autoSelected; + if (!hasAppliedAutoSelection.current && initialSelection.model && !model) { hasAppliedAutoSelection.current = true; - setModel(autoSelected.model); - setVariant(autoSelected.variant); + setModel(initialSelection.model); + setVariant(initialSelection.variant); } const state = useMemo( () => ({ mode, setMode, model, setModel, variant, setVariant }), diff --git a/apps/mobile/src/components/agents/new-session-prefill.test.ts b/apps/mobile/src/components/agents/new-session-prefill.test.ts new file mode 100644 index 0000000000..04c058be12 --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-prefill.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + appendNewSessionPrefill, + buildContinuePrefillParams, + describePrefillFallback, + type NewSessionPrefill, + readNewSessionPrefill, + resolvePrefillModel, + resolvePrefillRepo, +} from './new-session-prefill'; + +vi.mock('lucide-react-native', () => ({ + Bug: 'Bug', + Code: 'Code', + HelpCircle: 'HelpCircle', + NotebookPen: 'NotebookPen', + Workflow: 'Workflow', +})); + +// ════════════════════════════════════════════════════════════════ +// buildContinuePrefillParams +// ════════════════════════════════════════════════════════════════ + +describe('buildContinuePrefillParams', () => { + it.each([ + ['https://github.com/Kilo-Org/cloud.git', 'Kilo-Org/cloud'], + ['git@github.com:Kilo-Org/cloud.git', 'Kilo-Org/cloud'], + ['https://github.com/owner/repo.git', 'owner/repo'], + ['https://GitHub.com/owner/repo', 'owner/repo'], + ['git@GitHub.com:owner/repo.git', 'owner/repo'], + ])('extracts owner/repo from %s', (gitUrl, expected) => { + const params = buildContinuePrefillParams({ gitUrl, mode: 'code', model: '', variant: '' }); + expect(params.repo).toBe(expected); + expect(params.mode).toBe('code'); + }); + + it.each([ + { gitUrl: null as string | null, desc: 'null' }, + { gitUrl: 'https://github.com/group/sub/repo', desc: 'too many segments' }, + { gitUrl: 'https://gitlab.com/owner/repo.git', desc: 'non-GitHub HTTPS' }, + { gitUrl: 'git@gitlab.com:owner/repo.git', desc: 'non-GitHub scp-style' }, + { gitUrl: 'https://git.example.com/owner/repo.git', desc: 'self-hosted HTTPS' }, + { gitUrl: 'git@git.example.com:owner/repo.git', desc: 'self-hosted scp-style' }, + ])('omits repo when gitUrl is $desc', ({ gitUrl }) => { + const params = buildContinuePrefillParams({ gitUrl, mode: 'code', model: '', variant: '' }); + expect(params.repo).toBeUndefined(); + }); + + it('omits empty mode/model/variant', () => { + const params = buildContinuePrefillParams({ + gitUrl: 'https://github.com/owner/repo', + mode: '', + model: '', + variant: '', + }); + expect(params.repo).toBe('owner/repo'); + expect(params.mode).toBeUndefined(); + expect(params.model).toBeUndefined(); + expect(params.variant).toBeUndefined(); + }); + + it('includes model and variant when non-empty', () => { + const params = buildContinuePrefillParams({ + gitUrl: 'https://github.com/owner/repo', + mode: 'plan', + model: 'anthropic/claude-sonnet-4', + variant: 'thinking', + }); + expect(params.repo).toBe('owner/repo'); + expect(params.mode).toBe('plan'); + expect(params.model).toBe('anthropic/claude-sonnet-4'); + expect(params.variant).toBe('thinking'); + }); +}); + +// ════════════════════════════════════════════════════════════════ +// appendNewSessionPrefill +// ════════════════════════════════════════════════════════════════ + +describe('appendNewSessionPrefill', () => { + it('uses ? when base has no query string', () => { + const result = appendNewSessionPrefill('/agent-chat/new', { repo: 'owner/repo' }); + expect(result).toBe('/agent-chat/new?prefillRepo=owner%2Frepo'); + }); + + it('uses & when base already has a query string', () => { + const result = appendNewSessionPrefill('/agent-chat/new?organizationId=org1', { + repo: 'owner/repo', + }); + expect(result).toBe('/agent-chat/new?organizationId=org1&prefillRepo=owner%2Frepo'); + }); + + it('encodes values with special characters', () => { + const result = appendNewSessionPrefill('/agent-chat/new', { + model: 'anthropic/claude-sonnet-4', + variant: 'xhigh', + }); + expect(result).toBe( + '/agent-chat/new?prefillModel=anthropic%2Fclaude-sonnet-4&prefillVariant=xhigh' + ); + }); + + it.each([{ repo: '', mode: '', desc: 'empty strings' }, { desc: 'no params' }])( + 'returns base unchanged with $desc', + params => { + const result = appendNewSessionPrefill('/agent-chat/new', params); + expect(result).toBe('/agent-chat/new'); + } + ); + + it('appends only non-empty params', () => { + const result = appendNewSessionPrefill('/agent-chat/new', { + repo: 'owner/repo', + mode: '', + model: 'kilo-auto/efficient', + }); + expect(result).toBe( + '/agent-chat/new?prefillRepo=owner%2Frepo&prefillModel=kilo-auto%2Fefficient' + ); + }); +}); + +// ════════════════════════════════════════════════════════════════ +// readNewSessionPrefill +// ════════════════════════════════════════════════════════════════ + +describe('readNewSessionPrefill', () => { + it('reads string params', () => { + const prefill = readNewSessionPrefill({ + prefillRepo: 'owner/repo', + prefillMode: 'plan', + prefillModel: 'anthropic/claude-sonnet-4', + prefillVariant: 'thinking', + }); + expect(prefill).toEqual({ + mode: 'plan', + repo: 'owner/repo', + model: 'anthropic/claude-sonnet-4', + variant: 'thinking', + }); + }); + + it('takes first element of array-valued params', () => { + const prefill = readNewSessionPrefill({ + prefillRepo: ['owner/repo', 'other/repo'], + prefillMode: ['plan'], + }); + expect(prefill).toEqual({ mode: 'plan', repo: 'owner/repo' }); + }); + + it.each([ + ['unknown', 'code'], + ['architect', 'plan'], + ['build', 'code'], + ])('normalizes mode "%s" to "%s"', (input, expected) => { + const prefill = readNewSessionPrefill({ prefillMode: input }); + expect(prefill.mode).toBe(expected); + }); + + it('defaults mode to code when missing', () => { + const prefill = readNewSessionPrefill({}); + expect(prefill.mode).toBe('code'); + expect(prefill.repo).toBeUndefined(); + }); + + it('treats empty strings as absent', () => { + const prefill = readNewSessionPrefill({ + prefillRepo: '', + prefillMode: 'debug', + prefillModel: '', + }); + expect(prefill).toEqual({ mode: 'debug' }); + }); + + it('handles undefined params', () => { + const prefill = readNewSessionPrefill({ prefillRepo: undefined, prefillMode: undefined }); + expect(prefill).toEqual({ mode: 'code' }); + }); +}); + +// ════════════════════════════════════════════════════════════════ +// resolvePrefillModel +// ════════════════════════════════════════════════════════════════ + +describe('resolvePrefillModel', () => { + const catalog = [ + { id: 'anthropic/claude-sonnet-4', variants: ['default', 'thinking'] }, + { id: 'kilo-auto/efficient', variants: [] }, + ]; + + it('matches exactly and keeps requested variant', () => { + const result = resolvePrefillModel(catalog, { + mode: 'code', + model: 'anthropic/claude-sonnet-4', + variant: 'thinking', + }); + expect(result).toEqual({ model: 'anthropic/claude-sonnet-4', variant: 'thinking' }); + }); + + it('falls back to first variant when requested variant is unsupported', () => { + const result = resolvePrefillModel(catalog, { + mode: 'code', + model: 'anthropic/claude-sonnet-4', + variant: 'xhigh', + }); + expect(result).toEqual({ model: 'anthropic/claude-sonnet-4', variant: 'default' }); + }); + + it('falls back to empty string variant when no variants exist', () => { + const result = resolvePrefillModel(catalog, { + mode: 'code', + model: 'kilo-auto/efficient', + variant: 'thinking', + }); + expect(result).toEqual({ model: 'kilo-auto/efficient', variant: '' }); + }); + + it.each([ + { model: 'unknown/model' }, + { model: 'anthropic/claude-sonnet-4', catOverride: [] as { id: string; variants: string[] }[] }, + { model: undefined }, + { model: '' }, + ])('returns null when model="%s"', ({ model, catOverride }) => { + const result = resolvePrefillModel(catOverride ?? catalog, { mode: 'code', model }); + expect(result).toBeNull(); + }); +}); + +// ════════════════════════════════════════════════════════════════ +// resolvePrefillRepo +// ════════════════════════════════════════════════════════════════ + +describe('resolvePrefillRepo', () => { + const repos = [{ fullName: 'Kilo-Org/cloud' }, { fullName: 'kilo-org/mobile' }]; + + it('matches case-insensitively and returns canonical casing', () => { + const result = resolvePrefillRepo(repos, { mode: 'code', repo: 'kilo-org/cloud' }); + expect(result).toBe('Kilo-Org/cloud'); + }); + + it.each([ + { repo: 'other/repo', reposOverride: undefined, desc: 'no match' }, + { repo: 'Kilo-Org/cloud', reposOverride: [] as { fullName: string }[], desc: 'empty list' }, + { repo: undefined, reposOverride: undefined, desc: 'absent' }, + { repo: '', reposOverride: undefined, desc: 'empty string' }, + ])('returns null when repo is $desc', ({ repo, reposOverride }) => { + const result = resolvePrefillRepo(reposOverride ?? repos, { mode: 'code', repo }); + expect(result).toBeNull(); + }); +}); + +// ════════════════════════════════════════════════════════════════ +// describePrefillFallback +// ════════════════════════════════════════════════════════════════ + +describe('describePrefillFallback', () => { + const settled = { settled: true, matched: false }; + const matched = { settled: true, matched: true }; + const unsettled = { settled: false, matched: false }; + + it('returns null when nothing requested (both sides unsettled)', () => { + const note = describePrefillFallback({ + prefill: { mode: 'code' }, + repos: unsettled, + models: unsettled, + }); + expect(note).toBeNull(); + }); + + it.each([ + { + desc: 'repo unmatched, model not requested', + prefill: { mode: 'code', repo: 'owner/repo' } satisfies NewSessionPrefill, + repos: settled, + models: unsettled, + expected: 'owner/repo is no longer available. Pick a repository below.', + }, + { + desc: 'model unmatched, repo not requested', + prefill: { mode: 'code', model: 'anthropic/claude-sonnet-4' } satisfies NewSessionPrefill, + repos: unsettled, + models: settled, + expected: 'anthropic/claude-sonnet-4 is no longer available. Using your default model.', + }, + ])('returns per-field message when $desc', ({ prefill, repos, models, expected }) => { + expect(describePrefillFallback({ prefill, repos, models })).toBe(expected); + }); + + it.each([ + { + desc: 'both requested, only repos settled', + prefill: { + mode: 'code', + repo: 'owner/repo', + model: 'anthropic/claude-sonnet-4', + } satisfies NewSessionPrefill, + repos: settled, + models: unsettled, + }, + { + desc: 'both requested, only models settled', + prefill: { + mode: 'code', + repo: 'owner/repo', + model: 'anthropic/claude-sonnet-4', + } satisfies NewSessionPrefill, + repos: unsettled, + models: settled, + }, + { + desc: 'both matched', + prefill: { + mode: 'code', + repo: 'owner/repo', + model: 'anthropic/claude-sonnet-4', + } satisfies NewSessionPrefill, + repos: matched, + models: matched, + }, + { + desc: 'only repo requested, matched', + prefill: { mode: 'code', repo: 'owner/repo' } satisfies NewSessionPrefill, + repos: matched, + models: unsettled, + }, + { + desc: 'only model requested, matched', + prefill: { mode: 'code', model: 'anthropic/claude-sonnet-4' } satisfies NewSessionPrefill, + repos: unsettled, + models: matched, + }, + ])('returns null when $desc', ({ prefill, repos, models }) => { + expect(describePrefillFallback({ prefill, repos, models })).toBeNull(); + }); + + it('returns combined message when both requested + settled + both unmatched', () => { + const note = describePrefillFallback({ + prefill: { mode: 'code', repo: 'owner/repo', model: 'anthropic/claude-sonnet-4' }, + repos: settled, + models: settled, + }); + expect(note).toBe( + "The original session's repository and model are no longer available. Pick them below." + ); + }); +}); diff --git a/apps/mobile/src/components/agents/new-session-prefill.ts b/apps/mobile/src/components/agents/new-session-prefill.ts new file mode 100644 index 0000000000..d464b0e0ef --- /dev/null +++ b/apps/mobile/src/components/agents/new-session-prefill.ts @@ -0,0 +1,220 @@ +import { type AgentMode } from '@/components/agents/mode-selector'; +import { normalizeAgentMode } from '@/components/agents/mode-options'; +import { formatGitUrlProject } from '@/components/agents/session-list-helpers'; + +export type NewSessionPrefillParams = { + repo?: string; + mode?: string; + model?: string; + variant?: string; +}; + +export type NewSessionPrefill = { + /** Always a valid mode; defaults to 'code'. */ + mode: AgentMode; + repo?: string; + model?: string; + variant?: string; +}; + +function isValidOwnerRepo(value: string): boolean { + const segments = value.split('/').filter(Boolean); + return segments.length === 2; +} + +function isGitHubUrl(gitUrl: string): boolean { + // SCP-style: git@github.com:owner/repo.git (case-insensitive host) + if (/^git@github\.com:/i.test(gitUrl)) { + return true; + } + // HTTPS-style: https://github.com/owner/repo.git + try { + const url = new URL(gitUrl); + return url.hostname.toLowerCase() === 'github.com'; + } catch { + return false; + } +} + +/** + * Build query-param prefill values from a session's displayed targets. + * Each field is included only when non-empty; repo is included only when + * it reduces to exactly two non-empty `/` segments. + */ +export function buildContinuePrefillParams(input: { + gitUrl: string | null | undefined; + mode: string; + model: string; + variant: string; +}): NewSessionPrefillParams { + const params: NewSessionPrefillParams = {}; + + if (input.gitUrl) { + const project = formatGitUrlProject(input.gitUrl); + if (isGitHubUrl(input.gitUrl) && isValidOwnerRepo(project)) { + params.repo = project; + } + } + if (input.mode) { + params.mode = input.mode; + } + if (input.model) { + params.model = input.model; + } + if (input.variant) { + params.variant = input.variant; + } + + return params; +} + +/** + * Append prefill query params to a route path. Mirrors the existing + * `appendShareId` pattern: `?` when the base has no query string, `&` + * otherwise, values through `encodeURIComponent`. Returns `base` unchanged + * when there is nothing to append. + */ +export function appendNewSessionPrefill(base: string, params: NewSessionPrefillParams): string { + const entries: [string, string][] = []; + + if (params.repo) { + entries.push(['prefillRepo', params.repo]); + } + if (params.mode) { + entries.push(['prefillMode', params.mode]); + } + if (params.model) { + entries.push(['prefillModel', params.model]); + } + if (params.variant) { + entries.push(['prefillVariant', params.variant]); + } + + if (entries.length === 0) { + return base; + } + + const separator = base.includes('?') ? '&' : '?'; + const query = entries.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&'); + return `${base}${separator}${query}`; +} + +function getFirstParam(record: Record, key: string): string { + const val = record[key]; + if (Array.isArray(val)) { + return val[0] ?? ''; + } + return val ?? ''; +} + +/** + * Read prefill values from route search params. Each param can be + * `string | string[]` — take the first element of an array. Mode is + * always normalized to a valid `AgentMode`. Empty strings are treated + * as absent. + */ +export function readNewSessionPrefill( + raw: Record +): NewSessionPrefill { + const rawMode = getFirstParam(raw, 'prefillMode'); + const rawRepo = getFirstParam(raw, 'prefillRepo'); + const rawModel = getFirstParam(raw, 'prefillModel'); + const rawVariant = getFirstParam(raw, 'prefillVariant'); + + return { + mode: normalizeAgentMode(rawMode || undefined), + ...(rawRepo ? { repo: rawRepo } : {}), + ...(rawModel ? { model: rawModel } : {}), + ...(rawVariant ? { variant: rawVariant } : {}), + }; +} + +/** + * Resolve a prefill model against the loaded catalog. + * Returns `null` when `prefill.model` is absent or no entry matches + * (this also covers the still-loading case where models is empty). + * Otherwise returns `{ model: option.id, variant }` with the requested + * variant when supported, else the model's first variant. + */ +export function resolvePrefillModel( + models: { id: string; variants: string[] }[], + prefill: NewSessionPrefill +): { model: string; variant: string } | null { + if (!prefill.model) { + return null; + } + + const option = models.find(m => m.id === prefill.model); + if (!option) { + return null; + } + + const variant = + prefill.variant && option.variants.includes(prefill.variant) + ? prefill.variant + : (option.variants[0] ?? ''); + + return { model: option.id, variant }; +} + +/** + * Resolve a prefill repository against the loaded repository list. + * Match is **case-insensitive**; returns the **matched entry's + * `fullName`** (GitHub's canonical casing). Returns `null` when + * `prefill.repo` is absent or nothing matches. + */ +export function resolvePrefillRepo( + repositories: { fullName: string }[], + prefill: NewSessionPrefill +): string | null { + if (!prefill.repo) { + return null; + } + + const lower = prefill.repo.toLowerCase(); + const match = repositories.find(r => r.fullName.toLowerCase() === lower); + return match?.fullName ?? null; +} + +/** + * Describe what could not be carried over, if anything. + * + * `settled` means "the list finished loading, without error, and is + * **non-empty**". An account with no GitHub integration leaves + * `repos.settled === false` forever (empty list ≠ settled). + * + * Per-field gating: a field that was **not** requested never blocks + * and never contributes, whatever its `settled` value is. + */ +export function describePrefillFallback(input: { + prefill: NewSessionPrefill; + repos: { settled: boolean; matched: boolean }; + models: { settled: boolean; matched: boolean }; +}): string | null { + const { prefill, repos, models } = input; + + const repoRequested = Boolean(prefill.repo); + const modelRequested = Boolean(prefill.model); + + // Wait only on the fields that were actually requested. + if (repoRequested && !repos.settled) { + return null; + } + if (modelRequested && !models.settled) { + return null; + } + + const repoDropped = repoRequested && !repos.matched; + const modelDropped = modelRequested && !models.matched; + + if (repoDropped && modelDropped) { + return "The original session's repository and model are no longer available. Pick them below."; + } + if (repoDropped) { + return `${prefill.repo} is no longer available. Pick a repository below.`; + } + if (modelDropped) { + return `${prefill.model} is no longer available. Using your default model.`; + } + return null; +} diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index 665d0c8247..465aba6f5e 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -16,6 +16,11 @@ import { toast } from 'sonner-native'; import { getBlockingInteraction } from '@/components/agents/agent-interaction-policy'; import { ChatComposer } from '@/components/agents/chat-composer'; +import { + appendNewSessionPrefill, + buildContinuePrefillParams, +} from '@/components/agents/new-session-prefill'; +import { getNewAgentSessionPath } from '@/components/agents/session-list-routes'; import { createAndNavigateAgentSession } from '@/components/agents/create-and-navigate-agent-session'; import { exitRemoteSessionWithFeedback } from '@/components/agents/exit-remote-session-with-feedback'; import { ConnectivityBanner } from '@/components/agents/connectivity-banner'; @@ -739,6 +744,17 @@ export function SessionDetailContent({ [manager, router] ); + const handleContinueInNewSession = useCallback(() => { + const base = getNewAgentSessionPath(organizationId ?? null); + const params = buildContinuePrefillParams({ + gitUrl: fetchedData?.gitUrl, + mode: currentMode, + model: currentModel, + variant: currentVariant, + }); + router.push(appendNewSessionPrefill(base, params) as Href); + }, [organizationId, fetchedData?.gitUrl, currentMode, currentModel, currentVariant, router]); + const isFocused = useIsFocused(); // Focus bounds the awake window to the visible working UI; a backgrounded // or covered screen must not hold the OS idle timer. @@ -901,10 +917,18 @@ export function SessionDetailContent({ ) : null} {isReadOnly && messages.length > 0 && !hasBlockingInteraction ? ( - + This is a read-only session + ) : null} diff --git a/apps/mobile/src/components/agents/use-new-session-prefill.ts b/apps/mobile/src/components/agents/use-new-session-prefill.ts new file mode 100644 index 0000000000..c5d2a07cf9 --- /dev/null +++ b/apps/mobile/src/components/agents/use-new-session-prefill.ts @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useLocalSearchParams } from 'expo-router'; +import { toast } from 'sonner-native'; + +import { + describePrefillFallback, + type NewSessionPrefill, + readNewSessionPrefill, + resolvePrefillModel, + resolvePrefillRepo, +} from '@/components/agents/new-session-prefill'; + +/** + * Read prefill values from the current route's search params. Memoized on + * the four raw param values. + */ +export function useNewSessionPrefill(): NewSessionPrefill { + const raw = useLocalSearchParams<{ + prefillRepo?: string | string[]; + prefillMode?: string | string[]; + prefillModel?: string | string[]; + prefillVariant?: string | string[]; + }>(); + + const { prefillRepo, prefillMode, prefillModel, prefillVariant } = raw; + + const prefill = useMemo( + () => readNewSessionPrefill({ prefillRepo, prefillMode, prefillModel, prefillVariant }), + [prefillRepo, prefillMode, prefillModel, prefillVariant] + ); + + return prefill; +} + +export type UseNewSessionPrefillTargetsInput = { + repositories: { fullName: string }[]; + // !isLoadingRepos && !isReposError && repositories.length > 0 + reposSettled: boolean; + models: { id: string; variants: string[] }[]; + // !isLoadingModels && !isModelsError && models.length > 0 + modelsSettled: boolean; +}; + +/** + * Owns the `selectedRepo` state and applies the repo prefill exactly once + * when the repository list settles. Also fires the fallback info toast at + * most once per mount. + * + * The repo prefill apply mirrors the existing `hasAppliedAutoSelection` + * pattern in `agent-chat/new.tsx` — a same-component render-phase update + * guarded by a ref. + */ +export function useNewSessionPrefillTargets(input: UseNewSessionPrefillTargetsInput): { + selectedRepo: string; + setSelectedRepo: (value: string) => void; +} { + const { repositories, reposSettled, models, modelsSettled } = input; + const prefill = useNewSessionPrefill(); + const [selectedRepo, setSelectedRepo] = useState(''); + const hasAppliedRepo = useRef(false); + const hasFiredToast = useRef(false); + + if (!hasAppliedRepo.current && reposSettled && !selectedRepo) { + hasAppliedRepo.current = true; + const resolved = resolvePrefillRepo(repositories, prefill); + if (resolved) { + setSelectedRepo(resolved); + } + } + + useEffect(() => { + if (hasFiredToast.current) { + return; + } + + const note = describePrefillFallback({ + prefill, + repos: { + settled: reposSettled, + matched: resolvePrefillRepo(repositories, prefill) !== null, + }, + models: { + settled: modelsSettled, + matched: resolvePrefillModel(models, prefill) !== null, + }, + }); + + if (note) { + hasFiredToast.current = true; + toast.info(note); + } + }, [prefill, reposSettled, modelsSettled, repositories, models]); + + return { selectedRepo, setSelectedRepo }; +}