From 4dca672711aff6175ea0e2a3ca764545459baf3c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 21:10:16 +0700 Subject: [PATCH] Restore features lost during main merge and fix workspace setup flow Restores all features dropped when merging main into dev: - Keyboard shortcuts (send, dictation-confirm) with full e2e chain - Feature toggles (plan/fast mode) in agent status bar - Sidebar kebab menu (Remove Project) on project rows - Combined model selector (sticky header, sizing, favorites sort, search) - Question form Enter key submit with guard - Input focus restoration (composer + AgentStatusBar onDropdownClose) - Draft-agent feature toggles and focus restoration - Provider diagnostics UI (settings section, diagnostic sheet, status badge) - Provider diagnostics server (snapshot manager, diagnostic utils, RPC) Fixes dev-branch regressions: - New workspace button now opens composer in setup dialog via beginWorkspaceSetup - Setup tab auto-opens when workspace setup is running --- CONTRIBUTING.md | 162 ++++++++ .../app/src/components/agent-status-bar.tsx | 363 ++++++++++++++++-- .../components/combined-model-selector.tsx | 268 +++++++++---- packages/app/src/components/composer.tsx | 26 +- packages/app/src/components/message-input.tsx | 22 +- .../components/provider-diagnostic-sheet.tsx | 102 +++++ .../app/src/components/question-form-card.tsx | 3 + .../src/components/sidebar-workspace-list.tsx | 150 +++++++- packages/app/src/components/ui/combobox.tsx | 71 +++- .../app/src/components/ui/status-badge.tsx | 65 ++++ .../app/src/hooks/use-agent-input-draft.ts | 36 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 10 + packages/app/src/keyboard/actions.ts | 1 + .../keyboard/keyboard-action-dispatcher.ts | 4 + .../app/src/keyboard/keyboard-shortcuts.ts | 8 + packages/app/src/screens/settings-screen.tsx | 139 ++++++- .../workspace/workspace-draft-agent-config.ts | 2 + .../workspace/workspace-draft-agent-tab.tsx | 67 ++++ .../screens/workspace/workspace-screen.tsx | 64 +++ packages/server/src/client/daemon-client.ts | 60 +++ .../src/server/agent/agent-sdk-types.ts | 12 + .../server/agent/provider-snapshot-manager.ts | 217 +++++++++++ .../agent/providers/diagnostic-utils.ts | 80 ++++ packages/server/src/server/session.ts | 108 ++++++ .../server/src/server/websocket-server.ts | 12 + packages/server/src/shared/messages.ts | 97 +++++ 26 files changed, 2002 insertions(+), 147 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 packages/app/src/components/provider-diagnostic-sheet.tsx create mode 100644 packages/app/src/components/ui/status-badge.tsx create mode 100644 packages/server/src/server/agent/provider-snapshot-manager.ts create mode 100644 packages/server/src/server/agent/providers/diagnostic-utils.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..afb4acdf6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,162 @@ +# Contributing to Paseo + +Thanks for taking the time to contribute. + +## Before you start + +Please read these first: + +- [README.md](README.md) +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) +- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) +- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) +- [docs/TESTING.md](docs/TESTING.md) +- [CLAUDE.md](CLAUDE.md) + +## What is most helpful + +The highest-signal contributions right now are: + +- bug fixes +- regression fixes +- docs improvements +- packaging / platform fixes +- focused UX improvements that fit the existing product direction +- tests that lock down important behavior + +## Discuss large changes first + +If you want to add a major feature, change core UX, introduce a new surface, or bring in a new architectural concept, please open an issue or start a conversation first. + +Even if the code is good, large unsolicited PRs are unlikely to be merged if they set product direction without prior alignment. + +In short: + +- small, focused PRs: great +- large product-shaping PRs without discussion: probably not + +## Scope expectations + +Please keep PRs narrow. + +Good: + +- fix one bug +- improve one flow +- add one focused panel or command +- tighten one piece of UI + +Bad: + +- combine multiple product ideas in one PR +- bundle unrelated refactors with a feature +- sneak in roadmap decisions + +If a contribution contains multiple ideas, split it up. + +## Product fit matters + +Paseo is an opinionated product. + +When reviewing contributions, the bar is not just: + +- is this useful? +- is this well implemented? + +It is also: + +- does this fit Paseo? +- does this preserve the product's current direction? +- does this increase long-term complexity in a way that is worth it? + +## Development setup + +### Prerequisites + +- Node.js matching `.tool-versions` +- npm workspaces + +### Start local development + +```bash +npm run dev +``` + +Useful commands: + +```bash +npm run dev:server +npm run dev:app +npm run dev:desktop +npm run dev:website +npm run cli -- ls -a -g +``` + +Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details. + +## Testing and verification + +At minimum, run the checks relevant to your change. + +Common checks: + +```bash +npm run typecheck +npm run test --workspaces --if-present +``` + +Important rules: + +- always run `npm run typecheck` after changes +- tests should be deterministic +- prefer real dependencies over mocks when possible +- do not make breaking WebSocket / protocol changes +- app and daemon versions in the wild lag each other, so compatibility matters + +If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md). + +## Coding standards + +Paseo has explicit standards. Please follow them. + +Highlights: + +- keep complexity low +- avoid "while I'm at it" cleanup +- no `any` +- prefer object parameters over positional argument lists +- preserve behavior unless the change is explicitly meant to change behavior +- collocate tests with implementation + +The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md). + +## PR checklist + +Before opening a PR, make sure: + +- the change is focused +- the PR description explains what changed and why +- relevant docs were updated if needed +- typecheck passes +- tests pass, or you clearly explain what could not be run +- the change does not accidentally bundle unrelated product ideas + +## Communication + +If you are unsure whether something fits, ask first. + +That is especially true for: + +- new core UX +- naming / terminology changes +- new extension points +- new orchestration models +- anything that would be hard to remove later + +Early alignment is much better than a large PR that is expensive for everyone to unwind. + +## Forks are fine + +If you want to explore a different product direction, a fork is completely fine. + +Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable. diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 177d625e7..25bd5f5eb 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -3,10 +3,19 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native"; +import { + Brain, + ChevronDown, + ListTodo, + Settings2, + ShieldAlert, + ShieldCheck, + ShieldOff, + Zap, +} from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { CombinedModelSelector } from "@/components/combined-model-selector"; -import { useProviderModels } from "@/hooks/use-provider-models"; +import { useQuery } from "@tanstack/react-query"; import { useSessionStore } from "@/stores/session-store"; import { buildFavoriteModelKey, @@ -24,6 +33,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { + AgentFeature, AgentMode, AgentModelDefinition, AgentProvider, @@ -36,17 +46,19 @@ import { type AgentModeIcon, } from "@server/server/agent/provider-manifest"; import { + getFeatureHighlightColor, + getFeatureTooltip, getStatusSelectorHint, resolveAgentModelSelection, } from "@/components/agent-status-bar.utils"; - +import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading"; type StatusOption = { id: string; label: string; }; -type StatusSelector = "provider" | "mode" | "model" | "thinking"; +type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; const PROVIDER_DEFINITION_MAP = new Map( AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), @@ -73,6 +85,9 @@ type ControlledAgentStatusBarProps = { canSelectModelProvider?: (providerId: string) => boolean; favoriteKeys?: Set; onToggleFavoriteModel?: (provider: string, modelId: string) => void; + features?: AgentFeature[]; + onSetFeature?: (featureId: string, value: unknown) => void; + onDropdownClose?: () => void; }; export interface DraftAgentStatusBarProps { @@ -92,12 +107,16 @@ export interface DraftAgentStatusBarProps { thinkingOptions: NonNullable; selectedThinkingOptionId: string; onSelectThinkingOption: (thinkingOptionId: string) => void; + features?: AgentFeature[]; + onSetFeature?: (featureId: string, value: unknown) => void; + onDropdownClose?: () => void; disabled?: boolean; } interface AgentStatusBarProps { agentId: string; serverId: string; + onDropdownClose?: () => void; } function findOptionLabel( @@ -112,6 +131,38 @@ function findOptionLabel( return selected?.label ?? fallback; } +const FEATURE_ICONS: Record = { + "list-todo": ListTodo, + zap: Zap, +}; + +function getFeatureIcon(icon?: string) { + return (icon && FEATURE_ICONS[icon]) || Settings2; +} + +function getFeatureIconColor( + featureId: string, + enabled: boolean, + palette: { + blue: { 400: string }; + yellow: { 400: string }; + }, + foregroundMuted: string, +): string { + if (!enabled) { + return foregroundMuted; + } + + switch (getFeatureHighlightColor(featureId)) { + case "blue": + return palette.blue[400]; + case "yellow": + return palette.yellow[400]; + default: + return foregroundMuted; + } +} + const MODE_ICONS = { ShieldCheck, ShieldAlert, @@ -162,6 +213,9 @@ function ControlledStatusBar({ canSelectModelProvider, favoriteKeys = new Set(), onToggleFavoriteModel, + features, + onSetFeature, + onDropdownClose, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); const isWeb = Platform.OS === "web"; @@ -203,7 +257,8 @@ function ControlledStatusBar({ Boolean(providerOptions?.length) || Boolean(modeOptions?.length) || canSelectModel || - Boolean(thinkingOptions?.length); + Boolean(thinkingOptions?.length) || + Boolean(features?.length); if (!hasAnyControl) { return null; @@ -280,8 +335,11 @@ function ControlledStatusBar({ const handleOpenChange = useCallback( (selector: StatusSelector) => (nextOpen: boolean) => { setOpenSelector(nextOpen ? selector : null); + if (!nextOpen) { + onDropdownClose?.(); + } }, - [], + [onDropdownClose], ); const handleSelectorPress = useCallback( @@ -352,6 +410,7 @@ function ControlledStatusBar({ onToggleFavorite={onToggleFavoriteModel} isLoading={isModelLoading} disabled={modelDisabled} + onClose={onDropdownClose} /> @@ -455,6 +514,105 @@ function ControlledStatusBar({ /> ) : null} + + {features?.map((feature) => { + if (feature.type === "toggle") { + const FeatureIcon = getFeatureIcon(feature.icon); + return ( + + + onSetFeature?.(feature.id, !feature.value)} + style={({ pressed, hovered }) => [ + styles.modeIconBadge, + hovered && styles.modeBadgeHovered, + pressed && styles.modeBadgePressed, + disabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel={getFeatureTooltip(feature)} + testID={`agent-feature-${feature.id}`} + > + + + + + {getFeatureTooltip(feature)} + + + ); + } + if (feature.type === "select") { + const FeatureIcon = getFeatureIcon(feature.icon); + const selectedOption = feature.options.find((o) => o.id === feature.value); + return ( + + + + [ + styles.modeBadge, + hovered && styles.modeBadgeHovered, + (pressed || openSelector === `feature-${feature.id}`) && + styles.modeBadgePressed, + disabled && styles.disabledBadge, + ]} + accessibilityRole="button" + accessibilityLabel={getFeatureTooltip(feature)} + testID={`agent-feature-${feature.id}`} + > + + + {selectedOption?.label ?? feature.label} + + + + + + {getFeatureTooltip(feature)} + + + + {feature.options.map((option) => ( + onSetFeature?.(feature.id, option.id)} + > + {option.label} + + ))} + + + ); + } + return null; + })} ) : ( <> @@ -499,6 +657,7 @@ function ControlledStatusBar({ onToggleFavorite={onToggleFavoriteModel} isLoading={isModelLoading} disabled={modelDisabled} + onClose={onDropdownClose} renderTrigger={({ selectedModelLabel }) => ( ) : null} + + {features?.map((feature) => { + if (feature.type === "toggle") { + const FeatureIcon = getFeatureIcon(feature.icon); + return ( + + onSetFeature?.(feature.id, !feature.value)} + style={({ pressed }) => [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + disabled && styles.disabledSheetSelect, + ]} + accessibilityRole="button" + accessibilityLabel={getFeatureTooltip(feature)} + testID={`agent-feature-${feature.id}`} + > + + {feature.label} + {feature.value ? "On" : "Off"} + + + ); + } + if (feature.type === "select") { + const selectedOption = feature.options.find((o) => o.id === feature.value); + return ( + + + [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + disabled && styles.disabledSheetSelect, + ]} + accessibilityRole="button" + accessibilityLabel={getFeatureTooltip(feature)} + testID={`agent-feature-${feature.id}`} + > + + {selectedOption?.label ?? feature.label} + + + + + {feature.options.map((option) => ( + onSetFeature?.(feature.id, option.id)} + > + {option.label} + + ))} + + + + ); + } + return null; + })} )} @@ -602,7 +838,7 @@ function ControlledStatusBar({ const EMPTY_MODES: AgentMode[] = []; -export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { +export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStatusBarProps) { const { preferences, updatePreferences } = useFormPreferences(); const agent = useSessionStore( useShallow((state) => { @@ -614,6 +850,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { currentModeId: currentAgent.currentModeId, runtimeModelId: currentAgent.runtimeInfo?.model ?? null, model: currentAgent.model, + features: currentAgent.features, thinkingOptionId: currentAgent.thinkingOptionId, } : null; @@ -626,15 +863,52 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { ); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const { allProviderModels: providerModelsMap, isLoading: isProviderModelsLoading } = - useProviderModels(serverId); + const modelsQuery = useQuery({ + queryKey: ["providerModels", serverId, agent?.provider ?? "__missing_provider__"], + 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 agentProviderDefinitions = useMemo(() => { const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); return definition ? [definition] : []; }, [agent?.provider]); - const models = agent?.provider ? (providerModelsMap.get(agent.provider) ?? null) : null; + const agentProviderModelQuery = useQuery({ + queryKey: ["providerModels", serverId, agent?.provider, agent?.cwd ?? ""], + enabled: Boolean(client && agent?.cwd && 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 agentProviderModels = useMemo(() => { + const map = new Map(); + if (agent?.provider && agentProviderModelQuery.data) { + map.set(agent.provider, agentProviderModelQuery.data); + } + return map; + }, [agent?.provider, agentProviderModelQuery.data]); + + const models = modelsQuery.data ?? null; const displayMode = availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || @@ -682,7 +956,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { } selectedModeId={agent.currentModeId ?? undefined} providerDefinitions={agentProviderDefinitions} - allProviderModels={providerModelsMap} + allProviderModels={agentProviderModels} onSelectMode={(modeId) => { if (!client) { return; @@ -745,7 +1019,17 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); }); }} - isModelLoading={isProviderModelsLoading} + features={agent.features} + onSetFeature={(featureId, value) => { + if (!client) { + return; + } + void client.setAgentFeature(agentId, featureId, value).catch((error) => { + console.warn("[AgentStatusBar] setAgentFeature failed", error); + }); + }} + isModelLoading={isProviderModelsQueryLoading(modelsQuery)} + onDropdownClose={onDropdownClose} disabled={!client} /> ); @@ -768,6 +1052,9 @@ export function DraftAgentStatusBar({ thinkingOptions, selectedThinkingOptionId, onSelectThinkingOption, + features, + onSetFeature, + onDropdownClose, disabled = false, }: DraftAgentStatusBarProps) { const isWeb = Platform.OS === "web"; @@ -812,6 +1099,7 @@ export function DraftAgentStatusBar({ }} isLoading={isAllModelsLoading} disabled={disabled} + onClose={onDropdownClose} /> 0 ? mappedThinkingOptions : undefined} selectedThinkingOptionId={effectiveSelectedThinkingOption} onSelectThinkingOption={onSelectThinkingOption} + features={features} + onSetFeature={onSetFeature} + onDropdownClose={onDropdownClose} disabled={disabled} /> @@ -833,28 +1124,32 @@ export function DraftAgentStatusBar({ })); return ( - onSelectModel(modelId)} - isModelLoading={isAllModelsLoading} - favoriteKeys={favoriteKeys} - onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { - console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); - }); - }} - thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} - selectedThinkingOptionId={effectiveSelectedThinkingOption} - onSelectThinkingOption={onSelectThinkingOption} - disabled={disabled} - /> + <> + onSelectModel(modelId)} + isModelLoading={isAllModelsLoading} + favoriteKeys={favoriteKeys} + onToggleFavoriteModel={(provider, modelId) => { + void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); + }); + }} + thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} + selectedThinkingOptionId={effectiveSelectedThinkingOption} + onSelectThinkingOption={onSelectThinkingOption} + features={features} + onSetFeature={onSetFeature} + disabled={disabled} + /> + ); } diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 6340b51e8..a8aaa051d 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, Text, + TextInput, Pressable, Platform, ActivityIndicator, type GestureResponderEvent, } from "react-native"; +import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowLeft, @@ -15,9 +17,14 @@ import { Search, Star, } from "lucide-react-native"; -import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; +import type { + AgentModelDefinition, + AgentProvider, +} from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; -import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox"; +const IS_WEB = Platform.OS === "web"; + +import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { getProviderIcon } from "@/components/provider-icons"; import type { FavoriteModelRow } from "@/hooks/use-form-preferences"; @@ -29,8 +36,6 @@ import { type SelectorModelRow, } from "./combined-model-selector.utils"; -const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY; - type SelectorView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; @@ -51,6 +56,7 @@ interface CombinedModelSelectorProps { disabled: boolean; isOpen: boolean; }) => React.ReactNode; + onClose?: () => void; disabled?: boolean; } @@ -67,8 +73,6 @@ interface SelectorContentProps { canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; - onBack?: () => void; - isLoading?: boolean; } function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { @@ -100,6 +104,22 @@ function partitionRows( return { favoriteRows, regularRows }; } +function sortFavoritesFirst( + rows: SelectorModelRow[], + favoriteKeys: Set, +): SelectorModelRow[] { + const favorites: SelectorModelRow[] = []; + const rest: SelectorModelRow[] = []; + for (const row of rows) { + if (favoriteKeys.has(row.favoriteKey)) { + favorites.push(row); + } else { + rest.push(row); + } + } + return [...favorites, ...rest]; +} + function groupRowsByProvider( rows: SelectorModelRow[], ): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> { @@ -127,6 +147,7 @@ function ModelRow({ isSelected, isFavorite, disabled = false, + elevated = false, onPress, onToggleFavorite, }: { @@ -134,6 +155,7 @@ function ModelRow({ isSelected: boolean; isFavorite: boolean; disabled?: boolean; + elevated?: boolean; onPress: () => void; onToggleFavorite?: (provider: string, modelId: string) => void; }) { @@ -154,8 +176,9 @@ function ModelRow({ label={row.modelLabel} selected={isSelected} disabled={disabled} + elevated={elevated} onPress={onPress} - leadingSlot={} + leadingSlot={} trailingSlot={ onToggleFavorite && !disabled ? ( + Favorites @@ -240,11 +263,11 @@ function FavoritesSection({ isSelected={row.provider === selectedProvider && row.modelId === selectedModel} isFavorite={favoriteKeys.has(row.favoriteKey)} disabled={!canSelectProvider(row.provider)} + elevated onPress={() => onSelect(row.provider, row.modelId)} onToggleFavorite={onToggleFavorite} /> ))} - ); } @@ -259,6 +282,7 @@ function GroupedProviderRows({ canSelectProvider, onToggleFavorite, onDrillDown, + viewKind, }: { providerDefinitions: AgentProviderDefinition[]; groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; @@ -269,6 +293,7 @@ function GroupedProviderRows({ canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; + viewKind: SelectorView["kind"]; }) { const { theme } = useUnistyles(); @@ -277,19 +302,14 @@ function GroupedProviderRows({ {groupedRows.map((group, index) => { const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId); const ProvIcon = getProviderIcon(group.providerId); - const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD; + const isInline = viewKind === "provider"; return ( {index > 0 ? : null} {isInline ? ( <> - - - {providerDefinition?.label ?? group.providerLabel} - - - {group.rows.map((row) => ( + {sortFavoritesFirst(group.rows, favoriteKeys).map((row) => ( - + {group.providerLabel} - {group.rows.length} - + + {group.rows.length} {group.rows.length === 1 ? "model" : "models"} + + )} @@ -325,6 +347,46 @@ function GroupedProviderRows({ ); } +function ProviderSearchInput({ + value, + onChangeText, + autoFocus = false, +}: { + value: string; + onChangeText: (text: string) => void; + autoFocus?: boolean; +}) { + const { theme } = useUnistyles(); + const inputRef = useRef(null); + const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; + + useEffect(() => { + if (autoFocus && Platform.OS === "web" && inputRef.current) { + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); + } + }, [autoFocus]); + + return ( + + + + + ); +} + function SelectorContent({ view, providerDefinitions, @@ -338,9 +400,8 @@ function SelectorContent({ canSelectProvider, onToggleFavorite, onDrillDown, - onBack, - isLoading, }: SelectorContentProps) { + const { theme } = useUnistyles(); const allRows = useMemo( () => buildModelRows(providerDefinitions, allProviderModels), [allProviderModels, providerDefinitions], @@ -365,35 +426,41 @@ function SelectorContent({ [favoriteKeys, visibleRows], ); - const groupedRegularRows = useMemo(() => groupRowsByProvider(regularRows), [regularRows]); + // Group ALL visible rows by provider — favorites are a cross-cutting view, + // not a partition. A model being favorited doesn't remove it from its provider. + const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]); + + // When searching at Level 1, filter grouped rows to only providers whose name or models match + const filteredGroupedRows = useMemo(() => { + if (view.kind === "provider" || !normalizedQuery) { + return allGroupedRows; + } + return allGroupedRows.filter( + (group) => + group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0, + ); + }, [allGroupedRows, normalizedQuery, view.kind]); + + const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0; return ( - {view.kind === "provider" ? ( - + {view.kind === "all" ? ( + ) : null} - - - - - {groupedRegularRows.length > 0 ? ( + {filteredGroupedRows.length > 0 ? ( ) : null} - {favoriteRows.length === 0 && groupedRegularRows.length === 0 ? ( + {!hasResults ? ( - {isLoading ? ( - <> - - Loading models… - - ) : ( - <> - - No models match your search - - )} + + No models match your search ) : null} @@ -448,8 +507,8 @@ function ProviderBackButton({ pressed && styles.backButtonPressed, ]} > - - + + {providerLabel} ); @@ -466,6 +525,7 @@ export function CombinedModelSelector({ favoriteKeys = new Set(), onToggleFavorite, renderTrigger, + onClose, disabled = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); @@ -476,25 +536,35 @@ export function CombinedModelSelector({ const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); + // Single-provider mode: only one provider with models → skip Level 1 entirely + const singleProviderView = useMemo(() => { + const providers = Array.from(allProviderModels.keys()); + if (providers.length !== 1) return null; + const providerId = providers[0]!; + const label = resolveProviderLabel(providerDefinitions, providerId); + return { kind: "provider", providerId, providerLabel: label }; + }, [allProviderModels, providerDefinitions]); + const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); - setView({ kind: "all" }); + setView(singleProviderView ?? { kind: "all" }); if (!open) { setSearchQuery(""); + onClose?.(); } }, - [], + [onClose, singleProviderView], ); const handleSelect = useCallback( (provider: string, modelId: string) => { onSelect(provider as AgentProvider, modelId); setIsOpen(false); - setView({ kind: "all" }); + setView(singleProviderView ?? { kind: "all" }); setSearchQuery(""); }, - [onSelect], + [onSelect, singleProviderView], ); const ProviderIcon = getProviderIcon(selectedProvider); @@ -512,6 +582,15 @@ export function CombinedModelSelector({ return model?.label ?? resolveDefaultModelLabel(models); }, [allProviderModels, isLoading, selectedModel, selectedProvider]); + const desktopFixedHeight = useMemo(() => { + if (view.kind !== "provider") { + return undefined; + } + const models = allProviderModels.get(view.providerId); + const modelCount = models?.length ?? 0; + return Math.min(80 + modelCount * 40, 400); + }, [allProviderModels, view]); + const triggerLabel = useMemo(() => { if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { return selectedModelLabel; @@ -579,7 +658,30 @@ export function CombinedModelSelector({ stackBehavior="push" anchorRef={anchorRef} desktopPlacement="top-start" + desktopMinWidth={360} + desktopFixedHeight={desktopFixedHeight} title="Select model" + stickyHeader={ + view.kind === "provider" ? ( + + {!singleProviderView ? ( + { + setView({ kind: "all" }); + setSearchQuery(""); + }} + /> + ) : null} + + + ) : undefined + } > {isContentReady ? ( { setView({ kind: "provider", providerId, providerLabel }); }} - onBack={ - view.kind === "provider" - ? () => { - setView({ kind: "all" }); - } - : undefined - } /> ) : ( @@ -646,17 +740,23 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: 0, height: "auto", }, + favoritesContainer: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, separator: { height: 1, backgroundColor: theme.colors.border, - marginVertical: theme.spacing[1], }, sectionHeading: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, sectionHeadingText: { fontSize: theme.fontSize.xs, @@ -670,6 +770,7 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], minHeight: 36, + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, drillDownRowHovered: { backgroundColor: theme.colors.surface1, @@ -679,8 +780,8 @@ const styles = StyleSheet.create((theme) => ({ }, drillDownText: { flex: 1, - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, }, drillDownTrailing: { flexDirection: "row", @@ -691,6 +792,11 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, + level2Header: { + backgroundColor: theme.colors.surface1, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, backButton: { flexDirection: "row", alignItems: "center", @@ -699,9 +805,10 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, backButtonHovered: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surface2, }, backButtonPressed: { backgroundColor: theme.colors.surface2, @@ -746,4 +853,17 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + providerSearchContainer: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: theme.spacing[3], + gap: theme.spacing[2], + ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), + }, + providerSearchInput: { + flex: 1, + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, })); diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 01ac9659a..0636c26d4 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -75,6 +75,8 @@ interface ComposerProps { autoFocus?: boolean; /** Callback to expose the addImages function to parent components */ onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; + /** Callback to expose a focus function to parent components (desktop only). */ + onFocusInput?: (focus: () => void) => void; /** Optional draft context for listing commands before an agent exists. */ commandDraftConfig?: DraftCommandConfig; /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ @@ -107,6 +109,7 @@ export function Composer({ clearDraft, autoFocus = false, onAddImages, + onFocusInput, commandDraftConfig, onMessageSent, onComposerHeightChange, @@ -213,6 +216,21 @@ export function Composer({ onAddImages?.(addImages); }, [addImages, onAddImages]); + const focusInput = useCallback(() => { + if (Platform.OS !== "web") return; + focusWithRetries({ + focus: () => messageInputRef.current?.focus(), + isFocused: () => { + const el = messageInputRef.current?.getNativeElement?.() ?? null; + return el != null && document.activeElement === el; + }, + }); + }, []); + + useEffect(() => { + onFocusInput?.(focusInput); + }, [focusInput, onFocusInput]); + const submitMessage = useCallback( async (text: string, images?: ImageAttachment[]) => { onMessageSent?.(); @@ -422,12 +440,16 @@ export function Composer({ }, }); return true; + case "message-input.send": + return messageInputRef.current?.runKeyboardAction("send") ?? false; case "message-input.dictation-toggle": messageInputRef.current?.runKeyboardAction("dictation-toggle"); return true; case "message-input.dictation-cancel": messageInputRef.current?.runKeyboardAction("dictation-cancel"); return true; + case "message-input.dictation-confirm": + return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; case "message-input.voice-toggle": messageInputRef.current?.runKeyboardAction("voice-toggle"); return true; @@ -445,8 +467,10 @@ export function Composer({ handlerId: keyboardHandlerIdRef.current, actions: [ "message-input.focus", + "message-input.send", "message-input.dictation-toggle", "message-input.dictation-cancel", + "message-input.dictation-confirm", "message-input.voice-toggle", "message-input.voice-mute-toggle", ], @@ -621,7 +645,7 @@ export function Composer({ resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( ) : ( - + ); return ( diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index b672b6a3f..6280ccc93 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -103,7 +103,7 @@ export interface MessageInputProps { export interface MessageInputRef { focus: () => void; blur: () => void; - runKeyboardAction: (action: MessageInputKeyboardActionKind) => void; + runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean; /** * Web-only: return the underlying DOM element for focus assertions/retries. * May return null if not mounted or on native. @@ -247,26 +247,35 @@ export const MessageInput = forwardRef(funct runKeyboardAction: (action) => { if (action === "focus") { textInputRef.current?.focus(); - return; + return true; + } + + if (action === "send" || action === "dictation-confirm") { + if (isDictatingRef.current) { + sendAfterTranscriptRef.current = true; + confirmDictation(); + return true; + } + return false; } if (action === "voice-toggle") { handleToggleRealtimeVoiceShortcut(); - return; + return true; } if (action === "voice-mute-toggle") { if (isRealtimeVoiceForCurrentAgent) { voice?.toggleMute(); } - return; + return true; } if (action === "dictation-cancel") { if (isDictatingRef.current) { cancelDictation(); } - return; + return true; } if (action === "dictation-toggle") { @@ -276,7 +285,10 @@ export const MessageInput = forwardRef(funct } else { void startDictationIfAvailable(); } + return true; } + + return false; }, getNativeElement: () => { if (!IS_WEB) return null; diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx new file mode 100644 index 000000000..20ff08967 --- /dev/null +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useState } from "react"; +import { View, Text, ActivityIndicator, ScrollView } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; + +interface ProviderDiagnosticSheetProps { + provider: string; + visible: boolean; + onClose: () => void; + serverId: string; +} + +export function ProviderDiagnosticSheet({ + provider, + visible, + onClose, + serverId, +}: ProviderDiagnosticSheetProps) { + const { theme } = useUnistyles(); + const client = useHostRuntimeClient(serverId); + const [diagnostic, setDiagnostic] = useState(null); + const [loading, setLoading] = useState(false); + + const providerLabel = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === provider)?.label ?? provider; + + const fetchDiagnostic = useCallback(async () => { + if (!client || !provider) return; + + setLoading(true); + setDiagnostic(null); + + try { + const result = await client.getProviderDiagnostic(provider as AgentProvider); + setDiagnostic(result.diagnostic); + } catch (err) { + setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); + } finally { + setLoading(false); + } + }, [client, provider]); + + useEffect(() => { + if (visible) { + fetchDiagnostic(); + } else { + setDiagnostic(null); + } + }, [visible, fetchDiagnostic]); + + return ( + + {loading ? ( + + + Fetching diagnostic… + + ) : diagnostic ? ( + + + {diagnostic} + + + ) : null} + + ); +} + +const sheetStyles = StyleSheet.create((theme) => ({ + loadingContainer: { + paddingVertical: theme.spacing[6], + alignItems: "center", + gap: theme.spacing[2], + }, + loadingText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + scrollContainer: { + flex: 1, + }, + scrollContent: { + paddingBottom: theme.spacing[4], + }, + diagnosticText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + fontFamily: "monospace", + lineHeight: theme.fontSize.sm * 1.6, + }, +})); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index f5d711fdc..771b8d4fa 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -119,6 +119,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi }); function handleSubmit() { + if (!allAnswered || isResponding) return; setRespondingAction("submit"); const answers: Record = {}; for (let i = 0; i < questions!.length; i++) { @@ -228,7 +229,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi placeholderTextColor={theme.colors.foregroundMuted} value={otherText} onChangeText={(text) => setOtherText(qIndex, text)} + onSubmitEditing={handleSubmit} editable={!isResponding} + blurOnSubmit={false} /> ); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 4fde8db78..90290fdb5 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -10,7 +10,7 @@ import { type GestureResponderEvent, } from "react-native"; import * as Haptics from "expo-haptics"; -import { useQueries } from "@tanstack/react-query"; +import { useMutation, useQueries } from "@tanstack/react-query"; import { useCallback, useMemo, @@ -39,6 +39,7 @@ import { Monitor, MoreVertical, Plus, + Trash2, } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; @@ -47,6 +48,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { type SidebarProjectEntry, type SidebarWorkspaceEntry, @@ -84,7 +86,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { useSessionStore } from "@/stores/session-store"; +import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -93,6 +95,7 @@ import { resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; +import { createNameId } from "mnemonic-id"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -150,6 +153,8 @@ interface ProjectHeaderRowProps { isDragging: boolean; isArchiving?: boolean; menuController: ReturnType | null; + onRemoveProject?: () => void; + removeProjectStatus?: "idle" | "pending"; dragHandleProps?: DraggableListDragHandleProps; } @@ -702,24 +707,25 @@ function ProjectHeaderRow({ canCreateWorktree, isProjectActive = false, onWorkspacePress, + onWorktreeCreated, shortcutNumber = null, showShortcutBadge = false, drag, isDragging, isArchiving = false, menuController, + onRemoveProject, + removeProjectStatus = "idle", dragHandleProps, }: ProjectHeaderRowProps) { + const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = isCompactFormFactor(); const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup); - const handleBeginWorkspaceSetup = useCallback(() => { if (!serverId) { return; } - - onWorkspacePress?.(); beginWorkspaceSetup({ serverId, sourceDirectory: project.iconWorkingDir, @@ -727,12 +733,13 @@ function ProjectHeaderRow({ creationMethod: "create_worktree", navigationMethod: "navigate", }); + onWorkspacePress?.(); }, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]); useKeyboardActionHandler({ handlerId: `worktree-new-${project.projectKey}`, actions: ["worktree.new"], - enabled: isProjectActive && canCreateWorktree, + enabled: isProjectActive && canCreateWorktree && Boolean(serverId), priority: 0, handle: () => { handleBeginWorkspaceSetup(); @@ -776,16 +783,54 @@ function ProjectHeaderRow({ - {canCreateWorktree ? ( - - ) : null} + + {canCreateWorktree ? ( + + ) : null} + {onRemoveProject ? ( + + + [ + styles.projectKebabButton, + hovered && styles.projectKebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Project actions" + testID={`sidebar-project-kebab-${project.projectKey}`} + > + {({ hovered }) => ( + + )} + + + } + status={removeProjectStatus} + pendingLabel="Removing..." + onSelect={onRemoveProject} + > + Remove project + + + + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} @@ -1406,6 +1451,8 @@ function FlattenedProjectRow({ isDragging, dragHandleProps, isProjectActive = false, + onRemoveProject, + removeProjectStatus, }: { project: SidebarProjectEntry; displayName: string; @@ -1421,6 +1468,8 @@ function FlattenedProjectRow({ isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; isProjectActive?: boolean; + onRemoveProject?: () => void; + removeProjectStatus?: "idle" | "pending"; }) { if (project.projectKind === "directory") { return ( @@ -1459,6 +1508,8 @@ function FlattenedProjectRow({ drag={drag} isDragging={isDragging} menuController={null} + onRemoveProject={onRemoveProject} + removeProjectStatus={removeProjectStatus} dragHandleProps={dragHandleProps} /> ); @@ -1629,6 +1680,48 @@ function ProjectBlock({ [onWorkspaceReorder, project.projectKey], ); + const toast = useToast(); + const [isRemovingProject, setIsRemovingProject] = useState(false); + + const handleRemoveProject = useCallback(() => { + if (isRemovingProject || !serverId) { + return; + } + + void (async () => { + const confirmed = await confirmDialog({ + title: "Remove project?", + message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`, + confirmLabel: "Remove", + cancelLabel: "Cancel", + destructive: true, + }); + if (!confirmed) { + return; + } + + const client = getHostRuntimeStore().getClient(serverId); + if (!client) { + toast.error("Host is not connected"); + return; + } + + setIsRemovingProject(true); + try { + for (const ws of project.workspaces) { + const payload = await client.archiveWorkspace(Number(ws.workspaceId)); + if (payload.error) { + throw new Error(payload.error); + } + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to remove project"); + } finally { + setIsRemovingProject(false); + } + })(); + }, [isRemovingProject, serverId, displayName, toast, project.workspaces]); + return ( {rowModel.kind === "workspace_link" ? ( @@ -1653,6 +1746,8 @@ function ProjectBlock({ isDragging={isDragging} dragHandleProps={dragHandleProps} isProjectActive={isProjectActive} + onRemoveProject={handleRemoveProject} + removeProjectStatus={isRemovingProject ? "pending" : "idle"} /> ) : ( <> @@ -1671,7 +1766,10 @@ function ProjectBlock({ onWorktreeCreated={onWorktreeCreated} drag={drag} isDragging={isDragging} + isArchiving={isRemovingProject} menuController={null} + onRemoveProject={handleRemoveProject} + removeProjectStatus={isRemovingProject ? "pending" : "idle"} dragHandleProps={dragHandleProps} /> @@ -2172,6 +2270,26 @@ const styles = StyleSheet.create((theme) => ({ projectIconActionButtonHidden: { opacity: 0, }, + projectTrailingActions: { + flexDirection: "row", + alignItems: "center", + gap: 2, + flexShrink: 0, + }, + projectKebabButton: { + width: 24, + height: 24, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + projectKebabButtonHidden: { + opacity: 0, + }, + projectKebabButtonHovered: { + backgroundColor: theme.colors.surface2, + }, projectTrailingControlSlot: { width: 24, height: 24, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 179f94c15..ab4ec98f8 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -73,6 +73,12 @@ export interface ComboboxProps { * for that combobox instance to avoid animation overriding hidden opacity. */ desktopPreventInitialFlash?: boolean; + /** Minimum width for the desktop popover (overrides trigger-based width). */ + desktopMinWidth?: number; + /** Fixed height for the desktop popover (overrides default 400px max). */ + desktopFixedHeight?: number; + /** Content rendered above the scroll area on desktop (sticky header). */ + stickyHeader?: ReactNode; anchorRef: React.RefObject; children?: ReactNode; } @@ -150,6 +156,8 @@ export interface ComboboxItemProps { selected?: boolean; active?: boolean; disabled?: boolean; + /** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */ + elevated?: boolean; onPress: () => void; testID?: string; } @@ -163,6 +171,7 @@ export function ComboboxItem({ selected, active, disabled, + elevated, onPress, testID, }: ComboboxItemProps): ReactElement { @@ -187,8 +196,8 @@ export function ComboboxItem({ onPress={onPress} style={({ pressed, hovered = false }) => [ styles.comboboxItem, - hovered && styles.comboboxItemHovered, - pressed && styles.comboboxItemPressed, + hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered), + pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed), active && styles.comboboxItemActive, disabled && styles.comboboxItemDisabled, ]} @@ -246,6 +255,9 @@ export function Combobox({ stackBehavior, desktopPlacement = "top-start", desktopPreventInitialFlash = true, + desktopMinWidth, + desktopFixedHeight, + stickyHeader, anchorRef, children, }: ComboboxProps): ReactElement { @@ -390,12 +402,27 @@ export function Combobox({ ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; const shouldUseDesktopFade = !desktopPreventInitialFlash; + // For top-placed popups: once position resolves, use bottom-based CSS positioning + // so height changes grow upward naturally without floating-ui needing to reposition. + const useStableBottom = + !isDesktopAboveSearch && + IS_WEB && + !isMobile && + hasResolvedDesktopPosition && + desktopPlacement.startsWith("top") && + referenceTop !== null; + const desktopPositionStyle = isDesktopAboveSearch ? { left: floatingLeft ?? 0, bottom: desktopAboveSearchBottom ?? 0, } - : floatingStyles; + : useStableBottom + ? { + left: floatingLeft ?? 0, + bottom: Math.max(windowHeight - referenceTop!, collisionPadding), + } + : floatingStyles; useEffect(() => { if (!isMobile) return; @@ -662,6 +689,7 @@ export function Combobox({ {title} + {stickyHeader} update()} > {children ? ( - - {content} - + <> + {stickyHeader} + + {content} + + ) : ( <> {effectiveOptionsPosition === "above-search" ? ( @@ -783,9 +817,15 @@ const styles = StyleSheet.create((theme) => ({ comboboxItemHovered: { backgroundColor: theme.colors.surface1, }, + comboboxItemHoveredElevated: { + backgroundColor: theme.colors.surface2, + }, comboboxItemPressed: { backgroundColor: theme.colors.surface1, }, + comboboxItemPressedElevated: { + backgroundColor: theme.colors.surface2, + }, comboboxItemActive: { backgroundColor: theme.colors.surface1, }, @@ -876,6 +916,9 @@ const styles = StyleSheet.create((theme) => ({ desktopScrollContent: { paddingVertical: theme.spacing[1], }, + desktopChildrenScrollContent: { + // No padding — custom children (e.g. model selector) control their own spacing + }, desktopScrollContentAboveSearch: { flexGrow: 1, justifyContent: "flex-end", diff --git a/packages/app/src/components/ui/status-badge.tsx b/packages/app/src/components/ui/status-badge.tsx new file mode 100644 index 000000000..ca80ebb7a --- /dev/null +++ b/packages/app/src/components/ui/status-badge.tsx @@ -0,0 +1,65 @@ +import { View, Text } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; + +type StatusBadgeVariant = "success" | "error" | "muted"; + +interface StatusBadgeProps { + label: string; + variant?: StatusBadgeVariant; +} + +export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) { + const { theme } = useUnistyles(); + + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + pill: { + flexDirection: "row", + alignItems: "center", + borderRadius: theme.borderRadius.full, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface3, + paddingHorizontal: theme.spacing[2], + paddingVertical: 3, + }, + pillSuccess: { + backgroundColor: theme.colors.palette.green[900], + borderColor: theme.colors.palette.green[800], + }, + pillError: { + backgroundColor: theme.colors.palette.red[900], + borderColor: theme.colors.palette.red[800], + }, + pillText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + pillTextSuccess: { + color: theme.colors.palette.green[400], + }, + pillTextError: { + color: theme.colors.palette.red[500], + }, +})); diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 94d12d02d..78fb2c742 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -7,6 +7,7 @@ import { type CreateAgentInitialValues, type UseAgentFormStateResult, } from "@/hooks/use-agent-form-state"; +import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useDraftStore } from "@/stores/draft-store"; import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; @@ -35,6 +36,7 @@ type DraftComposerState = UseAgentFormStateResult & { workingDir: string; effectiveModelId: string; effectiveThinkingOptionId: string; + featureValues: Record | undefined; statusControls: DraftAgentStatusBarProps; commandDraftConfig: DraftCommandConfig | undefined; }; @@ -116,6 +118,7 @@ function buildDraftComposerCommandConfig(input: { selectedMode: string; effectiveModelId: string; effectiveThinkingOptionId: string; + featureValues?: Record; }): DraftCommandConfig | undefined { const cwd = input.cwd.trim(); if (!cwd) { @@ -130,13 +133,17 @@ function buildDraftComposerCommandConfig(input: { ...(input.effectiveThinkingOptionId ? { thinkingOptionId: input.effectiveThinkingOptionId } : {}), + ...(input.featureValues ? { featureValues: input.featureValues } : {}), }; } function buildDraftStatusControls(input: { formState: UseAgentFormStateResult; + features?: DraftAgentStatusBarProps["features"]; + onSetFeature?: DraftAgentStatusBarProps["onSetFeature"]; + onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"]; }): DraftAgentStatusBarProps { - const { formState } = input; + const { formState, features, onSetFeature, onDropdownClose } = input; return { providerDefinitions: formState.providerDefinitions, selectedProvider: formState.selectedProvider, @@ -154,6 +161,9 @@ function buildDraftStatusControls(input: { thinkingOptions: formState.availableThinkingOptions, selectedThinkingOptionId: formState.selectedThinkingOptionId, onSelectThinkingOption: formState.setThinkingOptionFromUser, + features, + onSetFeature, + onDropdownClose, }; } @@ -316,6 +326,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ); const workingDir = lockedWorkingDir || formState.workingDir; + const { + features: draftFeatures, + featureValues: draftFeatureValues, + setFeatureValue: setDraftFeatureValue, + } = useDraftAgentFeatures({ + serverId: formState.selectedServerId, + provider: formState.selectedProvider, + cwd: workingDir, + modeId: formState.selectedMode, + modelId: effectiveModelId, + thinkingOptionId: effectiveThinkingOptionId, + }); const commandDraftConfig = useMemo( () => @@ -327,12 +349,14 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr selectedMode: formState.selectedMode, effectiveModelId, effectiveThinkingOptionId, + featureValues: draftFeatureValues, }) : undefined, [ composerOptions, effectiveModelId, effectiveThinkingOptionId, + draftFeatureValues, workingDir, formState.modeOptions, formState.selectedMode, @@ -350,7 +374,12 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr workingDir, effectiveModelId, effectiveThinkingOptionId, - statusControls: buildDraftStatusControls({ formState }), + featureValues: draftFeatureValues, + statusControls: buildDraftStatusControls({ + formState, + features: draftFeatures, + onSetFeature: setDraftFeatureValue, + }), commandDraftConfig, }; }, [ @@ -358,7 +387,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr composerOptions, effectiveModelId, effectiveThinkingOptionId, + draftFeatures, + draftFeatureValues, formState, + setDraftFeatureValue, workingDir, ]); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index c014f828c..edbc739f1 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -129,6 +129,11 @@ export function useKeyboardShortcuts({ id: "message-input.focus", scope: "message-input", }); + case "send": + return keyboardActionDispatcher.dispatch({ + id: "message-input.send", + scope: "message-input", + }); case "dictation-toggle": return keyboardActionDispatcher.dispatch({ id: "message-input.dictation-toggle", @@ -139,6 +144,11 @@ export function useKeyboardShortcuts({ id: "message-input.dictation-cancel", scope: "message-input", }); + case "dictation-confirm": + return keyboardActionDispatcher.dispatch({ + id: "message-input.dictation-confirm", + scope: "message-input", + }); case "voice-toggle": return keyboardActionDispatcher.dispatch({ id: "message-input.voice-toggle", diff --git a/packages/app/src/keyboard/actions.ts b/packages/app/src/keyboard/actions.ts index 2653ac323..e9b8af965 100644 --- a/packages/app/src/keyboard/actions.ts +++ b/packages/app/src/keyboard/actions.ts @@ -11,6 +11,7 @@ export type MessageInputKeyboardActionKind = | "queue" | "dictation-toggle" | "dictation-cancel" + | "dictation-confirm" | "voice-toggle" | "voice-mute-toggle"; diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 817ac7101..90cbcf117 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -2,8 +2,10 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "work export type KeyboardActionId = | "message-input.focus" + | "message-input.send" | "message-input.dictation-toggle" | "message-input.dictation-cancel" + | "message-input.dictation-confirm" | "message-input.voice-toggle" | "message-input.voice-mute-toggle" | "workspace.tab.new" @@ -27,8 +29,10 @@ export type KeyboardActionId = export type KeyboardActionDefinition = | { id: "message-input.focus"; scope: KeyboardActionScope } + | { id: "message-input.send"; scope: KeyboardActionScope } | { id: "message-input.dictation-toggle"; scope: KeyboardActionScope } | { id: "message-input.dictation-cancel"; scope: KeyboardActionScope } + | { id: "message-input.dictation-confirm"; scope: KeyboardActionScope } | { id: "message-input.voice-toggle"; scope: KeyboardActionScope } | { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope } | { id: "workspace.tab.new"; scope: KeyboardActionScope } diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index cb880cc6e..06f4882d8 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -860,6 +860,14 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ }, }, + { + id: "message-input-dictation-confirm-enter", + action: "message-input.action", + combo: "Enter", + when: { commandCenter: false, terminal: false }, + payload: { type: "message-input", kind: "dictation-confirm" }, + }, + { id: "message-input-voice-mute-toggle", action: "message-input.action", diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index c5fabd2da..ff41fbd72 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -21,6 +21,7 @@ import { Info, Shield, Puzzle, + Blocks, } from "lucide-react-native"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import type { HostProfile, HostConnection } from "@/types/host-connection"; @@ -62,6 +63,11 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { isCompactFormFactor } from "@/constants/layout"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; +import { getProviderIcon } from "@/components/provider-icons"; +import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { StatusBadge } from "@/components/ui/status-badge"; +import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; // --------------------------------------------------------------------------- // Section definitions @@ -72,6 +78,7 @@ type SettingsSectionId = | "appearance" | "shortcuts" | "integrations" + | "providers" | "diagnostics" | "about" | "permissions" @@ -99,6 +106,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio } sections.push( + { id: "providers", label: "Providers", icon: Blocks }, { id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "about", label: "About", icon: Info }, ); @@ -417,6 +425,121 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro ); } +interface ProvidersSectionProps { + routeServerId: string; +} + +function ProvidersSection({ routeServerId }: ProvidersSectionProps) { + const { theme } = useUnistyles(); + const client = useHostRuntimeClient(routeServerId); + const isConnected = useHostRuntimeIsConnected(routeServerId); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [diagnosticProvider, setDiagnosticProvider] = useState(null); + + useEffect(() => { + if (!client || !isConnected) { + setEntries([]); + return; + } + + let cancelled = false; + setLoading(true); + client + .getProvidersSnapshot() + .then((result) => { + if (!cancelled) setEntries(result.entries); + }) + .catch(() => { + if (!cancelled) setEntries([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [client, isConnected]); + + const hasServer = routeServerId.length > 0; + + return ( + <> + + Providers + {!hasServer || !isConnected ? ( + + Connect to a host to see providers + + ) : loading ? ( + + Loading... + + ) : ( + + {AGENT_PROVIDER_DEFINITIONS.map((def) => { + const entry = entries.find((e) => e.provider === def.id); + const status = entry?.status ?? "unavailable"; + const ProviderIcon = getProviderIcon(def.id); + + return ( + + + + {def.label} + + + + + + + ); + })} + + )} + + + {diagnosticProvider ? ( + setDiagnosticProvider(null)} + serverId={routeServerId} + /> + ) : null} + + ); +} + interface DiagnosticsSectionProps { voiceAudioEngine: ReturnType; @@ -486,6 +609,7 @@ interface SettingsSectionContentProps { sectionId: SettingsSectionId; hostsProps: HostsSectionProps; appearanceProps: AppearanceSectionProps; + providersProps: ProvidersSectionProps; diagnosticsProps: DiagnosticsSectionProps; aboutProps: AboutSectionProps; appVersion: string | null; @@ -497,6 +621,7 @@ function SettingsSectionContent({ sectionId, hostsProps, appearanceProps, + providersProps, diagnosticsProps, aboutProps, appVersion, @@ -510,6 +635,8 @@ function SettingsSectionContent({ return ; case "shortcuts": return ; + case "providers": + return ; case "diagnostics": return ; case "about": @@ -567,7 +694,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout const isSelected = section.id === selectedSectionId; const IconComponent = section.icon; const showSeparator = - section.id === "integrations" || section.id === "diagnostics"; + section.id === "integrations" || section.id === "providers"; return ( {showSeparator ? : null} @@ -939,6 +1066,10 @@ export default function SettingsScreen() { handleThemeChange, }; + const providersProps: ProvidersSectionProps = { + routeServerId, + }; + const diagnosticsProps: DiagnosticsSectionProps = { voiceAudioEngine, isPlaybackTestRunning, @@ -954,6 +1085,7 @@ export default function SettingsScreen() { const sectionContentProps: Omit = { hostsProps, appearanceProps, + providersProps, diagnosticsProps, aboutProps, appVersion, @@ -1761,6 +1893,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, + providerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-config.ts b/packages/app/src/screens/workspace/workspace-draft-agent-config.ts index d9d566137..f3e9bf613 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-config.ts +++ b/packages/app/src/screens/workspace/workspace-draft-agent-config.ts @@ -6,6 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: { modeId?: string; model?: string; thinkingOptionId?: string; + featureValues?: Record; }): AgentSessionConfig { return { provider: input.provider, @@ -13,5 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: { ...(input.modeId ? { modeId: input.modeId } : {}), ...(input.model ? { model: input.model } : {}), ...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}), + ...(input.featureValues ? { featureValues: input.featureValues } : {}), }; } diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index a03c17e41..dd4ab2b8c 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -147,6 +147,7 @@ export function WorkspaceDraftAgentTab({ title: "Agent", cwd: workspaceDirectory, model, + features: composerState.statusControls.features, thinkingOptionId, labels: {}, }; @@ -166,6 +167,7 @@ export function WorkspaceDraftAgentTab({ : {}), model: composerState.effectiveModelId || undefined, thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, + featureValues: composerState.featureValues, }); const imagesData = await encodeImages(images); @@ -195,6 +197,63 @@ export function WorkspaceDraftAgentTab({ addImagesRef.current = addImages; }, []); + const focusInputRef = useRef<(() => void) | null>(null); + + const handleFocusInputCallback = useCallback((focus: () => void) => { + focusInputRef.current = focus; + }, []); + + const handleProviderSelectWithFocus = useCallback( + (provider: Parameters[0]) => { + composerState.setProviderFromUser(provider); + focusInputRef.current?.(); + }, + [composerState], + ); + + const handleModeSelectWithFocus = useCallback( + (modeId: string) => { + composerState.setModeFromUser(modeId); + focusInputRef.current?.(); + }, + [composerState], + ); + + const handleModelSelectWithFocus = useCallback( + (modelId: string) => { + composerState.setModelFromUser(modelId); + focusInputRef.current?.(); + }, + [composerState], + ); + + const handleProviderAndModelSelectWithFocus = useCallback( + ( + provider: Parameters[0], + modelId: string, + ) => { + composerState.setProviderAndModelFromUser(provider, modelId); + focusInputRef.current?.(); + }, + [composerState], + ); + + const handleThinkingOptionSelectWithFocus = useCallback( + (optionId: string) => { + composerState.setThinkingOptionFromUser(optionId); + focusInputRef.current?.(); + }, + [composerState], + ); + + const handleSetFeatureWithFocus = useCallback( + (featureId: string, value: unknown) => { + composerState.statusControls.onSetFeature?.(featureId, value); + focusInputRef.current?.(); + }, + [composerState], + ); + return ( @@ -242,9 +301,17 @@ export function WorkspaceDraftAgentTab({ clearDraft={draftInput.clear} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} onAddImages={handleAddImagesCallback} + onFocusInput={handleFocusInputCallback} commandDraftConfig={composerState.commandDraftConfig} statusControls={{ ...composerState.statusControls, + onSelectProvider: handleProviderSelectWithFocus, + onSelectMode: handleModeSelectWithFocus, + onSelectModel: handleModelSelectWithFocus, + onSelectProviderAndModel: handleProviderAndModelSelectWithFocus, + onSelectThinkingOption: handleThinkingOptionSelectWithFocus, + onSetFeature: handleSetFeatureWithFocus, + onDropdownClose: () => focusInputRef.current?.(), disabled: isSubmitting, }} /> diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 0288b475a..4342490ad 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -70,6 +70,7 @@ import { } from "@/utils/workspace-tab-identity"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useProviderModels } from "@/hooks/use-provider-models"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention"; import { checkoutStatusQueryKey, @@ -121,6 +122,7 @@ import { findAdjacentPane } from "@/utils/split-navigation"; import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; const TERMINALS_QUERY_STALE_TIME = 5_000; +const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_SET = new Set(); @@ -858,6 +860,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const workspaceLayout = useWorkspaceLayoutStore((state) => persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, ); + const workspaceSetupSnapshot = useWorkspaceSetupStore((state) => + persistenceKey ? state.snapshots[persistenceKey] ?? null : null, + ); const uiTabs = useMemo( () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), [workspaceLayout], @@ -1013,6 +1018,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) () => focusedPaneTabState.tabs.map((tab) => tab.descriptor), [focusedPaneTabState.tabs], ); + const hasSetupTab = useMemo( + () => + uiTabs.some( + (tab) => + tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId, + ), + [normalizedWorkspaceId, uiTabs], + ); const navigateToTabId = useCallback( function navigateToTabId(tabId: string) { @@ -1025,6 +1038,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) ); const emptyWorkspaceSeedRef = useRef(null); + const autoOpenedSetupTabWorkspaceRef = useRef(null); useEffect(() => { if (!persistenceKey) { return; @@ -1053,6 +1067,56 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) workspaceAgentVisibility.activeAgentIds.size, ]); + useEffect(() => { + if (!persistenceKey) { + return; + } + if (!workspaceSetupSnapshot) { + if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) { + autoOpenedSetupTabWorkspaceRef.current = null; + } + return; + } + + const snapshotAge = Date.now() - workspaceSetupSnapshot.updatedAt; + const shouldAutoOpen = + workspaceSetupSnapshot.status === "running" || + snapshotAge <= WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS; + if (!shouldAutoOpen) { + return; + } + if (hasSetupTab) { + autoOpenedSetupTabWorkspaceRef.current = persistenceKey; + return; + } + if (autoOpenedSetupTabWorkspaceRef.current === persistenceKey) { + return; + } + + const target = normalizeWorkspaceTabTarget({ + kind: "setup", + workspaceId: normalizedWorkspaceId, + }); + if (!target) { + return; + } + + const tabId = openWorkspaceTab(persistenceKey, target); + if (!tabId) { + return; + } + + focusWorkspaceTab(persistenceKey, tabId); + autoOpenedSetupTabWorkspaceRef.current = persistenceKey; + }, [ + focusWorkspaceTab, + hasSetupTab, + normalizedWorkspaceId, + openWorkspaceTab, + persistenceKey, + workspaceSetupSnapshot, + ]); + const handleOpenFileFromExplorer = useCallback( function handleOpenFileFromExplorer(filePath: string) { if (isMobile) { diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 2b473b89b..009d256f7 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -42,6 +42,9 @@ import type { ListProviderModelsResponseMessage, ListProviderModesResponseMessage, ListAvailableProvidersResponse, + GetProvidersSnapshotResponseMessage, + RefreshProvidersSnapshotResponseMessage, + ProviderDiagnosticResponseMessage, ListTerminalsResponse, CreateTerminalResponse, SubscribeTerminalResponse, @@ -152,6 +155,10 @@ export type DaemonEvent = requestId: string; resolution: AgentPermissionResponse; } + | { + type: "providers_snapshot_update"; + payload: Extract["payload"]; + } | { type: "error"; message: string }; export type DaemonEventHandler = (event: DaemonEvent) => void; @@ -228,6 +235,9 @@ type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload" type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; +type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"]; +type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"]; +type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, @@ -2574,6 +2584,51 @@ export class DaemonClient { }); } + async getProvidersSnapshot(options?: { + cwd?: string; + requestId?: string; + }): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "get_providers_snapshot_request", + cwd: options?.cwd, + }, + responseType: "get_providers_snapshot_response", + timeout: 10000, + }); + } + + async refreshProvidersSnapshot(options?: { + cwd?: string; + requestId?: string; + }): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "refresh_providers_snapshot_request", + cwd: options?.cwd, + }, + responseType: "refresh_providers_snapshot_response", + timeout: 5000, + }); + } + + async getProviderDiagnostic( + provider: AgentProvider, + options?: { requestId?: string }, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { + type: "provider_diagnostic_request", + provider, + }, + responseType: "provider_diagnostic_response", + timeout: 30000, + }); + } + async listCommands(agentId: string, requestId?: string): Promise; async listCommands(agentId: string, options?: ListCommandsOptions): Promise; async listCommands( @@ -3668,6 +3723,11 @@ export class DaemonClient { requestId: msg.payload.requestId, resolution: msg.payload.resolution, }; + case "providers_snapshot_update": + return { + type: "providers_snapshot_update", + payload: msg.payload, + }; default: return null; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index e3a3a495e..2ff94a178 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -45,6 +45,8 @@ export type AgentMode = { description?: string; }; +export type ProviderStatus = "ready" | "loading" | "error" | "unavailable"; + export type AgentModelDefinition = { provider: AgentProvider; id: string; @@ -64,6 +66,15 @@ export type AgentSelectOption = { metadata?: AgentMetadata; }; +export interface ProviderSnapshotEntry { + provider: AgentProvider; + status: ProviderStatus; + error?: string; + models?: AgentModelDefinition[]; + modes?: AgentMode[]; + fetchedAt?: string; +} + export type AgentFeatureToggle = { type: "toggle"; id: string; @@ -469,4 +480,5 @@ export interface AgentClient { * Returns true if available, false otherwise. */ isAvailable(): Promise; + getDiagnostic?(): Promise<{ diagnostic: string }>; } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts new file mode 100644 index 000000000..dded5a2b4 --- /dev/null +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -0,0 +1,217 @@ +import { EventEmitter } from "node:events"; +import { resolve } from "node:path"; + +import type { Logger } from "pino"; + +import type { + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { ProviderDefinition } from "./provider-registry.js"; +import { AGENT_PROVIDER_IDS } from "./provider-manifest.js"; + +const DEFAULT_CWD_KEY = "__default__"; + +type ProviderSnapshotChangeListener = ( + entries: ProviderSnapshotEntry[], + cwd?: string, +) => void; + +export class ProviderSnapshotManager { + private readonly snapshots = new Map>(); + private readonly warmUps = new Map>(); + private readonly events = new EventEmitter(); + private destroyed = false; + + constructor( + private readonly providerRegistry: Record, + private readonly logger: Logger, + ) {} + + getSnapshot(cwd?: string): ProviderSnapshotEntry[] { + const cwdKey = normalizeCwdKey(cwd); + const entries = this.snapshots.get(cwdKey); + if (!entries) { + const loadingEntries = this.createLoadingEntries(); + this.snapshots.set(cwdKey, loadingEntries); + void this.warmUp(cwd); + return entriesToArray(loadingEntries); + } + return entriesToArray(entries); + } + + refresh(cwd?: string): void { + const cwdKey = normalizeCwdKey(cwd); + this.snapshots.set(cwdKey, this.createLoadingEntries()); + void this.warmUp(cwd); + } + + on(event: "change", listener: ProviderSnapshotChangeListener): this { + this.events.on(event, listener); + return this; + } + + off(event: "change", listener: ProviderSnapshotChangeListener): this { + this.events.off(event, listener); + return this; + } + + destroy(): void { + this.destroyed = true; + this.events.removeAllListeners(); + this.snapshots.clear(); + this.warmUps.clear(); + } + + private createLoadingEntries(): Map { + const entries = new Map(); + for (const provider of this.getProviderIds()) { + entries.set(provider, { + provider, + status: "loading", + }); + } + return entries; + } + + private async warmUp(cwd?: string): Promise { + const cwdKey = normalizeCwdKey(cwd); + const inFlight = this.warmUps.get(cwdKey); + if (inFlight) { + return inFlight; + } + + const warmUpPromise = Promise.allSettled( + this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)), + ).then(() => undefined); + + this.warmUps.set(cwdKey, warmUpPromise); + + try { + await warmUpPromise; + } finally { + if (this.warmUps.get(cwdKey) === warmUpPromise) { + this.warmUps.delete(cwdKey); + } + } + } + + private async refreshProvider( + cwdKey: string, + provider: AgentProvider, + cwd?: string, + ): Promise { + const definition = this.providerRegistry[provider]; + if (!definition) { + return; + } + + const snapshot = this.getOrCreateSnapshot(cwdKey); + snapshot.set(provider, { + provider, + status: "loading", + }); + + try { + const client = definition.createClient(this.logger); + const available = await client.isAvailable(); + if (!available) { + snapshot.set(provider, { + provider, + status: "unavailable", + }); + this.emitChange(cwdKey); + return; + } + + const [models, modes] = await Promise.all([ + definition.fetchModels({ cwd }), + definition.fetchModes({ cwd }), + ]); + + snapshot.set(provider, { + provider, + status: "ready", + models, + modes, + fetchedAt: new Date().toISOString(), + }); + this.emitChange(cwdKey); + } catch (error) { + snapshot.set(provider, { + provider, + status: "error", + error: toErrorMessage(error), + }); + this.logger.warn({ err: error, provider, cwd: cwdKey }, "Failed to refresh provider snapshot"); + this.emitChange(cwdKey); + } + } + + private emitChange(cwdKey: string): void { + if (this.destroyed) { + return; + } + const snapshot = this.snapshots.get(cwdKey); + if (!snapshot) { + return; + } + this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey)); + } + + private getOrCreateSnapshot(cwdKey: string): Map { + const existing = this.snapshots.get(cwdKey); + if (existing) { + return existing; + } + + const created = this.createLoadingEntries(); + this.snapshots.set(cwdKey, created); + return created; + } + + private getProviderIds(): AgentProvider[] { + return AGENT_PROVIDER_IDS.filter((provider) => this.providerRegistry[provider]); + } +} + +function normalizeCwdKey(cwd?: string): string { + if (!cwd) { + return DEFAULT_CWD_KEY; + } + + const trimmed = cwd.trim(); + if (!trimmed) { + return DEFAULT_CWD_KEY; + } + + return resolve(trimmed); +} + +function denormalizeCwdKey(cwdKey: string): string | undefined { + return cwdKey === DEFAULT_CWD_KEY ? undefined : cwdKey; +} + +function entriesToArray( + entries: Map, +): ProviderSnapshotEntry[] { + return Array.from(entries.values(), cloneEntry); +} + +function cloneEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry { + return { + ...entry, + models: entry.models?.map((model) => ({ ...model })), + modes: entry.modes?.map((mode) => ({ ...mode })), + }; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error) { + return error; + } + return "Unknown error"; +} diff --git a/packages/server/src/server/agent/providers/diagnostic-utils.ts b/packages/server/src/server/agent/providers/diagnostic-utils.ts new file mode 100644 index 000000000..af077b271 --- /dev/null +++ b/packages/server/src/server/agent/providers/diagnostic-utils.ts @@ -0,0 +1,80 @@ +import { execFileSync } from "node:child_process"; + +import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; + +type DiagnosticEntry = { + label: string; + value: string; +}; + +export function formatProviderDiagnostic( + providerName: string, + entries: DiagnosticEntry[], +): string { + return [providerName, ...entries.map((entry) => ` ${entry.label}: ${entry.value}`)].join("\n"); +} + +export function formatProviderDiagnosticError( + providerName: string, + error: unknown, +): string { + return formatProviderDiagnostic(providerName, [ + { + label: "Error", + value: error instanceof Error ? error.message : String(error), + }, + ]); +} + +export function formatAvailabilityStatus(available: boolean): string { + return available ? "Available" : "Unavailable"; +} + +export function formatDiagnosticStatus( + available: boolean, + error?: { source: string; cause: unknown }, +): string { + if (error) { + return `Error (${error.source} failed: ${toDiagnosticErrorMessage(error.cause)})`; + } + return formatAvailabilityStatus(available); +} + +export function toDiagnosticErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return "Unknown error"; +} + +export function resolveBinaryVersion(binaryPath: string): string { + try { + return ( + execFileSync(binaryPath, ["--version"], { + encoding: "utf8", + timeout: 5_000, + }).trim() || "unknown" + ); + } catch { + return "unknown"; + } +} + +export function formatConfiguredCommand( + defaultArgv: readonly string[], + runtimeSettings?: ProviderRuntimeSettings, +): string { + const command = runtimeSettings?.command; + if (!command || command.mode === "default") { + return `${defaultArgv.join(" ")} (default)`; + } + + if (command.mode === "append") { + return [defaultArgv[0], ...(command.args ?? []), ...defaultArgv.slice(1)].join(" "); + } + + return command.argv.join(" "); +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 95cfd1261..93cd4411c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,7 @@ export type AgentMcpTransportFactory = () => Promise; import { buildProviderRegistry } from "./agent/provider-registry.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { AgentManager } from "./agent/agent-manager.js"; +import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { AgentTimelineCursor, AgentTimelineFetchDirection, @@ -96,6 +97,7 @@ import type { AgentStreamEvent, AgentProvider, AgentPersistenceHandle, + ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; @@ -392,6 +394,7 @@ export type SessionOptions = { stt: Resolvable; tts: Resolvable; terminalManager: TerminalManager | null; + providerSnapshotManager?: ProviderSnapshotManager; serviceRouteStore?: ServiceRouteStore; getDaemonTcpPort?: () => number | null; resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null; @@ -568,6 +571,8 @@ export class Session { } | null = null; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly terminalManager: TerminalManager | null; + private readonly providerSnapshotManager: ProviderSnapshotManager | null; + private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly serviceRouteStore: ServiceRouteStore | null; private readonly getDaemonTcpPort: (() => number | null) | null; private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null; @@ -625,6 +630,7 @@ export class Session { stt, tts, terminalManager, + providerSnapshotManager, serviceRouteStore, getDaemonTcpPort, resolveServiceStatus, @@ -665,6 +671,7 @@ export class Session { }); this.createAgentMcpTransport = createAgentMcpTransport; this.terminalManager = terminalManager; + this.providerSnapshotManager = providerSnapshotManager ?? null; this.serviceRouteStore = serviceRouteStore ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.resolveServiceStatus = resolveServiceStatus ?? null; @@ -673,6 +680,25 @@ export class Session { this.handleTerminalsChanged(event), ); } + if (this.providerSnapshotManager) { + const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => { + const visibleEntries = entries.filter((entry) => + this.isProviderVisibleToClient(entry.provider), + ); + this.emit({ + type: "providers_snapshot_update", + payload: { + cwd, + entries: visibleEntries, + generatedAt: new Date().toISOString(), + }, + }); + }; + this.providerSnapshotManager.on("change", handleProviderSnapshotChange); + this.unsubscribeProviderSnapshotEvents = () => { + this.providerSnapshotManager?.off("change", handleProviderSnapshotChange); + }; + } this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null; this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; @@ -1624,6 +1650,18 @@ export class Session { await this.handleListAvailableProvidersRequest(msg); break; + case "get_providers_snapshot_request": + await this.handleGetProvidersSnapshotRequest(msg); + break; + + case "refresh_providers_snapshot_request": + await this.handleRefreshProvidersSnapshotRequest(msg); + break; + + case "provider_diagnostic_request": + await this.handleProviderDiagnosticRequest(msg); + break; + case "clear_agent_attention": await this.handleClearAgentAttention(msg.agentId); break; @@ -3099,6 +3137,72 @@ export class Session { } } + private async handleGetProvidersSnapshotRequest( + msg: Extract, + ): Promise { + const entries = this.providerSnapshotManager + ? this.providerSnapshotManager + .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined) + .filter((entry) => this.isProviderVisibleToClient(entry.provider)) + : []; + + this.emit({ + type: "get_providers_snapshot_response", + payload: { + entries, + generatedAt: new Date().toISOString(), + requestId: msg.requestId, + }, + }); + } + + private async handleRefreshProvidersSnapshotRequest( + msg: Extract, + ): Promise { + this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined); + this.emit({ + type: "refresh_providers_snapshot_response", + payload: { + acknowledged: true, + requestId: msg.requestId, + }, + }); + } + + private async handleProviderDiagnosticRequest( + msg: Extract, + ): Promise { + try { + const client = this.providerRegistry[msg.provider].createClient(this.sessionLogger); + const diagnostic = client.getDiagnostic + ? (await client.getDiagnostic()).diagnostic + : "No diagnostic available for this provider."; + this.emit({ + type: "provider_diagnostic_response", + payload: { + provider: msg.provider, + diagnostic, + requestId: msg.requestId, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.sessionLogger.error( + { err, provider: msg.provider }, + `Failed to get provider diagnostic for ${msg.provider}`, + ); + this.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: `Failed to get provider diagnostic: ${err.message}`, + code: "provider_diagnostic_failed", + }, + }); + } + } + private assertSafeGitRef(ref: string, label: string): void { if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { throw new Error(`Invalid ${label}: ${ref}`); @@ -6913,6 +7017,10 @@ export class Session { this.unsubscribeTerminalsChanged(); this.unsubscribeTerminalsChanged = null; } + if (this.unsubscribeProviderSnapshotEvents) { + this.unsubscribeProviderSnapshotEvents(); + this.unsubscribeProviderSnapshotEvents = null; + } this.subscribedTerminalDirectories.clear(); for (const unsubscribeExit of this.terminalExitSubscriptions.values()) { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 3893ef1da..0573c655e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -31,6 +31,8 @@ import { isHostAllowed } from "./allowed-hosts.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; +import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; +import { buildProviderRegistry } from "./agent/provider-registry.js"; import { PushTokenStore } from "./push/token-store.js"; import { PushService } from "./push/push-service.js"; import type { ServiceRouteStore } from "./service-proxy.js"; @@ -258,6 +260,7 @@ export class VoiceAssistantWebSocketServer { private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; + private readonly providerSnapshotManager: ProviderSnapshotManager; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private serverCapabilities: ServerCapabilities | undefined; private runtimeWindowStartedAt = Date.now(); @@ -351,6 +354,13 @@ export class VoiceAssistantWebSocketServer { this.voice = voice ?? null; this.dictation = dictation ?? null; this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; + const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); + this.providerSnapshotManager = new ProviderSnapshotManager( + buildProviderRegistry(providerSnapshotLogger, { + runtimeSettings: this.agentProviderRuntimeSettings, + }), + providerSnapshotLogger, + ); this.onLifecycleIntent = onLifecycleIntent ?? null; this.serviceRouteStore = serviceRouteStore ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null; @@ -519,6 +529,7 @@ export class VoiceAssistantWebSocketServer { } await Promise.all(cleanupPromises); + this.providerSnapshotManager.destroy(); this.checkoutDiffManager.dispose(); this.pendingConnections.clear(); this.sessions.clear(); @@ -669,6 +680,7 @@ export class VoiceAssistantWebSocketServer { stt: () => this.speech?.resolveStt() ?? null, tts: () => this.speech?.resolveTts() ?? null, terminalManager: this.terminalManager, + providerSnapshotManager: this.providerSnapshotManager, serviceRouteStore: this.serviceRouteStore ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, resolveServiceStatus: this.resolveServiceStatus ?? undefined, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 01d839dbb..1a801833e 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -54,6 +54,8 @@ import type { AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, + ProviderSnapshotEntry, + ProviderStatus, AgentRuntimeInfo, AgentTimelineItem, ToolCallDetail, @@ -69,6 +71,13 @@ const AgentModeSchema: z.ZodType = z.object({ description: z.string().optional(), }); +const ProviderStatusSchema: z.ZodType = z.enum([ + "ready", + "loading", + "error", + "unavailable", +]); + const AgentSelectOptionSchema = z.object({ id: z.string(), label: z.string(), @@ -114,6 +123,15 @@ const AgentModelDefinitionSchema: z.ZodType = z.object({ defaultThinkingOptionId: z.string().optional(), }); +const ProviderSnapshotEntrySchema: z.ZodType = z.object({ + provider: AgentProviderSchema, + status: ProviderStatusSchema, + error: z.string().optional(), + models: z.array(AgentModelDefinitionSchema).optional(), + modes: z.array(AgentModeSchema).optional(), + fetchedAt: z.string().optional(), +}); + const AgentCapabilityFlagsSchema: z.ZodType = z.object({ supportsStreaming: z.boolean(), supportsSessionPersistence: z.boolean(), @@ -776,6 +794,24 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({ requestId: z.string(), }); +export const GetProvidersSnapshotRequestMessageSchema = z.object({ + type: z.literal("get_providers_snapshot_request"), + cwd: z.string().optional(), + requestId: z.string(), +}); + +export const RefreshProvidersSnapshotRequestMessageSchema = z.object({ + type: z.literal("refresh_providers_snapshot_request"), + cwd: z.string().optional(), + requestId: z.string(), +}); + +export const ProviderDiagnosticRequestMessageSchema = z.object({ + type: z.literal("provider_diagnostic_request"), + provider: AgentProviderSchema, + requestId: z.string(), +}); + export const ResumeAgentRequestMessageSchema = z.object({ type: z.literal("resume_agent_request"), handle: AgentPersistenceHandleSchema, @@ -1289,6 +1325,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema, ListAvailableProvidersRequestMessageSchema, + GetProvidersSnapshotRequestMessageSchema, + RefreshProvidersSnapshotRequestMessageSchema, + ProviderDiagnosticRequestMessageSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, CancelAgentRequestMessageSchema, @@ -2284,6 +2323,41 @@ export const ListAvailableProvidersResponseSchema = z.object({ }), }); +export const GetProvidersSnapshotResponseMessageSchema = z.object({ + type: z.literal("get_providers_snapshot_response"), + payload: z.object({ + entries: z.array(ProviderSnapshotEntrySchema), + generatedAt: z.string(), + requestId: z.string(), + }), +}); + +export const ProvidersSnapshotUpdateMessageSchema = z.object({ + type: z.literal("providers_snapshot_update"), + payload: z.object({ + cwd: z.string().optional(), + entries: z.array(ProviderSnapshotEntrySchema), + generatedAt: z.string(), + }), +}); + +export const RefreshProvidersSnapshotResponseMessageSchema = z.object({ + type: z.literal("refresh_providers_snapshot_response"), + payload: z.object({ + requestId: z.string(), + acknowledged: z.boolean(), + }), +}); + +export const ProviderDiagnosticResponseMessageSchema = z.object({ + type: z.literal("provider_diagnostic_response"), + payload: z.object({ + provider: AgentProviderSchema, + diagnostic: z.string(), + requestId: z.string(), + }), +}); + const AgentSlashCommandSchema = z.object({ name: z.string(), description: z.string(), @@ -2482,6 +2556,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListProviderModesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema, ListAvailableProvidersResponseSchema, + GetProvidersSnapshotResponseMessageSchema, + ProvidersSnapshotUpdateMessageSchema, + RefreshProvidersSnapshotResponseMessageSchema, + ProviderDiagnosticResponseMessageSchema, ListCommandsResponseSchema, ListTerminalsResponseSchema, TerminalsChangedSchema, @@ -2568,6 +2646,16 @@ export type ListProviderFeaturesResponseMessage = z.infer< typeof ListProviderFeaturesResponseMessageSchema >; export type ListAvailableProvidersResponse = z.infer; +export type GetProvidersSnapshotResponseMessage = z.infer< + typeof GetProvidersSnapshotResponseMessageSchema +>; +export type ProvidersSnapshotUpdateMessage = z.infer; +export type RefreshProvidersSnapshotResponseMessage = z.infer< + typeof RefreshProvidersSnapshotResponseMessageSchema +>; +export type ProviderDiagnosticResponseMessage = z.infer< + typeof ProviderDiagnosticResponseMessageSchema +>; export type ChatCreateResponse = z.infer; export type ChatListResponse = z.infer; export type ChatInspectResponse = z.infer; @@ -2615,6 +2703,15 @@ export type ListProviderFeaturesRequestMessage = z.infer< export type ListAvailableProvidersRequestMessage = z.infer< typeof ListAvailableProvidersRequestMessageSchema >; +export type GetProvidersSnapshotRequestMessage = z.infer< + typeof GetProvidersSnapshotRequestMessageSchema +>; +export type RefreshProvidersSnapshotRequestMessage = z.infer< + typeof RefreshProvidersSnapshotRequestMessageSchema +>; +export type ProviderDiagnosticRequestMessage = z.infer< + typeof ProviderDiagnosticRequestMessageSchema +>; export type ChatCreateRequest = z.infer; export type ChatListRequest = z.infer; export type ChatInspectRequest = z.infer;