diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/filter.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/filter.tsx index 4401c2bf32..306082ff72 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/filter.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/security-agent/[scope]/filter.tsx @@ -12,17 +12,20 @@ import { EmptyState } from '@/components/empty-state'; import { PickerSheet } from '@/components/picker-sheet'; import { FindingFilterModal } from '@/components/security-agent/finding-filter-modal'; import { - clearSecurityFindingFilterBridge, - getSecurityFindingFilterBridge, -} from '@/lib/security-finding-filter-bridge'; + SECURITY_FILTER_ROUTE_KEY, + securityFilterSlot, + useRouteRegistry, +} from '@/lib/route-registry'; export default function SecurityAgentFilterFindingsRoute() { const router = useRouter(); - const [bridge, setBridge] = useState(() => getSecurityFindingFilterBridge()); + const [bridge, setBridge] = useState(() => securityFilterSlot.get(SECURITY_FILTER_ROUTE_KEY)); const { t } = useTranslation(); const [draft, setDraft] = useState( - () => getSecurityFindingFilterBridge()?.filters ?? DEFAULT_SECURITY_FINDING_FILTERS + () => + securityFilterSlot.get(SECURITY_FILTER_ROUTE_KEY)?.filters ?? DEFAULT_SECURITY_FINDING_FILTERS ); + useRouteRegistry(SECURITY_FILTER_ROUTE_KEY); const handleClose = useCallback(() => { router.back(); @@ -35,11 +38,11 @@ export default function SecurityAgentFilterFindingsRoute() { useFocusEffect( useCallback(() => { - const nextBridge = getSecurityFindingFilterBridge(); + const nextBridge = securityFilterSlot.get(SECURITY_FILTER_ROUTE_KEY); setBridge(nextBridge); setDraft(nextBridge?.filters ?? DEFAULT_SECURITY_FINDING_FILTERS); return () => { - clearSecurityFindingFilterBridge(); + securityFilterSlot.clear(SECURITY_FILTER_ROUTE_KEY); }; }, []) ); diff --git a/apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx index 6740c34b80..737bea406e 100644 --- a/apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/instance-picker.tsx @@ -14,11 +14,8 @@ import { radioItemA11y } from '@/components/ui/radio-group'; import { Skeleton } from '@/components/ui/skeleton'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { - clearInstancePickerBridge, - getInstancePickerBridge, - type InstancePickerInstance, -} from '@/lib/picker-bridge'; +import { type InstancePickerInstance } from '@/lib/picker-bridge'; +import { instancePickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry'; import { dedupeInstanceLabels, type LabeledInstance, @@ -34,8 +31,9 @@ export default function InstancePickerScreen() { const colors = useThemeColors(); const { bottom } = useSafeAreaInsets(); const { t } = useTranslation(); - const [bridge, setBridge] = useState(() => getInstancePickerBridge()); + const [bridge, setBridge] = useState(() => instancePickerSlot.get(UNFENCED_ROUTE_KEY)); const bridgeRef = useRef(bridge); + useRouteRegistry(UNFENCED_ROUTE_KEY); const closePicker = useCallback(() => { router.back(); @@ -69,7 +67,7 @@ export default function InstancePickerScreen() { useFocusEffect( useCallback(() => { - const nextBridge = getInstancePickerBridge(); + const nextBridge = instancePickerSlot.get(UNFENCED_ROUTE_KEY); bridgeRef.current = nextBridge; setBridge(nextBridge); // kilocode_change - `refetchOnWindowFocus` only reacts to OS-level @@ -80,8 +78,8 @@ export default function InstancePickerScreen() { void refetchInstances(); return () => { - clearInstancePickerBridge(); - bridgeRef.current = null; + instancePickerSlot.clear(UNFENCED_ROUTE_KEY); + bridgeRef.current = undefined; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- refetchInstances is a stable react-query function identity; including it would re-run this effect on every render because react-query does not memoize it across renders. }, []) @@ -103,8 +101,8 @@ export default function InstancePickerScreen() { const handleSelectCloudAgent = useCallback(() => { void Haptics.selectionAsync(); bridgeRef.current?.onSelect(null); - clearInstancePickerBridge(); - bridgeRef.current = null; + instancePickerSlot.clear(UNFENCED_ROUTE_KEY); + bridgeRef.current = undefined; closePicker(); }, [closePicker]); @@ -112,8 +110,8 @@ export default function InstancePickerScreen() { (instance: InstancePickerInstance) => { void Haptics.selectionAsync(); bridgeRef.current?.onSelect(instance); - clearInstancePickerBridge(); - bridgeRef.current = null; + instancePickerSlot.clear(UNFENCED_ROUTE_KEY); + bridgeRef.current = undefined; closePicker(); }, [closePicker] diff --git a/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx index c6b11ef87d..c2ead3bca0 100644 --- a/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/mode-picker.tsx @@ -1,7 +1,7 @@ import * as Haptics from 'expo-haptics'; -import { useRouter } from 'expo-router'; +import { useLocalSearchParams, useRouter } from 'expo-router'; import { Check } from '@/components/ui/icons'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { FlatList, Pressable, ScrollView, View } from 'react-native'; import { useTranslation } from 'react-i18next'; @@ -21,27 +21,24 @@ import { import { PickerSheet } from '@/components/picker-sheet'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { clearModePickerBridge, getModePickerBridge } from '@/lib/picker-bridge'; +import { parseParam } from '@/lib/route-params'; +import { modePickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry'; export default function ModePickerScreen() { const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); - // Lazy init reads the bridge synchronously on first render — no effect, no + const { routeKey: rawRouteKey } = useLocalSearchParams<{ routeKey?: string }>(); + const routeKey = parseParam(rawRouteKey) ?? UNFENCED_ROUTE_KEY; + useRouteRegistry(routeKey); + // Lazy init reads the slot synchronously on first render — no effect, no // "No options available" flash before a later effect populates state. - const [bridge] = useState(() => getModePickerBridge()); - - useEffect( - () => () => { - clearModePickerBridge(); - }, - [] - ); + const [bridge] = useState(() => modePickerSlot.get(routeKey)); function handleSelect(mode: AgentMode) { void Haptics.selectionAsync(); bridge?.onSelect(mode); - clearModePickerBridge(); + modePickerSlot.clear(routeKey); router.back(); } diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx index 54aab99d27..9c787bb3c0 100644 --- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx +++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx @@ -10,7 +10,7 @@ import { EmptyState } from '@/components/empty-state'; import { PickerSheet } from '@/components/picker-sheet'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { clearRepoPickerBridge, getRepoPickerBridge } from '@/lib/picker-bridge'; +import { repoPickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry'; import { filterRepoPickerOptions } from '@/lib/repo-picker-filter'; export default function RepoPickerScreen() { @@ -19,9 +19,10 @@ export default function RepoPickerScreen() { const { bottom } = useSafeAreaInsets(); const { t } = useTranslation(); const [search, setSearch] = useState(''); - const [bridge, setBridge] = useState(() => getRepoPickerBridge()); + const [bridge, setBridge] = useState(() => repoPickerSlot.get(UNFENCED_ROUTE_KEY)); const bridgeRef = useRef(bridge); + useRouteRegistry(UNFENCED_ROUTE_KEY); const closePicker = useCallback(() => { router.back(); @@ -29,14 +30,14 @@ export default function RepoPickerScreen() { useFocusEffect( useCallback(() => { - const nextBridge = getRepoPickerBridge(); + const nextBridge = repoPickerSlot.get(UNFENCED_ROUTE_KEY); bridgeRef.current = nextBridge; setBridge(nextBridge); setSearch(''); return () => { - clearRepoPickerBridge(); - bridgeRef.current = null; + repoPickerSlot.clear(UNFENCED_ROUTE_KEY); + bridgeRef.current = undefined; }; }, []) ); @@ -50,8 +51,8 @@ export default function RepoPickerScreen() { (repo: string) => { void Haptics.selectionAsync(); bridgeRef.current?.onSelect(repo); - clearRepoPickerBridge(); - bridgeRef.current = null; + repoPickerSlot.clear(UNFENCED_ROUTE_KEY); + bridgeRef.current = undefined; closePicker(); }, [closePicker] @@ -73,7 +74,7 @@ export default function RepoPickerScreen() { repo.fullName} + keyExtractor={repo => `${repo.platform}:${repo.fullName}`} keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" contentContainerStyle={{ paddingBottom: bottom }} diff --git a/apps/mobile/src/components/agents/instance-selector.tsx b/apps/mobile/src/components/agents/instance-selector.tsx index 69ebf69eed..236d516111 100644 --- a/apps/mobile/src/components/agents/instance-selector.tsx +++ b/apps/mobile/src/components/agents/instance-selector.tsx @@ -6,7 +6,8 @@ import { Pressable } from 'react-native'; import { Text } from '@/components/ui/text'; import { i18n } from '@/i18n'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { type InstancePickerInstance, setInstancePickerBridge } from '@/lib/picker-bridge'; +import { type InstancePickerInstance } from '@/lib/picker-bridge'; +import { instancePickerSlot, UNFENCED_ROUTE_KEY } from '@/lib/route-registry'; import { cn } from '@/lib/utils'; type InstanceSelectorProps = { @@ -63,7 +64,7 @@ export function InstanceSelector({ if (!canOpenPicker) { return; } - setInstancePickerBridge({ + instancePickerSlot.set(UNFENCED_ROUTE_KEY, { instances, currentValue: value, onSelect: onChange, diff --git a/apps/mobile/src/components/agents/mode-selector.tsx b/apps/mobile/src/components/agents/mode-selector.tsx index 07cc33d731..fc5dc36c0a 100644 --- a/apps/mobile/src/components/agents/mode-selector.tsx +++ b/apps/mobile/src/components/agents/mode-selector.tsx @@ -1,5 +1,5 @@ import { Pressable } from 'react-native'; -import { type Href, useRouter } from 'expo-router'; +import { type Href, useLocalSearchParams, useRouter } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { ChevronDown } from '@/components/ui/icons'; @@ -14,7 +14,8 @@ import { } from '@/components/agents/mode-normalize'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { setModePickerBridge } from '@/lib/picker-bridge'; +import { parseParam } from '@/lib/route-params'; +import { modePickerSlot, UNFENCED_ROUTE_KEY } from '@/lib/route-registry'; import { cn } from '@/lib/utils'; export type { AgentMode }; @@ -35,6 +36,7 @@ export function ModeSelector({ const router = useRouter(); const colors = useThemeColors(); const { t } = useTranslation(); + const { 'session-id': rawSessionId } = useLocalSearchParams<{ 'session-id'?: string }>(); const selectedValue = normalizeAgentMode(value); const customOptionsDeduped = dedupeCustomModeOptions(customOptions); const selectedCustomOption = ensureSelectedCustomOption(customOptionsDeduped, selectedValue).find( @@ -49,12 +51,13 @@ export function ModeSelector({ if (disabled) { return; } - setModePickerBridge({ + const routeKey = parseParam(rawSessionId) ?? UNFENCED_ROUTE_KEY; + modePickerSlot.set(routeKey, { currentValue: selectedValue, onSelect: onChange, customOptions, }); - router.push('/(app)/agent-chat/mode-picker' as Href); + router.push(`/(app)/agent-chat/mode-picker?routeKey=${encodeURIComponent(routeKey)}` as Href); } return ( diff --git a/apps/mobile/src/components/agents/model-picker-content.tsx b/apps/mobile/src/components/agents/model-picker-content.tsx index 14e0a23128..a3dc3a28ba 100644 --- a/apps/mobile/src/components/agents/model-picker-content.tsx +++ b/apps/mobile/src/components/agents/model-picker-content.tsx @@ -1,5 +1,5 @@ import * as Haptics from 'expo-haptics'; -import { useFocusEffect, useRouter } from 'expo-router'; +import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { AlertCircle, Info, Search, SearchX } from '@/components/ui/icons'; import { useCallback, useMemo, useRef, useState } from 'react'; import { FlatList, TextInput, View } from 'react-native'; @@ -18,12 +18,9 @@ import { favoriteToggleAction, type ModelPickerRow, } from '@/lib/model-picker-rows'; -import { - clearModelPickerBridge, - commitModelPickerSelection, - getModelPickerBridge, - resolveModelPickerSelection, -} from '@/lib/picker-bridge'; +import { commitModelPickerSelection, resolveModelPickerSelection } from '@/lib/picker-bridge'; +import { parseParam } from '@/lib/route-params'; +import { modelPickerSlot, UNFENCED_ROUTE_KEY, useRouteRegistry } from '@/lib/route-registry'; export function ModelPickerContent() { const router = useRouter(); @@ -32,8 +29,11 @@ export function ModelPickerContent() { const { bottom } = useSafeAreaInsets(); const { favorites, favoritesError, addFavorite, removeFavorite } = useModelPreferences(undefined); const favoriteIds = useMemo(() => new Set(favorites), [favorites]); + const { routeKey: rawRouteKey } = useLocalSearchParams<{ routeKey?: string }>(); + const routeKey = parseParam(rawRouteKey) ?? UNFENCED_ROUTE_KEY; + useRouteRegistry(routeKey); const [search, setSearch] = useState(''); - const [bridge, setBridge] = useState(() => getModelPickerBridge()); + const [bridge, setBridge] = useState(() => modelPickerSlot.get(routeKey)); const [selectedModel, setSelectedModel] = useState(bridge?.currentValue ?? ''); const [selectedVariant, setSelectedVariant] = useState(bridge?.currentVariant ?? ''); const bridgeRef = useRef(bridge); @@ -48,7 +48,7 @@ export function ModelPickerContent() { useFocusEffect( useCallback(() => { - const nextBridge = getModelPickerBridge(); + const nextBridge = modelPickerSlot.get(routeKey); const nextModel = nextBridge?.currentValue ?? ''; const nextVariant = nextBridge?.currentVariant ?? ''; @@ -75,10 +75,10 @@ export function ModelPickerContent() { selectedVariantRef.current ); } - clearModelPickerBridge(); - bridgeRef.current = null; + modelPickerSlot.clear(routeKey); + bridgeRef.current = undefined; }; - }, []) + }, [routeKey]) ); const rows = useMemo( diff --git a/apps/mobile/src/components/agents/model-selector.tsx b/apps/mobile/src/components/agents/model-selector.tsx index 0f38aa4ede..c79d6c4d1c 100644 --- a/apps/mobile/src/components/agents/model-selector.tsx +++ b/apps/mobile/src/components/agents/model-selector.tsx @@ -20,11 +20,8 @@ import { type ModelOption, thinkingEffortLabel } from '@/lib/hooks/use-available import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { modelPickerCostLabel } from '@/lib/model-cost'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { - type ModelPickerSelection, - type ModelPickerSelectionScope, - setModelPickerBridge, -} from '@/lib/picker-bridge'; +import { type ModelPickerSelection, type ModelPickerSelectionScope } from '@/lib/picker-bridge'; +import { modelPickerSlot } from '@/lib/route-registry'; import { cn } from '@/lib/utils'; import { modelSelectorBadges } from './model-selector-badges'; @@ -107,7 +104,8 @@ export function openModelPicker( } ) { const { options, value, variant, onSelect, selectionScope = UNFENCED_SELECTION_CONTEXT } = params; - setModelPickerBridge({ + const routeKey = selectionScope.selectionScope.sessionId; + modelPickerSlot.set(routeKey, { options: options.map(option => toSessionModelOption(option)), currentValue: value, currentVariant: variant, @@ -117,7 +115,7 @@ export function openModelPicker( onSelect(selection.option.id, selection.variant, selection); }, }); - router.push('/(app)/agent-chat/model-picker' as Href); + router.push(`/(app)/agent-chat/model-picker?routeKey=${encodeURIComponent(routeKey)}` as Href); } export function ModelSelector({ diff --git a/apps/mobile/src/components/agents/repo-selector.tsx b/apps/mobile/src/components/agents/repo-selector.tsx index 776f5464dc..a9e38e769a 100644 --- a/apps/mobile/src/components/agents/repo-selector.tsx +++ b/apps/mobile/src/components/agents/repo-selector.tsx @@ -5,12 +5,17 @@ import { useTranslation } from 'react-i18next'; import { Text } from '@/components/ui/text'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; -import { setRepoPickerBridge } from '@/lib/picker-bridge'; +import { type RepoOption as BridgeRepoOption, type RepoPlatform } from '@/lib/picker-bridge'; +import { repoPickerSlot, UNFENCED_ROUTE_KEY } from '@/lib/route-registry'; import { cn } from '@/lib/utils'; type RepoOption = { fullName: string; isPrivate: boolean; + /** Provider platform; omitted rows are treated as GitHub until d1 fills it. */ + platform?: RepoPlatform; + workspaceUuid?: string; + repositoryUuid?: string; }; type RepoSelectorProps = { @@ -40,8 +45,15 @@ export function RepoSelector({ if (effectivelyDisabled) { return; } - setRepoPickerBridge({ - repositories, + const bridgeRepositories: BridgeRepoOption[] = repositories.map(repo => ({ + platform: repo.platform ?? 'github', + fullName: repo.fullName, + isPrivate: repo.isPrivate, + ...(repo.workspaceUuid !== undefined ? { workspaceUuid: repo.workspaceUuid } : {}), + ...(repo.repositoryUuid !== undefined ? { repositoryUuid: repo.repositoryUuid } : {}), + })); + repoPickerSlot.set(UNFENCED_ROUTE_KEY, { + repositories: bridgeRepositories, currentValue: value, onSelect: onChange, }); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx index 78caa921cc..8a8153dcf9 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx @@ -116,7 +116,12 @@ export function PrReviewFileList({ // pops the PR screen off the stack), drop the bridge so a stale // selection can never leak into the next mount. Re-mounting this // list always starts with no selection. - useEffect(() => clearDiffSelection, []); + useEffect( + () => () => { + clearDiffSelection({ owner, repo, number }); + }, + [owner, repo, number] + ); // Measured floating-bar height (null until the first layout event). const [barHeight, setBarHeight] = useState(null); diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx index 8e4e0aaaef..a2b47ab08c 100644 --- a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx +++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx @@ -113,7 +113,7 @@ export function PrDiffFloatingActions({ size="sm" onPress={() => { onClearSelection(); - clearDiffSelection(); + clearDiffSelection({ owner, repo, number }); }} accessibilityLabel={t('prReview.floatingActions.clearSelection')} > diff --git a/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts index a8fb1246c8..347255f0ce 100644 --- a/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts +++ b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts @@ -53,12 +53,12 @@ export function useDiffSelection({ if (isTablet && viewMode === 'side-by-side') { setSelectionState(prev => { if (prev) { - clearDiffSelection(); + clearDiffSelection({ owner, repo, number }); } return null; }); } - }, [isTablet, viewMode]); + }, [isTablet, viewMode, owner, repo, number]); // Diff-line tap producer: build the per-side line-number → text map // for this hunk, run the reducer, mirror to the bridge, and store diff --git a/apps/mobile/src/lib/picker-bridge.test.ts b/apps/mobile/src/lib/picker-bridge.test.ts index 5d6494ea1a..eb19db87d1 100644 --- a/apps/mobile/src/lib/picker-bridge.test.ts +++ b/apps/mobile/src/lib/picker-bridge.test.ts @@ -1,12 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { areModelPickerSelectionScopesEqual, - clearModelPickerBridge, commitModelPickerSelection, - getModelPickerBridge, resolveModelPickerSelection, - setModelPickerBridge, } from './picker-bridge'; const remoteOption = { @@ -32,27 +29,14 @@ const currentSelectionScope = { }; describe('model picker bridge', () => { - beforeEach(() => { - clearModelPickerBridge(); - }); - it('preserves exact model identity and override source while resetting an invalid variant', () => { - const onSelect = vi.fn(); - setModelPickerBridge({ + const bridge = { ...currentSelectionScope, options: [remoteOption], currentValue: remoteOption.id, currentVariant: 'removed', - onSelect: selection => { - onSelect(selection); - }, - }); - - const bridge = getModelPickerBridge(); - expect(bridge).not.toBeNull(); - if (!bridge) { - throw new Error('Expected model picker bridge'); - } + onSelect: vi.fn(), + }; const selection = resolveModelPickerSelection(bridge, remoteOption.id, 'removed'); if (!selection) { diff --git a/apps/mobile/src/lib/picker-bridge.ts b/apps/mobile/src/lib/picker-bridge.ts index 0c501c9a54..aa6c66954c 100644 --- a/apps/mobile/src/lib/picker-bridge.ts +++ b/apps/mobile/src/lib/picker-bridge.ts @@ -14,7 +14,7 @@ export type ModelPickerSelectionScope = { catalogGenerationIdentity: object | null; }; -type ModelPickerBridge = { +export type ModelPickerBridge = { options: SessionModelOption[]; currentValue: string; currentVariant: string; @@ -35,18 +35,23 @@ export function areModelPickerSelectionScopesEqual( ); } -type ModePickerBridge = { +export type ModePickerBridge = { currentValue: AgentMode; onSelect: (mode: AgentMode) => void; customOptions?: ModeOption[]; }; +export type RepoPlatform = 'github' | 'gitlab' | 'bitbucket'; + export type RepoOption = { + platform: RepoPlatform; fullName: string; isPrivate: boolean; + workspaceUuid?: string; + repositoryUuid?: string; }; -type RepoPickerBridge = { +export type RepoPickerBridge = { repositories: RepoOption[]; currentValue: string; onSelect: (repo: string) => void; @@ -66,17 +71,12 @@ export type InstancePickerInstance = { capabilities?: { attachments?: boolean }; }; -type InstancePickerBridge = { +export type InstancePickerBridge = { instances: InstancePickerInstance[]; currentValue: InstancePickerInstance | null; onSelect: (instance: InstancePickerInstance | null) => void; }; -let modelBridge: ModelPickerBridge | null = null; -let modeBridge: ModePickerBridge | null = null; -let repoBridge: RepoPickerBridge | null = null; -let instanceBridge: InstancePickerBridge | null = null; - export function resolveModelPickerSelection( bridge: ModelPickerBridge, value: string, @@ -110,43 +110,3 @@ export function commitModelPickerSelection( bridge.onSelect(selection); return true; } - -export function setModelPickerBridge(bridge: ModelPickerBridge) { - modelBridge = bridge; -} -export function getModelPickerBridge() { - return modelBridge; -} -export function clearModelPickerBridge() { - modelBridge = null; -} - -export function setModePickerBridge(bridge: ModePickerBridge) { - modeBridge = bridge; -} -export function getModePickerBridge() { - return modeBridge; -} -export function clearModePickerBridge() { - modeBridge = null; -} - -export function setRepoPickerBridge(bridge: RepoPickerBridge) { - repoBridge = bridge; -} -export function getRepoPickerBridge() { - return repoBridge; -} -export function clearRepoPickerBridge() { - repoBridge = null; -} - -export function setInstancePickerBridge(bridge: InstancePickerBridge) { - instanceBridge = bridge; -} -export function getInstancePickerBridge() { - return instanceBridge; -} -export function clearInstancePickerBridge() { - instanceBridge = null; -} diff --git a/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts b/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts index 50f5210789..2d79f433a5 100644 --- a/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts +++ b/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts @@ -1,15 +1,15 @@ -// Module-level bridge for the current diff selection (path + side + line +// Route-scoped bridge for the current diff selection (path + side + line // range + the actual selected line text). The diff side calls // `setDiffSelection` when the user taps a line range; the comment composer -// route reads it via `getDiffSelection` on focus and clears it on blur so -// a stale selection never leaks into the next visit. S7a implements the -// producer side (the diff component) and the consumer side (the composer -// sheet) in the same slice that adds the final pending-comment fields. +// route reads it via `getDiffSelection` on focus and the diff view clears it +// on blur so a stale selection never leaks into the next visit. // -// The selection carries its owning PR identity so a selection made in one -// PR can never be consumed by another PR's composer if both entries remain -// mounted in the navigation stack: `getDiffSelection` returns null unless -// the requested PR matches the stored selection. +// The selection is stored in the route registry under the PR's route key, so +// a selection made in one PR can never be consumed by another PR's composer +// if both entries remain mounted in the navigation stack: `getDiffSelection` +// returns null unless the requested PR matches the stored selection. + +import { prDiffSelectionSlot, prRouteKey } from '../route-registry'; type DiffSelectionSide = 'LEFT' | 'RIGHT'; @@ -27,27 +27,19 @@ export type DiffSelection = PrIdentity & { selectedText: string; }; -let selection: DiffSelection | null = null; - -function samePr(a: PrIdentity, b: PrIdentity): boolean { - return ( - a.owner.toLowerCase() === b.owner.toLowerCase() && - a.repo.toLowerCase() === b.repo.toLowerCase() && - a.number === b.number - ); -} - export function setDiffSelection(next: DiffSelection) { - selection = next; + prDiffSelectionSlot.set(prRouteKey(next), next); } export function getDiffSelection(pr: PrIdentity): DiffSelection | null { - if (!selection || !samePr(selection, pr)) { - return null; - } - return selection; + return prDiffSelectionSlot.get(prRouteKey(pr)) ?? null; } -export function clearDiffSelection() { - selection = null; +/** + * Drops the selection stored for one PR. Scoped, not global: two PR entries + * can sit on the navigation stack at once, and clearing the one on top must + * not discard the selection the entry underneath is still holding. + */ +export function clearDiffSelection(pr: PrIdentity) { + prDiffSelectionSlot.clear(prRouteKey(pr)); } diff --git a/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts b/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts index a4df62aa68..31f18d4637 100644 --- a/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts +++ b/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts @@ -1,14 +1,16 @@ -// Module-level bridge for the file-navigator's "scroll to file" request. +// Route-scoped bridge for the file-navigator's "scroll to file" request. // The file navigator subscribes via `subscribeFileNavigatorRequest` and // navigates the diff list when the user picks a file from the navigator // sheet. `requestScrollToFile` is the producer side, called by the -// navigator sheet on selection. S6c implements both ends. +// navigator sheet on selection. // -// Every request carries its owning PR identity, and subscribers register -// for a specific PR, so a navigation request emitted for one PR is never -// delivered to another PR's diff list if both remain mounted. +// Every request carries its owning PR identity, and subscribers register for +// a specific PR's route key in the route registry, so a navigation request +// emitted for one PR is never delivered to another PR's diff list if both +// remain mounted. import { type PrIdentity } from './diff-selection-bridge'; +import { prFileNavSlot, prRouteKey } from '../route-registry'; export type FileNavigatorRequest = PrIdentity & { path: string; @@ -16,28 +18,32 @@ export type FileNavigatorRequest = PrIdentity & { type Listener = (request: FileNavigatorRequest) => void; -const listeners = new Set<{ pr: PrIdentity; listener: Listener }>(); - -function samePr(a: PrIdentity, b: PrIdentity): boolean { - return ( - a.owner.toLowerCase() === b.owner.toLowerCase() && - a.repo.toLowerCase() === b.repo.toLowerCase() && - a.number === b.number - ); -} - export function requestScrollToFile(request: FileNavigatorRequest) { - for (const entry of listeners) { - if (samePr(entry.pr, request)) { - entry.listener(request); - } + const listeners = prFileNavSlot.get(prRouteKey(request)); + if (!listeners) { + return; + } + for (const listener of listeners) { + listener(request); } } export function subscribeFileNavigatorRequest(pr: PrIdentity, listener: Listener): () => void { - const entry = { pr, listener }; - listeners.add(entry); + const routeKey = prRouteKey(pr); + const existing = prFileNavSlot.get(routeKey); + if (existing) { + existing.add(listener); + } else { + prFileNavSlot.set(routeKey, new Set([listener])); + } return () => { - listeners.delete(entry); + const listeners = prFileNavSlot.get(routeKey); + if (!listeners) { + return; + } + listeners.delete(listener); + if (listeners.size === 0) { + prFileNavSlot.clear(routeKey); + } }; } diff --git a/apps/mobile/src/lib/pr-review/pr-bridges.test.ts b/apps/mobile/src/lib/pr-review/pr-bridges.test.ts index 63170dc012..44b060f57b 100644 --- a/apps/mobile/src/lib/pr-review/pr-bridges.test.ts +++ b/apps/mobile/src/lib/pr-review/pr-bridges.test.ts @@ -14,7 +14,8 @@ const PR_B = { owner: 'octocat', repo: 'hello', number: 2 }; describe('diff-selection-bridge', () => { beforeEach(() => { - clearDiffSelection(); + clearDiffSelection(PR_A); + clearDiffSelection(PR_B); }); it('returns the selection only to the PR that produced it', () => { @@ -32,10 +33,20 @@ describe('diff-selection-bridge', () => { it('clears the selection so it never leaks into the next visit', () => { setDiffSelection({ ...PR_A, path: 'a.ts', side: 'RIGHT', line: 3, selectedText: 'x' }); - clearDiffSelection(); + clearDiffSelection(PR_A); expect(getDiffSelection(PR_A)).toBeNull(); }); + + it('clears only the requested PR when two PRs hold a selection', () => { + setDiffSelection({ ...PR_A, path: 'a.ts', side: 'RIGHT', line: 3, selectedText: 'x' }); + setDiffSelection({ ...PR_B, path: 'b.ts', side: 'LEFT', line: 9, selectedText: 'y' }); + + clearDiffSelection(PR_A); + + expect(getDiffSelection(PR_A)).toBeNull(); + expect(getDiffSelection(PR_B)?.path).toBe('b.ts'); + }); }); describe('file-navigator-bridge', () => { diff --git a/apps/mobile/src/lib/repo-picker-filter.test.ts b/apps/mobile/src/lib/repo-picker-filter.test.ts index 46faf616b8..393b866cfc 100644 --- a/apps/mobile/src/lib/repo-picker-filter.test.ts +++ b/apps/mobile/src/lib/repo-picker-filter.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest'; +import { type RepoOption } from './picker-bridge'; import { filterRepoPickerOptions } from './repo-picker-filter'; -const repositories = [ - { fullName: 'Kilo-Org/cloud', isPrivate: true }, - { fullName: 'octocat/Hello-World', isPrivate: false }, - { fullName: 'acme/widgets', isPrivate: true }, +const repositories: RepoOption[] = [ + { fullName: 'Kilo-Org/cloud', isPrivate: true, platform: 'github' }, + { fullName: 'octocat/Hello-World', isPrivate: false, platform: 'github' }, + { fullName: 'acme/widgets', isPrivate: true, platform: 'github' }, ]; describe('filterRepoPickerOptions', () => { diff --git a/apps/mobile/src/lib/route-registry.test.ts b/apps/mobile/src/lib/route-registry.test.ts new file mode 100644 index 0000000000..c53e57ef02 --- /dev/null +++ b/apps/mobile/src/lib/route-registry.test.ts @@ -0,0 +1,68 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/components/agents/model-selector.mounted.test.tsx) */ +import { createElement } from 'react'; +import TestRenderer from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { type ModelPickerBridge } from './picker-bridge'; +import { modelPickerSlot, useRouteRegistry } from './route-registry'; + +function makeBridge(sessionId: string): ModelPickerBridge { + return { + options: [], + currentValue: '', + currentVariant: '', + selectionScope: { + sessionId, + ownerConnectionId: null, + protocol: 'unknown', + catalogGenerationIdentity: null, + }, + isSelectionCurrent: () => true, + onSelect: vi.fn<() => void>(), + }; +} + +function RouteRegistrar({ routeKey }: Readonly<{ routeKey: string }>) { + useRouteRegistry(routeKey); + return null; +} + +function mountRegistrar(routeKey: string): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + TestRenderer.act(() => { + ref.current = TestRenderer.create(createElement(RouteRegistrar, { routeKey })); + }); + const renderer = ref.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +describe('route registry', () => { + it('keeps two route keys from sharing a picker slot', () => { + const bridgeA = makeBridge('session-a'); + const bridgeB = makeBridge('session-b'); + + modelPickerSlot.set('session-a', bridgeA); + modelPickerSlot.set('session-b', bridgeB); + + expect(modelPickerSlot.get('session-a')).toBe(bridgeA); + expect(modelPickerSlot.get('session-b')).toBe(bridgeB); + expect(modelPickerSlot.get('session-a')).not.toBe(modelPickerSlot.get('session-b')); + }); + + it('clears the slot when the route unmounts', () => { + const bridge = makeBridge('session-a'); + const renderer = mountRegistrar('session-a'); + modelPickerSlot.set('session-a', bridge); + + expect(modelPickerSlot.get('session-a')).toBe(bridge); + + TestRenderer.act(() => { + renderer.unmount(); + }); + + expect(modelPickerSlot.get('session-a')).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/route-registry.ts b/apps/mobile/src/lib/route-registry.ts new file mode 100644 index 0000000000..ce249a0247 --- /dev/null +++ b/apps/mobile/src/lib/route-registry.ts @@ -0,0 +1,131 @@ +// Route-scoped registry that replaces the process-global navigation bridges. +// +// Each bridge value is stored under a `routeKey` — a stable id for the flow +// that produced it: a session id for the agent-chat pickers, the PR +// owner/repo/number for the review bridges, or a branded scope for the +// security filter. A route reads its slot with `useRouteRegistry(routeKey)` +// which clears every slot under that key when the route unmounts, so a stale +// bridge never leaks into the next visit. + +import { useEffect } from 'react'; + +import { type DiffSelection } from './pr-review/diff-selection-bridge'; +import { + type InstancePickerBridge, + type ModelPickerBridge, + type ModePickerBridge, + type RepoPickerBridge, +} from './picker-bridge'; +import { type SecurityFindingFilterBridge } from './security-finding-filter-bridge'; + +export type RouteKey = string; + +/** + * Route key for pickers opened outside a live session (new-session and the + * unfenced code-reviewer/security selectors). Mirrors the + * `sessionId: 'unscoped'` default in the model-picker selection scope. + */ +export const UNFENCED_ROUTE_KEY = 'unscoped'; + +/** + * The security filter sheet is pushed from a screen that owns the scope but + * does not pass it through the bridge, so the slot uses one fixed key. The + * route clears it on unmount the same way as every other slot. + */ +export const SECURITY_FILTER_ROUTE_KEY = 'security-filter'; + +/** A file-navigator "scroll to file" listener. */ +type FileNavigatorListener = (request: { + owner: string; + repo: string; + number: number; + path: string; +}) => void; + +type SlotValue = { + modelPicker: ModelPickerBridge; + modePicker: ModePickerBridge; + repoPicker: RepoPickerBridge; + instancePicker: InstancePickerBridge; + prFileNav: Set; + prDiffSelection: DiffSelection; + securityFilter: SecurityFindingFilterBridge; +}; + +type SlotKind = keyof SlotValue; + +type RegistrySlots = { [K in SlotKind]: Map }; + +const slots: RegistrySlots = { + modelPicker: new Map(), + modePicker: new Map(), + repoPicker: new Map(), + instancePicker: new Map(), + prFileNav: new Map>(), + prDiffSelection: new Map(), + securityFilter: new Map(), +}; + +const ALL_SLOT_KINDS: readonly SlotKind[] = [ + 'modelPicker', + 'modePicker', + 'repoPicker', + 'instancePicker', + 'prFileNav', + 'prDiffSelection', + 'securityFilter', +]; + +export type RouteSlot = { + get: (routeKey: RouteKey) => SlotValue[K] | undefined; + set: (routeKey: RouteKey, value: SlotValue[K]) => void; + clear: (routeKey: RouteKey) => void; + clearAll: () => void; +}; + +function createSlot(kind: K): RouteSlot { + const map = slots[kind]; + return { + get: routeKey => map.get(routeKey), + set: (routeKey, value) => { + map.set(routeKey, value); + }, + clear: routeKey => { + map.delete(routeKey); + }, + clearAll: () => { + map.clear(); + }, + }; +} + +export const modelPickerSlot = createSlot('modelPicker'); +export const modePickerSlot = createSlot('modePicker'); +export const repoPickerSlot = createSlot('repoPicker'); +export const instancePickerSlot = createSlot('instancePicker'); +export const prFileNavSlot = createSlot('prFileNav'); +export const prDiffSelectionSlot = createSlot('prDiffSelection'); +export const securityFilterSlot = createSlot('securityFilter'); + +/** + * Canonical route key for a PR review flow. Owner and repo are lowercased so + * a selection or request made for one casing is never lost to another. + */ +export function prRouteKey(pr: { owner: string; repo: string; number: number }): RouteKey { + return `${pr.owner.toLowerCase()}/${pr.repo.toLowerCase()}#${pr.number}`; +} + +/** + * Registers `routeKey` for the lifetime of the calling route. On unmount the + * hook clears every slot stored under that key. + */ +export function useRouteRegistry(routeKey: RouteKey): void { + useEffect( + () => () => { + for (const kind of ALL_SLOT_KINDS) { + slots[kind].delete(routeKey); + } + }, + [routeKey] + ); +} diff --git a/apps/mobile/src/lib/security-finding-filter-bridge.ts b/apps/mobile/src/lib/security-finding-filter-bridge.ts index 0194795721..282eb42310 100644 --- a/apps/mobile/src/lib/security-finding-filter-bridge.ts +++ b/apps/mobile/src/lib/security-finding-filter-bridge.ts @@ -1,29 +1,23 @@ import { type SecurityFindingFilters } from '@kilocode/app-shared/security-agent'; +import { SECURITY_FILTER_ROUTE_KEY, securityFilterSlot } from './route-registry'; + // Carries the filter sheet's draft in/out-of-band, same shape as the -// agent-chat picker bridges in picker-bridge.ts: the caller sets it right -// before pushing the formSheet route, the route reads it once focused, and -// clears it on blur so a stale bridge never leaks into the next visit. +// agent-chat picker bridges: the caller sets it right before pushing the +// formSheet route, the route reads it once focused, and clears it on blur so +// a stale bridge never leaks into the next visit. Stored in the route +// registry under a fixed key (the producer does not pass its scope through +// the bridge); the route clears it on unmount. type SecurityFindingFilterRepositoryOption = { fullName: string; }; -type SecurityFindingFilterBridge = { +export type SecurityFindingFilterBridge = { filters: SecurityFindingFilters; repositories: SecurityFindingFilterRepositoryOption[]; onApply: (filters: SecurityFindingFilters) => void; }; -let bridge: SecurityFindingFilterBridge | null = null; - export function setSecurityFindingFilterBridge(next: SecurityFindingFilterBridge) { - bridge = next; -} - -export function getSecurityFindingFilterBridge() { - return bridge; -} - -export function clearSecurityFindingFilterBridge() { - bridge = null; + securityFilterSlot.set(SECURITY_FILTER_ROUTE_KEY, next); } diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx index e03f831ff1..ca0c6399cf 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -18,6 +18,7 @@ import { } from '@kilocode/cloud-agent-sdk'; import type { SendMessagePayload } from '@/lib/cloud-agent-next/cloud-agent-client'; import { CLOUD_AGENT_NEXT_WS_URL, SESSION_INGEST_WS_URL } from '@/lib/constants'; +import { normalizeAlias } from './session-config'; import { usePostHog } from 'posthog-js/react'; import { fetchWebSessionSnapshotPage } from './session-page-adapter'; @@ -310,7 +311,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi organizationId: sessionResult.organization_id, gitUrl: sessionResult.git_url, gitBranch: rs?.upstreamBranch ?? sessionResult.git_branch, - mode: rs?.mode ?? null, + mode: normalizeAlias(rs?.mode), model: rs?.model ?? null, variant: rs?.variant ?? null, repository: rs?.githubRepo ?? null, diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 6318dd9ffc..9e607e0809 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -13,7 +13,14 @@ import { useManager } from './CloudAgentProvider'; import { MobileSidebarToggle } from './MobileSidebarToggle'; import { ChatHeader } from './ChatHeader'; import { ChatInput } from './ChatInput'; -import type { ModeOption } from '@/components/shared/ModeCombobox'; +import { + dedupeCustomModeOptions, + ensureSelectedCustomOption, + modeControlValue, + normalizeAlias, + type CustomModeOption, +} from './session-config'; +import { useCombinedProfiles, useProfiles, useProfile } from '@/hooks/useCloudAgentProfiles'; import { MessageErrorBoundary } from './MessageErrorBoundary'; import { MessageBubble } from './MessageBubble'; import { ChildSessionDrawer } from './ChildSessionDrawer'; @@ -557,7 +564,7 @@ export default function CloudChatPage({ payload: { type: 'prompt', prompt, - mode: sessionConfig?.mode ?? 'code', + mode: normalizeAlias(sessionConfig?.mode) || 'code', model: agentModelOverrideForSend ?? sessionConfig?.model ?? '', variant: agentModelOverrideForSend ? agentVariantOverrideForSend @@ -718,18 +725,62 @@ export default function CloudChatPage({ focusTarget.focus(); }, []); - // Expose the session's custom agents to the chat picker. Slug + name only; - // the full config stays server-side. `GetSessionOutput.runtimeAgents` - // already filters to enabled & non-hidden at send time, so we just pass - // through. - const customModeOptions: ModeOption[] | undefined = sessionConfig?.runtimeAgents - ?.length - ? sessionConfig.runtimeAgents.map(a => ({ - value: a.slug as AgentMode, - label: a.name, - description: '', - })) - : undefined; + // Surface the session's custom agents plus the current visible profile + // agents to the chat picker. `runtimeAgents` are the agents active when the + // session was created; the profile list enriches those same agents with + // their current descriptions, filtered to slugs the session can still run. + // + // Only agents that would surface in NewSessionPanel's picker are included + // (not disabled, not hidden, not subagent-only). Built-in slugs are dropped + // and the selected slug is appended once when it is neither built-in nor + // already listed, so an inherited custom slug stays visible. + const { data: combinedProfilesData } = useCombinedProfiles({ + organizationId: organizationId ?? '', + enabled: !!organizationId, + }); + const { data: personalProfiles } = useProfiles({ + organizationId: undefined, + enabled: !organizationId, + }); + const effectiveAgentProfileId = organizationId + ? (combinedProfilesData?.effectiveDefaultId ?? null) + : (personalProfiles?.find(p => p.isDefault)?.id ?? null); + const effectiveAgentProfileOrg = + effectiveAgentProfileId && organizationId + ? combinedProfilesData?.orgProfiles.some(p => p.id === effectiveAgentProfileId) + ? organizationId + : undefined + : undefined; + const { data: selectedProfileDetails } = useProfile(effectiveAgentProfileId ?? '', { + organizationId: effectiveAgentProfileOrg, + enabled: !!effectiveAgentProfileId, + }); + // Only surface profile agents the session can still run. `runtimeAgents` is + // frozen at session creation, but the current visible profile may have + // gained an agent since; the worker's `validateModeAgainstRuntimeAgents` + // accepts only built-in slugs or slugs in that frozen list, so a newer + // profile agent would be offered here and then rejected on send. + const runtimeAgentSlugs = new Set((sessionConfig?.runtimeAgents ?? []).map(a => a.slug)); + const visibleProfileAgents = (selectedProfileDetails?.agents ?? []) + .filter(a => !a.config.disable && !a.config.hidden && a.config.mode !== 'subagent') + .filter(a => runtimeAgentSlugs.has(a.slug)); + + const runtimeCustomOptions: CustomModeOption[] = (sessionConfig?.runtimeAgents ?? []).map(a => ({ + value: a.slug, + label: a.name, + description: '', + })); + const profileCustomOptions: CustomModeOption[] = visibleProfileAgents.map(a => ({ + value: a.slug, + label: a.name, + description: a.config.description ?? '', + })); + const combinedCustomOptions = ensureSelectedCustomOption( + dedupeCustomModeOptions([...runtimeCustomOptions, ...profileCustomOptions]), + sessionConfig?.mode ?? '' + ); + const customModeOptions: CustomModeOption[] | undefined = + combinedCustomOptions.length > 0 ? combinedCustomOptions : undefined; // If the selected custom agent pins a model, the chat model picker must // reflect + lock that value. The agent's `variant` is only meaningful when @@ -1071,7 +1122,7 @@ export default function CloudChatPage({ isStreaming={isStreaming && !activeSuggestion} placeholder={placeholder} slashCommands={availableCommands} - mode={sessionConfig?.mode as AgentMode | undefined} + mode={modeControlValue(sessionConfig?.mode ?? null)} model={displayModel} modelOptions={modelOptions} isLoadingModels={isLoadingModels} diff --git a/apps/web/src/components/cloud-agent-next/session-config.test.ts b/apps/web/src/components/cloud-agent-next/session-config.test.ts index f822cf4d4a..a483a73b63 100644 --- a/apps/web/src/components/cloud-agent-next/session-config.test.ts +++ b/apps/web/src/components/cloud-agent-next/session-config.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from '@jest/globals'; -import { needsResumeConfiguration } from './session-config'; +import { + dedupeCustomModeOptions, + ensureSelectedCustomOption, + isValidSessionConfig, + modeControlValue, + needsResumeConfiguration, + normalizeAlias, + type CustomModeOption, +} from './session-config'; import type { SessionConfig, ResumeConfig } from './types'; describe('needsResumeConfiguration', () => { @@ -111,9 +119,9 @@ describe('needsResumeConfiguration', () => { ).toBe(true); }); - it('returns true for session with invalid mode', () => { + it('returns true for session with empty mode', () => { const invalidConfig: SessionConfig = { - mode: 'invalid-mode', // Not a valid AgentMode, but SessionConfig.mode is string + mode: '', // Empty mode is invalid, even after alias normalization model: 'anthropic/claude-3-5-sonnet', repository: 'owner/repo', sessionId: 'abc-123', @@ -129,6 +137,24 @@ describe('needsResumeConfiguration', () => { ).toBe(true); }); + it('returns false for session with a custom mode and a model', () => { + const customConfig: SessionConfig = { + mode: 'my-agent', + model: 'anthropic/claude-3-5-sonnet', + repository: 'owner/repo', + sessionId: 'abc-123', + }; + + expect( + needsResumeConfiguration({ + currentDbSessionId: 'abc-123', + resumeConfig: null, + persistedResumeConfig: null, + sessionConfig: customConfig, + }) + ).toBe(false); + }); + it('prioritizes resumeConfig over invalid sessionConfig', () => { const resumeConfig: ResumeConfig = { mode: 'code', @@ -175,3 +201,119 @@ describe('needsResumeConfiguration', () => { ).toBe(false); }); }); + +describe('normalizeAlias', () => { + it('keeps a custom runtimeState mode unchanged', () => { + expect(normalizeAlias('my-agent')).toBe('my-agent'); + }); + + it('maps build to code', () => { + expect(normalizeAlias('build')).toBe('code'); + }); + + it('maps architect to plan', () => { + expect(normalizeAlias('architect')).toBe('plan'); + }); + + it.each(['code', 'plan', 'debug', 'orchestrator', 'ask'])('keeps built-in %s', mode => { + expect(normalizeAlias(mode)).toBe(mode); + }); + + it.each([null, undefined, ''])('stays empty for %s', mode => { + expect(normalizeAlias(mode)).toBe(''); + }); +}); + +describe('modeControlValue', () => { + it('returns undefined for an empty control value', () => { + expect(modeControlValue('')).toBeUndefined(); + }); + + it('returns undefined for null and undefined', () => { + expect(modeControlValue(null)).toBeUndefined(); + expect(modeControlValue(undefined)).toBeUndefined(); + }); + + it('returns a custom slug unchanged', () => { + expect(modeControlValue('my-agent')).toBe('my-agent'); + }); + + it('maps build to code', () => { + expect(modeControlValue('build')).toBe('code'); + }); +}); + +describe('isValidSessionConfig', () => { + it('accepts a custom slug with a model', () => { + const config: SessionConfig = { + mode: 'my-agent', + model: 'anthropic/claude-3-5-sonnet', + repository: 'owner/repo', + sessionId: 'abc-123', + }; + expect(isValidSessionConfig(config)).toBe(true); + }); + + it('accepts an aliased mode with a model', () => { + const config: SessionConfig = { + mode: 'build', + model: 'anthropic/claude-3-5-sonnet', + repository: 'owner/repo', + sessionId: 'abc-123', + }; + expect(isValidSessionConfig(config)).toBe(true); + }); + + it('rejects an empty mode', () => { + const config: SessionConfig = { + mode: '', + model: 'anthropic/claude-3-5-sonnet', + repository: 'owner/repo', + sessionId: 'abc-123', + }; + expect(isValidSessionConfig(config)).toBe(false); + }); + + it('rejects a null config', () => { + expect(isValidSessionConfig(null)).toBe(false); + }); +}); + +describe('ensureSelectedCustomOption', () => { + it('appends a missing custom slug once', () => { + expect(ensureSelectedCustomOption([], 'my-agent')).toEqual([ + { value: 'my-agent', label: 'my-agent', description: '' }, + ]); + }); + + it('does not append a built-in slug', () => { + expect(ensureSelectedCustomOption([], 'code')).toEqual([]); + }); + + it('does not append an empty slug', () => { + expect(ensureSelectedCustomOption([], '')).toEqual([]); + }); + + it('does not append an already-present slug', () => { + const custom: CustomModeOption[] = [{ value: 'my-agent', label: 'My Agent', description: '' }]; + expect(ensureSelectedCustomOption(custom, 'my-agent')).toHaveLength(1); + }); +}); + +describe('dedupeCustomModeOptions', () => { + it('drops built-in slugs and duplicates', () => { + const options: CustomModeOption[] = [ + { value: 'code', label: 'Code', description: '' }, + { value: 'my-agent', label: 'My Agent', description: '' }, + { value: 'my-agent', label: 'Duplicate', description: '' }, + ]; + expect(dedupeCustomModeOptions(options)).toEqual([ + { value: 'my-agent', label: 'My Agent', description: '' }, + ]); + }); + + it('drops an empty slug', () => { + const options: CustomModeOption[] = [{ value: '', label: '', description: '' }]; + expect(dedupeCustomModeOptions(options)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/session-config.ts b/apps/web/src/components/cloud-agent-next/session-config.ts index c4719d07d9..83c9b29b28 100644 --- a/apps/web/src/components/cloud-agent-next/session-config.ts +++ b/apps/web/src/components/cloud-agent-next/session-config.ts @@ -100,7 +100,8 @@ export function buildSessionConfig(options: BuildSessionConfigOptions): SessionC * Check if a SessionConfig has valid mode and model for sendMessage. * * The sendMessage schema requires: - * - mode: one of the valid agent modes (code, plan, debug, orchestrator, ask) + * - mode: any non-empty slug after alias normalization (`build` → `code`, + * `architect` → `plan`) * - model: non-empty string (min 1 character) * * @param config - SessionConfig to validate @@ -109,13 +110,87 @@ export function buildSessionConfig(options: BuildSessionConfigOptions): SessionC export function isValidSessionConfig(config: SessionConfig | null): config is SessionConfig { if (!config) return false; - const validModes: AgentMode[] = ['code', 'plan', 'debug', 'orchestrator', 'ask']; - const hasValidMode = (validModes as string[]).includes(config.mode); + const mode = normalizeAlias(config.mode); + const hasValidMode = mode.length > 0; const hasValidModel = config.model.length > 0; return hasValidMode && hasValidModel; } +/** + * Map legacy mode aliases to their canonical built-in slugs. + * + * `build` → `code` and `architect` → `plan`. Empty, null, and undefined stay + * empty so the mode control shows "Select mode" rather than "Code". Any other + * non-empty slug passes through unchanged. + */ +export function normalizeAlias(mode: string | null | undefined): string { + if (mode === 'build') return 'code'; + if (mode === 'architect') return 'plan'; + return mode ?? ''; +} + +/** + * Resolve a mode for the mode control. Empty, null, and undefined become + * `undefined` so the picker shows its "Select mode" placeholder instead of + * defaulting to "Code". + */ +export function modeControlValue(mode: string | null | undefined): string | undefined { + return normalizeAlias(mode) || undefined; +} + +/** The five built-in agent mode slugs. */ +const BUILTIN_MODE_SET: ReadonlySet = new Set([ + 'code', + 'plan', + 'debug', + 'orchestrator', + 'ask', +]); + +/** True when `value` is one of the five built-in slugs. */ +export function isBuiltinAgentMode(value: string): boolean { + return BUILTIN_MODE_SET.has(value); +} + +/** One custom-mode row in the mode picker. */ +export type CustomModeOption = { + value: string; + label: string; + description: string; +}; + +/** + * Drop custom options that collide with a built-in slug and de-duplicate by + * value (first occurrence wins). + */ +export function dedupeCustomModeOptions(options: CustomModeOption[]): CustomModeOption[] { + const seen = new Set(); + const result: CustomModeOption[] = []; + for (const option of options) { + if (option.value === '' || isBuiltinAgentMode(option.value) || seen.has(option.value)) { + continue; + } + seen.add(option.value); + result.push(option); + } + return result; +} + +/** + * Append the selected slug once when it is neither a built-in nor already in + * the custom list, so a prefill or inherited custom slug stays visible. + */ +export function ensureSelectedCustomOption( + custom: CustomModeOption[], + selected: string +): CustomModeOption[] { + if (!selected || isBuiltinAgentMode(selected) || custom.some(o => o.value === selected)) { + return custom; + } + return [...custom, { value: selected, label: selected, description: '' }]; +} + /** * Get mode and model from various sources with debug info. *