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
This commit is contained in:
Mohamed Boudra
2026-04-05 21:10:16 +07:00
parent 8e482b1613
commit 4dca672711
26 changed files with 2002 additions and 147 deletions
+162
View File
@@ -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.
+329 -34
View File
@@ -3,10 +3,19 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; 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 { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector"; 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 { useSessionStore } from "@/stores/session-store";
import { import {
buildFavoriteModelKey, buildFavoriteModelKey,
@@ -24,6 +33,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { import type {
AgentFeature,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentProvider, AgentProvider,
@@ -36,17 +46,19 @@ import {
type AgentModeIcon, type AgentModeIcon,
} from "@server/server/agent/provider-manifest"; } from "@server/server/agent/provider-manifest";
import { import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } from "@/components/agent-status-bar.utils";
import { isProviderModelsQueryLoading } from "@/components/agent-status-bar.model-loading";
type StatusOption = { type StatusOption = {
id: string; id: string;
label: string; label: string;
}; };
type StatusSelector = "provider" | "mode" | "model" | "thinking"; type StatusSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const PROVIDER_DEFINITION_MAP = new Map( const PROVIDER_DEFINITION_MAP = new Map(
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
@@ -73,6 +85,9 @@ type ControlledAgentStatusBarProps = {
canSelectModelProvider?: (providerId: string) => boolean; canSelectModelProvider?: (providerId: string) => boolean;
favoriteKeys?: Set<string>; favoriteKeys?: Set<string>;
onToggleFavoriteModel?: (provider: string, modelId: string) => void; onToggleFavoriteModel?: (provider: string, modelId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
}; };
export interface DraftAgentStatusBarProps { export interface DraftAgentStatusBarProps {
@@ -92,12 +107,16 @@ export interface DraftAgentStatusBarProps {
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>; thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string; selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void; onSelectThinkingOption: (thinkingOptionId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
disabled?: boolean; disabled?: boolean;
} }
interface AgentStatusBarProps { interface AgentStatusBarProps {
agentId: string; agentId: string;
serverId: string; serverId: string;
onDropdownClose?: () => void;
} }
function findOptionLabel( function findOptionLabel(
@@ -112,6 +131,38 @@ function findOptionLabel(
return selected?.label ?? fallback; return selected?.label ?? fallback;
} }
const FEATURE_ICONS: Record<string, typeof Zap> = {
"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 = { const MODE_ICONS = {
ShieldCheck, ShieldCheck,
ShieldAlert, ShieldAlert,
@@ -162,6 +213,9 @@ function ControlledStatusBar({
canSelectModelProvider, canSelectModelProvider,
favoriteKeys = new Set<string>(), favoriteKeys = new Set<string>(),
onToggleFavoriteModel, onToggleFavoriteModel,
features,
onSetFeature,
onDropdownClose,
}: ControlledAgentStatusBarProps) { }: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isWeb = Platform.OS === "web"; const isWeb = Platform.OS === "web";
@@ -203,7 +257,8 @@ function ControlledStatusBar({
Boolean(providerOptions?.length) || Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) || Boolean(modeOptions?.length) ||
canSelectModel || canSelectModel ||
Boolean(thinkingOptions?.length); Boolean(thinkingOptions?.length) ||
Boolean(features?.length);
if (!hasAnyControl) { if (!hasAnyControl) {
return null; return null;
@@ -280,8 +335,11 @@ function ControlledStatusBar({
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(selector: StatusSelector) => (nextOpen: boolean) => { (selector: StatusSelector) => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null); setOpenSelector(nextOpen ? selector : null);
if (!nextOpen) {
onDropdownClose?.();
}
}, },
[], [onDropdownClose],
); );
const handleSelectorPress = useCallback( const handleSelectorPress = useCallback(
@@ -352,6 +410,7 @@ function ControlledStatusBar({
onToggleFavorite={onToggleFavoriteModel} onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading} isLoading={isModelLoading}
disabled={modelDisabled} disabled={modelDisabled}
onClose={onDropdownClose}
/> />
</View> </View>
</TooltipTrigger> </TooltipTrigger>
@@ -455,6 +514,105 @@ function ControlledStatusBar({
/> />
</> </>
) : null} ) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => 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}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
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}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
</> </>
) : ( ) : (
<> <>
@@ -499,6 +657,7 @@ function ControlledStatusBar({
onToggleFavorite={onToggleFavoriteModel} onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading} isLoading={isModelLoading}
disabled={modelDisabled} disabled={modelDisabled}
onClose={onDropdownClose}
renderTrigger={({ selectedModelLabel }) => ( renderTrigger={({ selectedModelLabel }) => (
<View <View
style={[ style={[
@@ -593,6 +752,83 @@ function ControlledStatusBar({
</DropdownMenu> </DropdownMenu>
</View> </View>
) : null} ) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => 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}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={handleOpenChange(`feature-${feature.id}`)}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
</AdaptiveModalSheet> </AdaptiveModalSheet>
</> </>
)} )}
@@ -602,7 +838,7 @@ function ControlledStatusBar({
const EMPTY_MODES: AgentMode[] = []; const EMPTY_MODES: AgentMode[] = [];
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore( const agent = useSessionStore(
useShallow((state) => { useShallow((state) => {
@@ -614,6 +850,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
currentModeId: currentAgent.currentModeId, currentModeId: currentAgent.currentModeId,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null, runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model, model: currentAgent.model,
features: currentAgent.features,
thinkingOptionId: currentAgent.thinkingOptionId, thinkingOptionId: currentAgent.thinkingOptionId,
} }
: null; : null;
@@ -626,15 +863,52 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
); );
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const { allProviderModels: providerModelsMap, isLoading: isProviderModelsLoading } = const modelsQuery = useQuery({
useProviderModels(serverId); 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 agentProviderDefinitions = useMemo(() => {
const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider); const definition = AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === agent?.provider);
return definition ? [definition] : []; return definition ? [definition] : [];
}, [agent?.provider]); }, [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<string, AgentModelDefinition[]>();
if (agent?.provider && agentProviderModelQuery.data) {
map.set(agent.provider, agentProviderModelQuery.data);
}
return map;
}, [agent?.provider, agentProviderModelQuery.data]);
const models = modelsQuery.data ?? null;
const displayMode = const displayMode =
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label || availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
@@ -682,7 +956,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
} }
selectedModeId={agent.currentModeId ?? undefined} selectedModeId={agent.currentModeId ?? undefined}
providerDefinitions={agentProviderDefinitions} providerDefinitions={agentProviderDefinitions}
allProviderModels={providerModelsMap} allProviderModels={agentProviderModels}
onSelectMode={(modeId) => { onSelectMode={(modeId) => {
if (!client) { if (!client) {
return; return;
@@ -745,7 +1019,17 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); 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} disabled={!client}
/> />
); );
@@ -768,6 +1052,9 @@ export function DraftAgentStatusBar({
thinkingOptions, thinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption, onSelectThinkingOption,
features,
onSetFeature,
onDropdownClose,
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web"; const isWeb = Platform.OS === "web";
@@ -812,6 +1099,7 @@ export function DraftAgentStatusBar({
}} }}
isLoading={isAllModelsLoading} isLoading={isAllModelsLoading}
disabled={disabled} disabled={disabled}
onClose={onDropdownClose}
/> />
<ControlledStatusBar <ControlledStatusBar
provider={selectedProvider} provider={selectedProvider}
@@ -821,6 +1109,9 @@ export function DraftAgentStatusBar({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption} onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
disabled={disabled} disabled={disabled}
/> />
</View> </View>
@@ -833,28 +1124,32 @@ export function DraftAgentStatusBar({
})); }));
return ( return (
<ControlledStatusBar <>
provider={selectedProvider} <ControlledStatusBar
providerDefinitions={providerDefinitions} provider={selectedProvider}
allProviderModels={allProviderModels} providerDefinitions={providerDefinitions}
modeOptions={mappedModeOptions} allProviderModels={allProviderModels}
selectedModeId={effectiveSelectedMode} modeOptions={mappedModeOptions}
onSelectMode={onSelectMode} selectedModeId={effectiveSelectedMode}
modelOptions={modelOptions} onSelectMode={onSelectMode}
selectedModelId={selectedModel} modelOptions={modelOptions}
onSelectModel={(modelId) => onSelectModel(modelId)} selectedModelId={selectedModel}
isModelLoading={isAllModelsLoading} onSelectModel={(modelId) => onSelectModel(modelId)}
favoriteKeys={favoriteKeys} isModelLoading={isAllModelsLoading}
onToggleFavoriteModel={(provider, modelId) => { favoriteKeys={favoriteKeys}
void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { onToggleFavoriteModel={(provider, modelId) => {
console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => {
}); console.warn("[DraftAgentStatusBar] toggle favorite model failed", error);
}} });
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} }}
selectedThinkingOptionId={effectiveSelectedThinkingOption} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
onSelectThinkingOption={onSelectThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
disabled={disabled} onSelectThinkingOption={onSelectThinkingOption}
/> features={features}
onSetFeature={onSetFeature}
disabled={disabled}
/>
</>
); );
} }
@@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
View, View,
Text, Text,
TextInput,
Pressable, Pressable,
Platform, Platform,
ActivityIndicator, ActivityIndicator,
type GestureResponderEvent, type GestureResponderEvent,
} from "react-native"; } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { import {
ArrowLeft, ArrowLeft,
@@ -15,9 +17,14 @@ import {
Search, Search,
Star, Star,
} from "lucide-react-native"; } 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 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 { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import type { FavoriteModelRow } from "@/hooks/use-form-preferences"; import type { FavoriteModelRow } from "@/hooks/use-form-preferences";
@@ -29,8 +36,6 @@ import {
type SelectorModelRow, type SelectorModelRow,
} from "./combined-model-selector.utils"; } from "./combined-model-selector.utils";
const INLINE_MODEL_THRESHOLD = Number.POSITIVE_INFINITY;
type SelectorView = type SelectorView =
| { kind: "all" } | { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string }; | { kind: "provider"; providerId: string; providerLabel: string };
@@ -51,6 +56,7 @@ interface CombinedModelSelectorProps {
disabled: boolean; disabled: boolean;
isOpen: boolean; isOpen: boolean;
}) => React.ReactNode; }) => React.ReactNode;
onClose?: () => void;
disabled?: boolean; disabled?: boolean;
} }
@@ -67,8 +73,6 @@ interface SelectorContentProps {
canSelectProvider: (provider: string) => boolean; canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void; onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void;
onBack?: () => void;
isLoading?: boolean;
} }
function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string {
@@ -100,6 +104,22 @@ function partitionRows(
return { favoriteRows, regularRows }; return { favoriteRows, regularRows };
} }
function sortFavoritesFirst(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
): 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( function groupRowsByProvider(
rows: SelectorModelRow[], rows: SelectorModelRow[],
): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> { ): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> {
@@ -127,6 +147,7 @@ function ModelRow({
isSelected, isSelected,
isFavorite, isFavorite,
disabled = false, disabled = false,
elevated = false,
onPress, onPress,
onToggleFavorite, onToggleFavorite,
}: { }: {
@@ -134,6 +155,7 @@ function ModelRow({
isSelected: boolean; isSelected: boolean;
isFavorite: boolean; isFavorite: boolean;
disabled?: boolean; disabled?: boolean;
elevated?: boolean;
onPress: () => void; onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void; onToggleFavorite?: (provider: string, modelId: string) => void;
}) { }) {
@@ -154,8 +176,9 @@ function ModelRow({
label={row.modelLabel} label={row.modelLabel}
selected={isSelected} selected={isSelected}
disabled={disabled} disabled={disabled}
elevated={elevated}
onPress={onPress} onPress={onPress}
leadingSlot={<ProviderIcon size={14} color={theme.colors.foregroundMuted} />} leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
trailingSlot={ trailingSlot={
onToggleFavorite && !disabled ? ( onToggleFavorite && !disabled ? (
<Pressable <Pressable
@@ -229,7 +252,7 @@ function FavoritesSection({
} }
return ( return (
<View> <View style={styles.favoritesContainer}>
<View style={styles.sectionHeading}> <View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>Favorites</Text> <Text style={styles.sectionHeadingText}>Favorites</Text>
</View> </View>
@@ -240,11 +263,11 @@ function FavoritesSection({
isSelected={row.provider === selectedProvider && row.modelId === selectedModel} isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)} isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)} disabled={!canSelectProvider(row.provider)}
elevated
onPress={() => onSelect(row.provider, row.modelId)} onPress={() => onSelect(row.provider, row.modelId)}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
/> />
))} ))}
<View style={styles.separator} />
</View> </View>
); );
} }
@@ -259,6 +282,7 @@ function GroupedProviderRows({
canSelectProvider, canSelectProvider,
onToggleFavorite, onToggleFavorite,
onDrillDown, onDrillDown,
viewKind,
}: { }: {
providerDefinitions: AgentProviderDefinition[]; providerDefinitions: AgentProviderDefinition[];
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
@@ -269,6 +293,7 @@ function GroupedProviderRows({
canSelectProvider: (provider: string) => boolean; canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void; onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void;
viewKind: SelectorView["kind"];
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -277,19 +302,14 @@ function GroupedProviderRows({
{groupedRows.map((group, index) => { {groupedRows.map((group, index) => {
const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId); const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId);
const ProvIcon = getProviderIcon(group.providerId); const ProvIcon = getProviderIcon(group.providerId);
const isInline = group.rows.length <= INLINE_MODEL_THRESHOLD; const isInline = viewKind === "provider";
return ( return (
<View key={group.providerId}> <View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null} {index > 0 ? <View style={styles.separator} /> : null}
{isInline ? ( {isInline ? (
<> <>
<View style={styles.sectionHeading}> {sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
<Text style={styles.sectionHeadingText}>
{providerDefinition?.label ?? group.providerLabel}
</Text>
</View>
{group.rows.map((row) => (
<ModelRow <ModelRow
key={row.favoriteKey} key={row.favoriteKey}
row={row} row={row}
@@ -310,11 +330,13 @@ function GroupedProviderRows({
pressed && styles.drillDownRowPressed, pressed && styles.drillDownRowPressed,
]} ]}
> >
<ProvIcon size={14} color={theme.colors.foregroundMuted} /> <ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text> <Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}> <View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>{group.rows.length}</Text> <Text style={styles.drillDownCount}>
<ChevronRight size={14} color={theme.colors.foregroundMuted} /> {group.rows.length} {group.rows.length === 1 ? "model" : "models"}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View> </View>
</Pressable> </Pressable>
)} )}
@@ -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<TextInput>(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 (
<View style={styles.providerSearchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<InputComponent
ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, Platform.OS === "web" && { outlineStyle: "none" }]}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
);
}
function SelectorContent({ function SelectorContent({
view, view,
providerDefinitions, providerDefinitions,
@@ -338,9 +400,8 @@ function SelectorContent({
canSelectProvider, canSelectProvider,
onToggleFavorite, onToggleFavorite,
onDrillDown, onDrillDown,
onBack,
isLoading,
}: SelectorContentProps) { }: SelectorContentProps) {
const { theme } = useUnistyles();
const allRows = useMemo( const allRows = useMemo(
() => buildModelRows(providerDefinitions, allProviderModels), () => buildModelRows(providerDefinitions, allProviderModels),
[allProviderModels, providerDefinitions], [allProviderModels, providerDefinitions],
@@ -365,35 +426,41 @@ function SelectorContent({
[favoriteKeys, visibleRows], [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 ( return (
<View> <View>
{view.kind === "provider" ? ( {view.kind === "all" ? (
<ProviderBackButton providerId={view.providerId} providerLabel={view.providerLabel} onBack={onBack} /> <FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
) : null} ) : null}
<SearchInput {filteredGroupedRows.length > 0 ? (
placeholder={view.kind === "provider" ? "Search models..." : "Search models or providers..."}
value={searchQuery}
onChangeText={onSearchChange}
autoFocus={Platform.OS === "web"}
/>
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{groupedRegularRows.length > 0 ? (
<GroupedProviderRows <GroupedProviderRows
providerDefinitions={providerDefinitions} providerDefinitions={providerDefinitions}
groupedRows={groupedRegularRows} groupedRows={filteredGroupedRows}
selectedProvider={selectedProvider} selectedProvider={selectedProvider}
selectedModel={selectedModel} selectedModel={selectedModel}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
@@ -401,22 +468,14 @@ function SelectorContent({
canSelectProvider={canSelectProvider} canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onDrillDown={onDrillDown} onDrillDown={onDrillDown}
viewKind={view.kind}
/> />
) : null} ) : null}
{favoriteRows.length === 0 && groupedRegularRows.length === 0 ? ( {!hasResults ? (
<View style={styles.emptyState}> <View style={styles.emptyState}>
{isLoading ? ( <Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<> <Text style={styles.emptyStateText}>No models match your search</Text>
<ActivityIndicator size="small" color="#777" />
<Text style={styles.emptyStateText}>Loading models…</Text>
</>
) : (
<>
<Search size={16} color="#777" />
<Text style={styles.emptyStateText}>No models match your search</Text>
</>
)}
</View> </View>
) : null} ) : null}
</View> </View>
@@ -448,8 +507,8 @@ function ProviderBackButton({
pressed && styles.backButtonPressed, pressed && styles.backButtonPressed,
]} ]}
> >
<ArrowLeft size={14} color={theme.colors.foregroundMuted} /> <ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ProviderIcon size={14} color={theme.colors.foregroundMuted} /> <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text> <Text style={styles.backButtonText}>{providerLabel}</Text>
</Pressable> </Pressable>
); );
@@ -466,6 +525,7 @@ export function CombinedModelSelector({
favoriteKeys = new Set<string>(), favoriteKeys = new Set<string>(),
onToggleFavorite, onToggleFavorite,
renderTrigger, renderTrigger,
onClose,
disabled = false, disabled = false,
}: CombinedModelSelectorProps) { }: CombinedModelSelectorProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -476,25 +536,35 @@ export function CombinedModelSelector({
const [view, setView] = useState<SelectorView>({ kind: "all" }); const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
// Single-provider mode: only one provider with models → skip Level 1 entirely
const singleProviderView = useMemo<SelectorView | null>(() => {
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( const handleOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
setIsOpen(open); setIsOpen(open);
setView({ kind: "all" }); setView(singleProviderView ?? { kind: "all" });
if (!open) { if (!open) {
setSearchQuery(""); setSearchQuery("");
onClose?.();
} }
}, },
[], [onClose, singleProviderView],
); );
const handleSelect = useCallback( const handleSelect = useCallback(
(provider: string, modelId: string) => { (provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId); onSelect(provider as AgentProvider, modelId);
setIsOpen(false); setIsOpen(false);
setView({ kind: "all" }); setView(singleProviderView ?? { kind: "all" });
setSearchQuery(""); setSearchQuery("");
}, },
[onSelect], [onSelect, singleProviderView],
); );
const ProviderIcon = getProviderIcon(selectedProvider); const ProviderIcon = getProviderIcon(selectedProvider);
@@ -512,6 +582,15 @@ export function CombinedModelSelector({
return model?.label ?? resolveDefaultModelLabel(models); return model?.label ?? resolveDefaultModelLabel(models);
}, [allProviderModels, isLoading, selectedModel, selectedProvider]); }, [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(() => { const triggerLabel = useMemo(() => {
if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") {
return selectedModelLabel; return selectedModelLabel;
@@ -579,7 +658,30 @@ export function CombinedModelSelector({
stackBehavior="push" stackBehavior="push"
anchorRef={anchorRef} anchorRef={anchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
desktopMinWidth={360}
desktopFixedHeight={desktopFixedHeight}
title="Select model" title="Select model"
stickyHeader={
view.kind === "provider" ? (
<View style={styles.level2Header}>
{!singleProviderView ? (
<ProviderBackButton
providerId={view.providerId}
providerLabel={view.providerLabel}
onBack={() => {
setView({ kind: "all" });
setSearchQuery("");
}}
/>
) : null}
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={Platform.OS === "web"}
/>
</View>
) : undefined
}
> >
{isContentReady ? ( {isContentReady ? (
<SelectorContent <SelectorContent
@@ -594,17 +696,9 @@ export function CombinedModelSelector({
onSelect={handleSelect} onSelect={handleSelect}
canSelectProvider={canSelectProvider} canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
isLoading={isLoading}
onDrillDown={(providerId, providerLabel) => { onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel }); setView({ kind: "provider", providerId, providerLabel });
}} }}
onBack={
view.kind === "provider"
? () => {
setView({ kind: "all" });
}
: undefined
}
/> />
) : ( ) : (
<View style={styles.sheetLoadingState}> <View style={styles.sheetLoadingState}>
@@ -646,17 +740,23 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0, paddingVertical: 0,
height: "auto", height: "auto",
}, },
favoritesContainer: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
separator: { separator: {
height: 1, height: 1,
backgroundColor: theme.colors.border, backgroundColor: theme.colors.border,
marginVertical: theme.spacing[1],
}, },
sectionHeading: { sectionHeading: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1], paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
}, },
sectionHeadingText: { sectionHeadingText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -670,6 +770,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
minHeight: 36, minHeight: 36,
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
}, },
drillDownRowHovered: { drillDownRowHovered: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
@@ -679,8 +780,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
drillDownText: { drillDownText: {
flex: 1, flex: 1,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted, color: theme.colors.foreground,
}, },
drillDownTrailing: { drillDownTrailing: {
flexDirection: "row", flexDirection: "row",
@@ -691,6 +792,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
level2Header: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
backButton: { backButton: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -699,9 +805,10 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
}, },
backButtonHovered: { backButtonHovered: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface2,
}, },
backButtonPressed: { backButtonPressed: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -746,4 +853,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, 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,
},
})); }));
+25 -1
View File
@@ -75,6 +75,8 @@ interface ComposerProps {
autoFocus?: boolean; autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */ /** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; 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. */ /** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig; commandDraftConfig?: DraftCommandConfig;
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
@@ -107,6 +109,7 @@ export function Composer({
clearDraft, clearDraft,
autoFocus = false, autoFocus = false,
onAddImages, onAddImages,
onFocusInput,
commandDraftConfig, commandDraftConfig,
onMessageSent, onMessageSent,
onComposerHeightChange, onComposerHeightChange,
@@ -213,6 +216,21 @@ export function Composer({
onAddImages?.(addImages); onAddImages?.(addImages);
}, [addImages, onAddImages]); }, [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( const submitMessage = useCallback(
async (text: string, images?: ImageAttachment[]) => { async (text: string, images?: ImageAttachment[]) => {
onMessageSent?.(); onMessageSent?.();
@@ -422,12 +440,16 @@ export function Composer({
}, },
}); });
return true; return true;
case "message-input.send":
return messageInputRef.current?.runKeyboardAction("send") ?? false;
case "message-input.dictation-toggle": case "message-input.dictation-toggle":
messageInputRef.current?.runKeyboardAction("dictation-toggle"); messageInputRef.current?.runKeyboardAction("dictation-toggle");
return true; return true;
case "message-input.dictation-cancel": case "message-input.dictation-cancel":
messageInputRef.current?.runKeyboardAction("dictation-cancel"); messageInputRef.current?.runKeyboardAction("dictation-cancel");
return true; return true;
case "message-input.dictation-confirm":
return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false;
case "message-input.voice-toggle": case "message-input.voice-toggle":
messageInputRef.current?.runKeyboardAction("voice-toggle"); messageInputRef.current?.runKeyboardAction("voice-toggle");
return true; return true;
@@ -445,8 +467,10 @@ export function Composer({
handlerId: keyboardHandlerIdRef.current, handlerId: keyboardHandlerIdRef.current,
actions: [ actions: [
"message-input.focus", "message-input.focus",
"message-input.send",
"message-input.dictation-toggle", "message-input.dictation-toggle",
"message-input.dictation-cancel", "message-input.dictation-cancel",
"message-input.dictation-confirm",
"message-input.voice-toggle", "message-input.voice-toggle",
"message-input.voice-mute-toggle", "message-input.voice-mute-toggle",
], ],
@@ -621,7 +645,7 @@ export function Composer({
resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} /> <DraftAgentStatusBar {...statusControls} />
) : ( ) : (
<AgentStatusBar agentId={agentId} serverId={serverId} /> <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />
); );
return ( return (
+17 -5
View File
@@ -103,7 +103,7 @@ export interface MessageInputProps {
export interface MessageInputRef { export interface MessageInputRef {
focus: () => void; focus: () => void;
blur: () => void; blur: () => void;
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void; runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean;
/** /**
* Web-only: return the underlying DOM element for focus assertions/retries. * Web-only: return the underlying DOM element for focus assertions/retries.
* May return null if not mounted or on native. * May return null if not mounted or on native.
@@ -247,26 +247,35 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
runKeyboardAction: (action) => { runKeyboardAction: (action) => {
if (action === "focus") { if (action === "focus") {
textInputRef.current?.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") { if (action === "voice-toggle") {
handleToggleRealtimeVoiceShortcut(); handleToggleRealtimeVoiceShortcut();
return; return true;
} }
if (action === "voice-mute-toggle") { if (action === "voice-mute-toggle") {
if (isRealtimeVoiceForCurrentAgent) { if (isRealtimeVoiceForCurrentAgent) {
voice?.toggleMute(); voice?.toggleMute();
} }
return; return true;
} }
if (action === "dictation-cancel") { if (action === "dictation-cancel") {
if (isDictatingRef.current) { if (isDictatingRef.current) {
cancelDictation(); cancelDictation();
} }
return; return true;
} }
if (action === "dictation-toggle") { if (action === "dictation-toggle") {
@@ -276,7 +285,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
} else { } else {
void startDictationIfAvailable(); void startDictationIfAvailable();
} }
return true;
} }
return false;
}, },
getNativeElement: () => { getNativeElement: () => {
if (!IS_WEB) return null; if (!IS_WEB) return null;
@@ -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<string | null>(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 (
<AdaptiveModalSheet
title={providerLabel}
visible={visible}
onClose={onClose}
snapPoints={["50%", "85%"]}
>
{loading ? (
<View style={sheetStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.loadingText}>Fetching diagnostic…</Text>
</View>
) : diagnostic ? (
<ScrollView
horizontal
style={sheetStyles.scrollContainer}
contentContainerStyle={sheetStyles.scrollContent}
>
<Text style={sheetStyles.diagnosticText} selectable>
{diagnostic}
</Text>
</ScrollView>
) : null}
</AdaptiveModalSheet>
);
}
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,
},
}));
@@ -119,6 +119,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}); });
function handleSubmit() { function handleSubmit() {
if (!allAnswered || isResponding) return;
setRespondingAction("submit"); setRespondingAction("submit");
const answers: Record<string, string> = {}; const answers: Record<string, string> = {};
for (let i = 0; i < questions!.length; i++) { for (let i = 0; i < questions!.length; i++) {
@@ -228,7 +229,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
value={otherText} value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)} onChangeText={(text) => setOtherText(qIndex, text)}
onSubmitEditing={handleSubmit}
editable={!isResponding} editable={!isResponding}
blurOnSubmit={false}
/> />
</View> </View>
); );
@@ -10,7 +10,7 @@ import {
type GestureResponderEvent, type GestureResponderEvent,
} from "react-native"; } from "react-native";
import * as Haptics from "expo-haptics"; import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query"; import { useMutation, useQueries } from "@tanstack/react-query";
import { import {
useCallback, useCallback,
useMemo, useMemo,
@@ -39,6 +39,7 @@ import {
Monitor, Monitor,
MoreVertical, MoreVertical,
Plus, Plus,
Trash2,
} from "lucide-react-native"; } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; 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 { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { import {
type SidebarProjectEntry, type SidebarProjectEntry,
type SidebarWorkspaceEntry, 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 { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; 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 { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
@@ -93,6 +95,7 @@ import {
resolveWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution"; } from "@/utils/workspace-execution";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { createNameId } from "mnemonic-id";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) { if (!icon) {
@@ -150,6 +153,8 @@ interface ProjectHeaderRowProps {
isDragging: boolean; isDragging: boolean;
isArchiving?: boolean; isArchiving?: boolean;
menuController: ReturnType<typeof useContextMenu> | null; menuController: ReturnType<typeof useContextMenu> | null;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
} }
@@ -702,24 +707,25 @@ function ProjectHeaderRow({
canCreateWorktree, canCreateWorktree,
isProjectActive = false, isProjectActive = false,
onWorkspacePress, onWorkspacePress,
onWorktreeCreated,
shortcutNumber = null, shortcutNumber = null,
showShortcutBadge = false, showShortcutBadge = false,
drag, drag,
isDragging, isDragging,
isArchiving = false, isArchiving = false,
menuController, menuController,
onRemoveProject,
removeProjectStatus = "idle",
dragHandleProps, dragHandleProps,
}: ProjectHeaderRowProps) { }: ProjectHeaderRowProps) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = isCompactFormFactor(); const isMobileBreakpoint = isCompactFormFactor();
const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup); const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup);
const handleBeginWorkspaceSetup = useCallback(() => { const handleBeginWorkspaceSetup = useCallback(() => {
if (!serverId) { if (!serverId) {
return; return;
} }
onWorkspacePress?.();
beginWorkspaceSetup({ beginWorkspaceSetup({
serverId, serverId,
sourceDirectory: project.iconWorkingDir, sourceDirectory: project.iconWorkingDir,
@@ -727,12 +733,13 @@ function ProjectHeaderRow({
creationMethod: "create_worktree", creationMethod: "create_worktree",
navigationMethod: "navigate", navigationMethod: "navigate",
}); });
onWorkspacePress?.();
}, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]); }, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
useKeyboardActionHandler({ useKeyboardActionHandler({
handlerId: `worktree-new-${project.projectKey}`, handlerId: `worktree-new-${project.projectKey}`,
actions: ["worktree.new"], actions: ["worktree.new"],
enabled: isProjectActive && canCreateWorktree, enabled: isProjectActive && canCreateWorktree && Boolean(serverId),
priority: 0, priority: 0,
handle: () => { handle: () => {
handleBeginWorkspaceSetup(); handleBeginWorkspaceSetup();
@@ -776,16 +783,54 @@ function ProjectHeaderRow({
</Text> </Text>
</View> </View>
</View> </View>
{canCreateWorktree ? ( <View style={styles.projectTrailingActions}>
<NewWorktreeButton {canCreateWorktree ? (
displayName={displayName} <NewWorktreeButton
onPress={handleBeginWorkspaceSetup} displayName={displayName}
visible={isHovered || isMobileBreakpoint} onPress={handleBeginWorkspaceSetup}
loading={false} visible={isHovered || isMobileBreakpoint}
showShortcutHint={isProjectActive} showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`} testID={`sidebar-project-new-worktree-${project.projectKey}`}
/> />
) : null} ) : null}
{onRemoveProject ? (
<View
style={!(isHovered || isMobileBreakpoint) && styles.projectKebabButtonHidden}
pointerEvents={isHovered || isMobileBreakpoint ? "auto" : "none"}
>
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.projectKebabButton,
hovered && styles.projectKebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Project actions"
testID={`sidebar-project-kebab-${project.projectKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${project.projectKey}`}
leading={<Trash2 size={14} color={theme.colors.foregroundMuted} />}
status={removeProjectStatus}
pendingLabel="Removing..."
onSelect={onRemoveProject}
>
Remove project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
</View>
{showShortcutBadge && shortcutNumber !== null ? ( {showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}> <View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text> <Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
@@ -1406,6 +1451,8 @@ function FlattenedProjectRow({
isDragging, isDragging,
dragHandleProps, dragHandleProps,
isProjectActive = false, isProjectActive = false,
onRemoveProject,
removeProjectStatus,
}: { }: {
project: SidebarProjectEntry; project: SidebarProjectEntry;
displayName: string; displayName: string;
@@ -1421,6 +1468,8 @@ function FlattenedProjectRow({
isDragging: boolean; isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps; dragHandleProps?: DraggableListDragHandleProps;
isProjectActive?: boolean; isProjectActive?: boolean;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
}) { }) {
if (project.projectKind === "directory") { if (project.projectKind === "directory") {
return ( return (
@@ -1459,6 +1508,8 @@ function FlattenedProjectRow({
drag={drag} drag={drag}
isDragging={isDragging} isDragging={isDragging}
menuController={null} menuController={null}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
/> />
); );
@@ -1629,6 +1680,48 @@ function ProjectBlock({
[onWorkspaceReorder, project.projectKey], [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 ( return (
<View style={styles.projectBlock}> <View style={styles.projectBlock}>
{rowModel.kind === "workspace_link" ? ( {rowModel.kind === "workspace_link" ? (
@@ -1653,6 +1746,8 @@ function ProjectBlock({
isDragging={isDragging} isDragging={isDragging}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
isProjectActive={isProjectActive} isProjectActive={isProjectActive}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
/> />
) : ( ) : (
<> <>
@@ -1671,7 +1766,10 @@ function ProjectBlock({
onWorktreeCreated={onWorktreeCreated} onWorktreeCreated={onWorktreeCreated}
drag={drag} drag={drag}
isDragging={isDragging} isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null} menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps} dragHandleProps={dragHandleProps}
/> />
@@ -2172,6 +2270,26 @@ const styles = StyleSheet.create((theme) => ({
projectIconActionButtonHidden: { projectIconActionButtonHidden: {
opacity: 0, 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: { projectTrailingControlSlot: {
width: 24, width: 24,
height: 24, height: 24,
+57 -14
View File
@@ -73,6 +73,12 @@ export interface ComboboxProps {
* for that combobox instance to avoid animation overriding hidden opacity. * for that combobox instance to avoid animation overriding hidden opacity.
*/ */
desktopPreventInitialFlash?: boolean; 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<View | null>; anchorRef: React.RefObject<View | null>;
children?: ReactNode; children?: ReactNode;
} }
@@ -150,6 +156,8 @@ export interface ComboboxItemProps {
selected?: boolean; selected?: boolean;
active?: boolean; active?: boolean;
disabled?: boolean; disabled?: boolean;
/** When true, bumps hover/pressed colors up one surface level (for items on elevated backgrounds). */
elevated?: boolean;
onPress: () => void; onPress: () => void;
testID?: string; testID?: string;
} }
@@ -163,6 +171,7 @@ export function ComboboxItem({
selected, selected,
active, active,
disabled, disabled,
elevated,
onPress, onPress,
testID, testID,
}: ComboboxItemProps): ReactElement { }: ComboboxItemProps): ReactElement {
@@ -187,8 +196,8 @@ export function ComboboxItem({
onPress={onPress} onPress={onPress}
style={({ pressed, hovered = false }) => [ style={({ pressed, hovered = false }) => [
styles.comboboxItem, styles.comboboxItem,
hovered && styles.comboboxItemHovered, hovered && (elevated ? styles.comboboxItemHoveredElevated : styles.comboboxItemHovered),
pressed && styles.comboboxItemPressed, pressed && (elevated ? styles.comboboxItemPressedElevated : styles.comboboxItemPressed),
active && styles.comboboxItemActive, active && styles.comboboxItemActive,
disabled && styles.comboboxItemDisabled, disabled && styles.comboboxItemDisabled,
]} ]}
@@ -246,6 +255,9 @@ export function Combobox({
stackBehavior, stackBehavior,
desktopPlacement = "top-start", desktopPlacement = "top-start",
desktopPreventInitialFlash = true, desktopPreventInitialFlash = true,
desktopMinWidth,
desktopFixedHeight,
stickyHeader,
anchorRef, anchorRef,
children, children,
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
@@ -390,12 +402,27 @@ export function Combobox({
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin);
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash; 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 const desktopPositionStyle = isDesktopAboveSearch
? { ? {
left: floatingLeft ?? 0, left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0, bottom: desktopAboveSearchBottom ?? 0,
} }
: floatingStyles; : useStableBottom
? {
left: floatingLeft ?? 0,
bottom: Math.max(windowHeight - referenceTop!, collisionPadding),
}
: floatingStyles;
useEffect(() => { useEffect(() => {
if (!isMobile) return; if (!isMobile) return;
@@ -662,6 +689,7 @@ export function Combobox({
<View style={styles.bottomSheetHeader}> <View style={styles.bottomSheetHeader}>
<Text style={styles.comboboxTitle}>{title}</Text> <Text style={styles.comboboxTitle}>{title}</Text>
</View> </View>
{stickyHeader}
<BottomSheetScrollView <BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent} contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -687,13 +715,16 @@ export function Combobox({
styles.desktopContainer, styles.desktopContainer,
{ {
position: "absolute", position: "absolute",
minWidth: referenceWidth ?? 200, minWidth: desktopMinWidth ?? referenceWidth ?? 200,
maxWidth: 400, maxWidth: Math.max(400, desktopMinWidth ?? 0),
}, },
desktopFixedHeight != null
? { minHeight: desktopFixedHeight, maxHeight: desktopFixedHeight }
: null,
desktopPositionStyle, desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null, shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number" typeof availableSize?.height === "number"
? { maxHeight: Math.min(availableSize.height, 400) } ? { maxHeight: Math.min(availableSize.height, desktopFixedHeight ?? 400) }
: null, : null,
]} ]}
ref={refs.setFloating} ref={refs.setFloating}
@@ -701,14 +732,17 @@ export function Combobox({
onLayout={() => update()} onLayout={() => update()}
> >
{children ? ( {children ? (
<ScrollView <>
contentContainerStyle={styles.desktopScrollContent} {stickyHeader}
keyboardShouldPersistTaps="handled" <ScrollView
showsVerticalScrollIndicator={false} contentContainerStyle={styles.desktopChildrenScrollContent}
style={styles.desktopScroll} keyboardShouldPersistTaps="handled"
> showsVerticalScrollIndicator={false}
{content} style={styles.desktopScroll}
</ScrollView> >
{content}
</ScrollView>
</>
) : ( ) : (
<> <>
{effectiveOptionsPosition === "above-search" ? ( {effectiveOptionsPosition === "above-search" ? (
@@ -783,9 +817,15 @@ const styles = StyleSheet.create((theme) => ({
comboboxItemHovered: { comboboxItemHovered: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
comboboxItemHoveredElevated: {
backgroundColor: theme.colors.surface2,
},
comboboxItemPressed: { comboboxItemPressed: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
comboboxItemPressedElevated: {
backgroundColor: theme.colors.surface2,
},
comboboxItemActive: { comboboxItemActive: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
@@ -876,6 +916,9 @@ const styles = StyleSheet.create((theme) => ({
desktopScrollContent: { desktopScrollContent: {
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
desktopChildrenScrollContent: {
// No padding — custom children (e.g. model selector) control their own spacing
},
desktopScrollContentAboveSearch: { desktopScrollContentAboveSearch: {
flexGrow: 1, flexGrow: 1,
justifyContent: "flex-end", justifyContent: "flex-end",
@@ -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 (
<View
style={[
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
]}
>
<Text
style={[
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "error" && styles.pillTextError,
]}
>
{label}
</Text>
</View>
);
}
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],
},
}));
@@ -7,6 +7,7 @@ import {
type CreateAgentInitialValues, type CreateAgentInitialValues,
type UseAgentFormStateResult, type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state"; } from "@/hooks/use-agent-form-state";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
@@ -35,6 +36,7 @@ type DraftComposerState = UseAgentFormStateResult & {
workingDir: string; workingDir: string;
effectiveModelId: string; effectiveModelId: string;
effectiveThinkingOptionId: string; effectiveThinkingOptionId: string;
featureValues: Record<string, unknown> | undefined;
statusControls: DraftAgentStatusBarProps; statusControls: DraftAgentStatusBarProps;
commandDraftConfig: DraftCommandConfig | undefined; commandDraftConfig: DraftCommandConfig | undefined;
}; };
@@ -116,6 +118,7 @@ function buildDraftComposerCommandConfig(input: {
selectedMode: string; selectedMode: string;
effectiveModelId: string; effectiveModelId: string;
effectiveThinkingOptionId: string; effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined { }): DraftCommandConfig | undefined {
const cwd = input.cwd.trim(); const cwd = input.cwd.trim();
if (!cwd) { if (!cwd) {
@@ -130,13 +133,17 @@ function buildDraftComposerCommandConfig(input: {
...(input.effectiveThinkingOptionId ...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId } ? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}), : {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
}; };
} }
function buildDraftStatusControls(input: { function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult; formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
onSetFeature?: DraftAgentStatusBarProps["onSetFeature"];
onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"];
}): DraftAgentStatusBarProps { }): DraftAgentStatusBarProps {
const { formState } = input; const { formState, features, onSetFeature, onDropdownClose } = input;
return { return {
providerDefinitions: formState.providerDefinitions, providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider, selectedProvider: formState.selectedProvider,
@@ -154,6 +161,9 @@ function buildDraftStatusControls(input: {
thinkingOptions: formState.availableThinkingOptions, thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId, selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser, onSelectThinkingOption: formState.setThinkingOptionFromUser,
features,
onSetFeature,
onDropdownClose,
}; };
} }
@@ -316,6 +326,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
); );
const workingDir = lockedWorkingDir || formState.workingDir; 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( const commandDraftConfig = useMemo(
() => () =>
@@ -327,12 +349,14 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
selectedMode: formState.selectedMode, selectedMode: formState.selectedMode,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
featureValues: draftFeatureValues,
}) })
: undefined, : undefined,
[ [
composerOptions, composerOptions,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
draftFeatureValues,
workingDir, workingDir,
formState.modeOptions, formState.modeOptions,
formState.selectedMode, formState.selectedMode,
@@ -350,7 +374,12 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
workingDir, workingDir,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
statusControls: buildDraftStatusControls({ formState }), featureValues: draftFeatureValues,
statusControls: buildDraftStatusControls({
formState,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
}),
commandDraftConfig, commandDraftConfig,
}; };
}, [ }, [
@@ -358,7 +387,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
composerOptions, composerOptions,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
draftFeatures,
draftFeatureValues,
formState, formState,
setDraftFeatureValue,
workingDir, workingDir,
]); ]);
@@ -129,6 +129,11 @@ export function useKeyboardShortcuts({
id: "message-input.focus", id: "message-input.focus",
scope: "message-input", scope: "message-input",
}); });
case "send":
return keyboardActionDispatcher.dispatch({
id: "message-input.send",
scope: "message-input",
});
case "dictation-toggle": case "dictation-toggle":
return keyboardActionDispatcher.dispatch({ return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-toggle", id: "message-input.dictation-toggle",
@@ -139,6 +144,11 @@ export function useKeyboardShortcuts({
id: "message-input.dictation-cancel", id: "message-input.dictation-cancel",
scope: "message-input", scope: "message-input",
}); });
case "dictation-confirm":
return keyboardActionDispatcher.dispatch({
id: "message-input.dictation-confirm",
scope: "message-input",
});
case "voice-toggle": case "voice-toggle":
return keyboardActionDispatcher.dispatch({ return keyboardActionDispatcher.dispatch({
id: "message-input.voice-toggle", id: "message-input.voice-toggle",
+1
View File
@@ -11,6 +11,7 @@ export type MessageInputKeyboardActionKind =
| "queue" | "queue"
| "dictation-toggle" | "dictation-toggle"
| "dictation-cancel" | "dictation-cancel"
| "dictation-confirm"
| "voice-toggle" | "voice-toggle"
| "voice-mute-toggle"; | "voice-mute-toggle";
@@ -2,8 +2,10 @@ export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "work
export type KeyboardActionId = export type KeyboardActionId =
| "message-input.focus" | "message-input.focus"
| "message-input.send"
| "message-input.dictation-toggle" | "message-input.dictation-toggle"
| "message-input.dictation-cancel" | "message-input.dictation-cancel"
| "message-input.dictation-confirm"
| "message-input.voice-toggle" | "message-input.voice-toggle"
| "message-input.voice-mute-toggle" | "message-input.voice-mute-toggle"
| "workspace.tab.new" | "workspace.tab.new"
@@ -27,8 +29,10 @@ export type KeyboardActionId =
export type KeyboardActionDefinition = export type KeyboardActionDefinition =
| { id: "message-input.focus"; scope: KeyboardActionScope } | { id: "message-input.focus"; scope: KeyboardActionScope }
| { id: "message-input.send"; scope: KeyboardActionScope }
| { id: "message-input.dictation-toggle"; scope: KeyboardActionScope } | { id: "message-input.dictation-toggle"; scope: KeyboardActionScope }
| { id: "message-input.dictation-cancel"; 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-toggle"; scope: KeyboardActionScope }
| { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope } | { id: "message-input.voice-mute-toggle"; scope: KeyboardActionScope }
| { id: "workspace.tab.new"; scope: KeyboardActionScope } | { id: "workspace.tab.new"; scope: KeyboardActionScope }
@@ -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", id: "message-input-voice-mute-toggle",
action: "message-input.action", action: "message-input.action",
+138 -1
View File
@@ -21,6 +21,7 @@ import {
Info, Info,
Shield, Shield,
Puzzle, Puzzle,
Blocks,
} from "lucide-react-native"; } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection"; 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 { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { isCompactFormFactor } from "@/constants/layout"; 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 // Section definitions
@@ -72,6 +78,7 @@ type SettingsSectionId =
| "appearance" | "appearance"
| "shortcuts" | "shortcuts"
| "integrations" | "integrations"
| "providers"
| "diagnostics" | "diagnostics"
| "about" | "about"
| "permissions" | "permissions"
@@ -99,6 +106,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
} }
sections.push( sections.push(
{ id: "providers", label: "Providers", icon: Blocks },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope }, { id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info }, { 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<ProviderSnapshotEntry[]>([]);
const [loading, setLoading] = useState(false);
const [diagnosticProvider, setDiagnosticProvider] = useState<string | null>(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 (
<>
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Providers</Text>
{!hasServer || !isConnected ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Connect to a host to see providers</Text>
</View>
) : loading ? (
<View style={[settingsStyles.card, styles.emptyCard]}>
<Text style={styles.emptyText}>Loading...</Text>
</View>
) : (
<View style={[settingsStyles.card, styles.audioCard]}>
{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 (
<View key={def.id} style={styles.audioRow}>
<View
style={[
styles.audioRowContent,
{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] },
]}
>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foreground} />
<Text style={styles.audioRowTitle}>{def.label}</Text>
</View>
<View style={styles.providerActions}>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready"
? "success"
: status === "error"
? "error"
: "muted"
}
/>
<Button
variant="secondary"
size="sm"
onPress={() => setDiagnosticProvider(def.id)}
>
Diagnostic
</Button>
</View>
</View>
);
})}
</View>
)}
</View>
{diagnosticProvider ? (
<ProviderDiagnosticSheet
provider={diagnosticProvider}
visible
onClose={() => setDiagnosticProvider(null)}
serverId={routeServerId}
/>
) : null}
</>
);
}
interface DiagnosticsSectionProps { interface DiagnosticsSectionProps {
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>; voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
@@ -486,6 +609,7 @@ interface SettingsSectionContentProps {
sectionId: SettingsSectionId; sectionId: SettingsSectionId;
hostsProps: HostsSectionProps; hostsProps: HostsSectionProps;
appearanceProps: AppearanceSectionProps; appearanceProps: AppearanceSectionProps;
providersProps: ProvidersSectionProps;
diagnosticsProps: DiagnosticsSectionProps; diagnosticsProps: DiagnosticsSectionProps;
aboutProps: AboutSectionProps; aboutProps: AboutSectionProps;
appVersion: string | null; appVersion: string | null;
@@ -497,6 +621,7 @@ function SettingsSectionContent({
sectionId, sectionId,
hostsProps, hostsProps,
appearanceProps, appearanceProps,
providersProps,
diagnosticsProps, diagnosticsProps,
aboutProps, aboutProps,
appVersion, appVersion,
@@ -510,6 +635,8 @@ function SettingsSectionContent({
return <AppearanceSection {...appearanceProps} />; return <AppearanceSection {...appearanceProps} />;
case "shortcuts": case "shortcuts":
return <KeyboardShortcutsSection />; return <KeyboardShortcutsSection />;
case "providers":
return <ProvidersSection {...providersProps} />;
case "diagnostics": case "diagnostics":
return <DiagnosticsSection {...diagnosticsProps} />; return <DiagnosticsSection {...diagnosticsProps} />;
case "about": case "about":
@@ -567,7 +694,7 @@ function SettingsDesktopLayout({ sections, sectionContentProps }: SettingsLayout
const isSelected = section.id === selectedSectionId; const isSelected = section.id === selectedSectionId;
const IconComponent = section.icon; const IconComponent = section.icon;
const showSeparator = const showSeparator =
section.id === "integrations" || section.id === "diagnostics"; section.id === "integrations" || section.id === "providers";
return ( return (
<View key={section.id}> <View key={section.id}>
{showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null} {showSeparator ? <View style={desktopStyles.sidebarSeparator} /> : null}
@@ -939,6 +1066,10 @@ export default function SettingsScreen() {
handleThemeChange, handleThemeChange,
}; };
const providersProps: ProvidersSectionProps = {
routeServerId,
};
const diagnosticsProps: DiagnosticsSectionProps = { const diagnosticsProps: DiagnosticsSectionProps = {
voiceAudioEngine, voiceAudioEngine,
isPlaybackTestRunning, isPlaybackTestRunning,
@@ -954,6 +1085,7 @@ export default function SettingsScreen() {
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = { const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
hostsProps, hostsProps,
appearanceProps, appearanceProps,
providersProps,
diagnosticsProps, diagnosticsProps,
aboutProps, aboutProps,
appVersion, appVersion,
@@ -1761,6 +1893,11 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
}, },
providerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
aboutValue: { aboutValue: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -6,6 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}): AgentSessionConfig { }): AgentSessionConfig {
return { return {
provider: input.provider, provider: input.provider,
@@ -13,5 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: {
...(input.modeId ? { modeId: input.modeId } : {}), ...(input.modeId ? { modeId: input.modeId } : {}),
...(input.model ? { model: input.model } : {}), ...(input.model ? { model: input.model } : {}),
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}), ...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
}; };
} }
@@ -147,6 +147,7 @@ export function WorkspaceDraftAgentTab({
title: "Agent", title: "Agent",
cwd: workspaceDirectory, cwd: workspaceDirectory,
model, model,
features: composerState.statusControls.features,
thinkingOptionId, thinkingOptionId,
labels: {}, labels: {},
}; };
@@ -166,6 +167,7 @@ export function WorkspaceDraftAgentTab({
: {}), : {}),
model: composerState.effectiveModelId || undefined, model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
featureValues: composerState.featureValues,
}); });
const imagesData = await encodeImages(images); const imagesData = await encodeImages(images);
@@ -195,6 +197,63 @@ export function WorkspaceDraftAgentTab({
addImagesRef.current = addImages; addImagesRef.current = addImages;
}, []); }, []);
const focusInputRef = useRef<(() => void) | null>(null);
const handleFocusInputCallback = useCallback((focus: () => void) => {
focusInputRef.current = focus;
}, []);
const handleProviderSelectWithFocus = useCallback(
(provider: Parameters<typeof composerState.setProviderFromUser>[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<typeof composerState.setProviderAndModelFromUser>[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 ( return (
<FileDropZone onFilesDropped={handleFilesDropped}> <FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}> <View style={styles.container}>
@@ -242,9 +301,17 @@ export function WorkspaceDraftAgentTab({
clearDraft={draftInput.clear} clearDraft={draftInput.clear}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
onAddImages={handleAddImagesCallback} onAddImages={handleAddImagesCallback}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig} commandDraftConfig={composerState.commandDraftConfig}
statusControls={{ statusControls={{
...composerState.statusControls, ...composerState.statusControls,
onSelectProvider: handleProviderSelectWithFocus,
onSelectMode: handleModeSelectWithFocus,
onSelectModel: handleModelSelectWithFocus,
onSelectProviderAndModel: handleProviderAndModelSelectWithFocus,
onSelectThinkingOption: handleThinkingOptionSelectWithFocus,
onSetFeature: handleSetFeatureWithFocus,
onDropdownClose: () => focusInputRef.current?.(),
disabled: isSubmitting, disabled: isSubmitting,
}} }}
/> />
@@ -70,6 +70,7 @@ import {
} from "@/utils/workspace-tab-identity"; } from "@/utils/workspace-tab-identity";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProviderModels } from "@/hooks/use-provider-models"; 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 { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import { import {
checkoutStatusQueryKey, checkoutStatusQueryKey,
@@ -121,6 +122,7 @@ import { findAdjacentPane } from "@/utils/split-navigation";
import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_UI_TABS: WorkspaceTab[] = [];
const EMPTY_SET = new Set<string>(); const EMPTY_SET = new Set<string>();
@@ -858,6 +860,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const workspaceLayout = useWorkspaceLayoutStore((state) => const workspaceLayout = useWorkspaceLayoutStore((state) =>
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
); );
const workspaceSetupSnapshot = useWorkspaceSetupStore((state) =>
persistenceKey ? state.snapshots[persistenceKey] ?? null : null,
);
const uiTabs = useMemo( const uiTabs = useMemo(
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
[workspaceLayout], [workspaceLayout],
@@ -1013,6 +1018,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
() => focusedPaneTabState.tabs.map((tab) => tab.descriptor), () => focusedPaneTabState.tabs.map((tab) => tab.descriptor),
[focusedPaneTabState.tabs], [focusedPaneTabState.tabs],
); );
const hasSetupTab = useMemo(
() =>
uiTabs.some(
(tab) =>
tab.target.kind === "setup" && tab.target.workspaceId === normalizedWorkspaceId,
),
[normalizedWorkspaceId, uiTabs],
);
const navigateToTabId = useCallback( const navigateToTabId = useCallback(
function navigateToTabId(tabId: string) { function navigateToTabId(tabId: string) {
@@ -1025,6 +1038,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
); );
const emptyWorkspaceSeedRef = useRef<string | null>(null); const emptyWorkspaceSeedRef = useRef<string | null>(null);
const autoOpenedSetupTabWorkspaceRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
if (!persistenceKey) { if (!persistenceKey) {
return; return;
@@ -1053,6 +1067,56 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
workspaceAgentVisibility.activeAgentIds.size, 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( const handleOpenFileFromExplorer = useCallback(
function handleOpenFileFromExplorer(filePath: string) { function handleOpenFileFromExplorer(filePath: string) {
if (isMobile) { if (isMobile) {
@@ -42,6 +42,9 @@ import type {
ListProviderModelsResponseMessage, ListProviderModelsResponseMessage,
ListProviderModesResponseMessage, ListProviderModesResponseMessage,
ListAvailableProvidersResponse, ListAvailableProvidersResponse,
GetProvidersSnapshotResponseMessage,
RefreshProvidersSnapshotResponseMessage,
ProviderDiagnosticResponseMessage,
ListTerminalsResponse, ListTerminalsResponse,
CreateTerminalResponse, CreateTerminalResponse,
SubscribeTerminalResponse, SubscribeTerminalResponse,
@@ -152,6 +155,10 @@ export type DaemonEvent =
requestId: string; requestId: string;
resolution: AgentPermissionResponse; resolution: AgentPermissionResponse;
} }
| {
type: "providers_snapshot_update";
payload: Extract<SessionOutboundMessage, { type: "providers_snapshot_update" }>["payload"];
}
| { type: "error"; message: string }; | { type: "error"; message: string };
export type DaemonEventHandler = (event: DaemonEvent) => void; export type DaemonEventHandler = (event: DaemonEvent) => void;
@@ -228,6 +235,9 @@ type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick< type ListCommandsDraftConfig = Pick<
AgentSessionConfig, AgentSessionConfig,
@@ -2574,6 +2584,51 @@ export class DaemonClient {
}); });
} }
async getProvidersSnapshot(options?: {
cwd?: string;
requestId?: string;
}): Promise<GetProvidersSnapshotPayload> {
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<RefreshProvidersSnapshotPayload> {
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<ProviderDiagnosticPayload> {
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<ListCommandsPayload>; async listCommands(agentId: string, requestId?: string): Promise<ListCommandsPayload>;
async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>; async listCommands(agentId: string, options?: ListCommandsOptions): Promise<ListCommandsPayload>;
async listCommands( async listCommands(
@@ -3668,6 +3723,11 @@ export class DaemonClient {
requestId: msg.payload.requestId, requestId: msg.payload.requestId,
resolution: msg.payload.resolution, resolution: msg.payload.resolution,
}; };
case "providers_snapshot_update":
return {
type: "providers_snapshot_update",
payload: msg.payload,
};
default: default:
return null; return null;
} }
@@ -45,6 +45,8 @@ export type AgentMode = {
description?: string; description?: string;
}; };
export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
export type AgentModelDefinition = { export type AgentModelDefinition = {
provider: AgentProvider; provider: AgentProvider;
id: string; id: string;
@@ -64,6 +66,15 @@ export type AgentSelectOption = {
metadata?: AgentMetadata; metadata?: AgentMetadata;
}; };
export interface ProviderSnapshotEntry {
provider: AgentProvider;
status: ProviderStatus;
error?: string;
models?: AgentModelDefinition[];
modes?: AgentMode[];
fetchedAt?: string;
}
export type AgentFeatureToggle = { export type AgentFeatureToggle = {
type: "toggle"; type: "toggle";
id: string; id: string;
@@ -469,4 +480,5 @@ export interface AgentClient {
* Returns true if available, false otherwise. * Returns true if available, false otherwise.
*/ */
isAvailable(): Promise<boolean>; isAvailable(): Promise<boolean>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
} }
@@ -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<string, Map<AgentProvider, ProviderSnapshotEntry>>();
private readonly warmUps = new Map<string, Promise<void>>();
private readonly events = new EventEmitter();
private destroyed = false;
constructor(
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
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<AgentProvider, ProviderSnapshotEntry> {
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
for (const provider of this.getProviderIds()) {
entries.set(provider, {
provider,
status: "loading",
});
}
return entries;
}
private async warmUp(cwd?: string): Promise<void> {
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<void> {
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<AgentProvider, ProviderSnapshotEntry> {
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<AgentProvider, ProviderSnapshotEntry>,
): 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";
}
@@ -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(" ");
}
+108
View File
@@ -68,6 +68,7 @@ export type AgentMcpTransportFactory = () => Promise<Transport>;
import { buildProviderRegistry } from "./agent/provider-registry.js"; import { buildProviderRegistry } from "./agent/provider-registry.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { AgentManager } from "./agent/agent-manager.js"; import { AgentManager } from "./agent/agent-manager.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { import type {
AgentTimelineCursor, AgentTimelineCursor,
AgentTimelineFetchDirection, AgentTimelineFetchDirection,
@@ -96,6 +97,7 @@ import type {
AgentStreamEvent, AgentStreamEvent,
AgentProvider, AgentProvider,
AgentPersistenceHandle, AgentPersistenceHandle,
ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
@@ -392,6 +394,7 @@ export type SessionOptions = {
stt: Resolvable<SpeechToTextProvider | null>; stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>; tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null; terminalManager: TerminalManager | null;
providerSnapshotManager?: ProviderSnapshotManager;
serviceRouteStore?: ServiceRouteStore; serviceRouteStore?: ServiceRouteStore;
getDaemonTcpPort?: () => number | null; getDaemonTcpPort?: () => number | null;
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null; resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null;
@@ -568,6 +571,8 @@ export class Session {
} | null = null; } | null = null;
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000; private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
private readonly terminalManager: TerminalManager | null; private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager | null;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly serviceRouteStore: ServiceRouteStore | null; private readonly serviceRouteStore: ServiceRouteStore | null;
private readonly getDaemonTcpPort: (() => number | null) | null; private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null; private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | null) | null;
@@ -625,6 +630,7 @@ export class Session {
stt, stt,
tts, tts,
terminalManager, terminalManager,
providerSnapshotManager,
serviceRouteStore, serviceRouteStore,
getDaemonTcpPort, getDaemonTcpPort,
resolveServiceStatus, resolveServiceStatus,
@@ -665,6 +671,7 @@ export class Session {
}); });
this.createAgentMcpTransport = createAgentMcpTransport; this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager; this.terminalManager = terminalManager;
this.providerSnapshotManager = providerSnapshotManager ?? null;
this.serviceRouteStore = serviceRouteStore ?? null; this.serviceRouteStore = serviceRouteStore ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.resolveServiceStatus = resolveServiceStatus ?? null; this.resolveServiceStatus = resolveServiceStatus ?? null;
@@ -673,6 +680,25 @@ export class Session {
this.handleTerminalsChanged(event), 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.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
@@ -1624,6 +1650,18 @@ export class Session {
await this.handleListAvailableProvidersRequest(msg); await this.handleListAvailableProvidersRequest(msg);
break; 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": case "clear_agent_attention":
await this.handleClearAgentAttention(msg.agentId); await this.handleClearAgentAttention(msg.agentId);
break; break;
@@ -3099,6 +3137,72 @@ export class Session {
} }
} }
private async handleGetProvidersSnapshotRequest(
msg: Extract<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "provider_diagnostic_request" }>,
): Promise<void> {
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 { private assertSafeGitRef(ref: string, label: string): void {
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
throw new Error(`Invalid ${label}: ${ref}`); throw new Error(`Invalid ${label}: ${ref}`);
@@ -6913,6 +7017,10 @@ export class Session {
this.unsubscribeTerminalsChanged(); this.unsubscribeTerminalsChanged();
this.unsubscribeTerminalsChanged = null; this.unsubscribeTerminalsChanged = null;
} }
if (this.unsubscribeProviderSnapshotEvents) {
this.unsubscribeProviderSnapshotEvents();
this.unsubscribeProviderSnapshotEvents = null;
}
this.subscribedTerminalDirectories.clear(); this.subscribedTerminalDirectories.clear();
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) { for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
@@ -31,6 +31,8 @@ import { isHostAllowed } from "./allowed-hosts.js";
import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.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 { PushTokenStore } from "./push/token-store.js";
import { PushService } from "./push/push-service.js"; import { PushService } from "./push/push-service.js";
import type { ServiceRouteStore } from "./service-proxy.js"; import type { ServiceRouteStore } from "./service-proxy.js";
@@ -258,6 +260,7 @@ export class VoiceAssistantWebSocketServer {
private readonly voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>(); private readonly voiceSpeakHandlers = new Map<string, VoiceSpeakHandler>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>(); private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private serverCapabilities: ServerCapabilities | undefined; private serverCapabilities: ServerCapabilities | undefined;
private runtimeWindowStartedAt = Date.now(); private runtimeWindowStartedAt = Date.now();
@@ -351,6 +354,13 @@ export class VoiceAssistantWebSocketServer {
this.voice = voice ?? null; this.voice = voice ?? null;
this.dictation = dictation ?? null; this.dictation = dictation ?? null;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; 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.onLifecycleIntent = onLifecycleIntent ?? null;
this.serviceRouteStore = serviceRouteStore ?? null; this.serviceRouteStore = serviceRouteStore ?? null;
this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpPort = getDaemonTcpPort ?? null;
@@ -519,6 +529,7 @@ export class VoiceAssistantWebSocketServer {
} }
await Promise.all(cleanupPromises); await Promise.all(cleanupPromises);
this.providerSnapshotManager.destroy();
this.checkoutDiffManager.dispose(); this.checkoutDiffManager.dispose();
this.pendingConnections.clear(); this.pendingConnections.clear();
this.sessions.clear(); this.sessions.clear();
@@ -669,6 +680,7 @@ export class VoiceAssistantWebSocketServer {
stt: () => this.speech?.resolveStt() ?? null, stt: () => this.speech?.resolveStt() ?? null,
tts: () => this.speech?.resolveTts() ?? null, tts: () => this.speech?.resolveTts() ?? null,
terminalManager: this.terminalManager, terminalManager: this.terminalManager,
providerSnapshotManager: this.providerSnapshotManager,
serviceRouteStore: this.serviceRouteStore ?? undefined, serviceRouteStore: this.serviceRouteStore ?? undefined,
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
resolveServiceStatus: this.resolveServiceStatus ?? undefined, resolveServiceStatus: this.resolveServiceStatus ?? undefined,
+97
View File
@@ -54,6 +54,8 @@ import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPersistenceHandle, AgentPersistenceHandle,
ProviderSnapshotEntry,
ProviderStatus,
AgentRuntimeInfo, AgentRuntimeInfo,
AgentTimelineItem, AgentTimelineItem,
ToolCallDetail, ToolCallDetail,
@@ -69,6 +71,13 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
description: z.string().optional(), description: z.string().optional(),
}); });
const ProviderStatusSchema: z.ZodType<ProviderStatus> = z.enum([
"ready",
"loading",
"error",
"unavailable",
]);
const AgentSelectOptionSchema = z.object({ const AgentSelectOptionSchema = z.object({
id: z.string(), id: z.string(),
label: z.string(), label: z.string(),
@@ -114,6 +123,15 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
defaultThinkingOptionId: z.string().optional(), defaultThinkingOptionId: z.string().optional(),
}); });
const ProviderSnapshotEntrySchema: z.ZodType<ProviderSnapshotEntry> = 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<AgentCapabilityFlags> = z.object({ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
supportsStreaming: z.boolean(), supportsStreaming: z.boolean(),
supportsSessionPersistence: z.boolean(), supportsSessionPersistence: z.boolean(),
@@ -776,6 +794,24 @@ export const ListAvailableProvidersRequestMessageSchema = z.object({
requestId: z.string(), 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({ export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"), type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema, handle: AgentPersistenceHandleSchema,
@@ -1289,6 +1325,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModesRequestMessageSchema, ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema, ListAvailableProvidersRequestMessageSchema,
GetProvidersSnapshotRequestMessageSchema,
RefreshProvidersSnapshotRequestMessageSchema,
ProviderDiagnosticRequestMessageSchema,
ResumeAgentRequestMessageSchema, ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema, RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema, 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({ const AgentSlashCommandSchema = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@@ -2482,6 +2556,10 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModesResponseMessageSchema, ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema,
ListAvailableProvidersResponseSchema, ListAvailableProvidersResponseSchema,
GetProvidersSnapshotResponseMessageSchema,
ProvidersSnapshotUpdateMessageSchema,
RefreshProvidersSnapshotResponseMessageSchema,
ProviderDiagnosticResponseMessageSchema,
ListCommandsResponseSchema, ListCommandsResponseSchema,
ListTerminalsResponseSchema, ListTerminalsResponseSchema,
TerminalsChangedSchema, TerminalsChangedSchema,
@@ -2568,6 +2646,16 @@ export type ListProviderFeaturesResponseMessage = z.infer<
typeof ListProviderFeaturesResponseMessageSchema typeof ListProviderFeaturesResponseMessageSchema
>; >;
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>; export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type GetProvidersSnapshotResponseMessage = z.infer<
typeof GetProvidersSnapshotResponseMessageSchema
>;
export type ProvidersSnapshotUpdateMessage = z.infer<typeof ProvidersSnapshotUpdateMessageSchema>;
export type RefreshProvidersSnapshotResponseMessage = z.infer<
typeof RefreshProvidersSnapshotResponseMessageSchema
>;
export type ProviderDiagnosticResponseMessage = z.infer<
typeof ProviderDiagnosticResponseMessageSchema
>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>; export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>; export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>; export type ChatInspectResponse = z.infer<typeof ChatInspectResponseSchema>;
@@ -2615,6 +2703,15 @@ export type ListProviderFeaturesRequestMessage = z.infer<
export type ListAvailableProvidersRequestMessage = z.infer< export type ListAvailableProvidersRequestMessage = z.infer<
typeof ListAvailableProvidersRequestMessageSchema 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<typeof ChatCreateRequestSchema>; export type ChatCreateRequest = z.infer<typeof ChatCreateRequestSchema>;
export type ChatListRequest = z.infer<typeof ChatListRequestSchema>; export type ChatListRequest = z.infer<typeof ChatListRequestSchema>;
export type ChatInspectRequest = z.infer<typeof ChatInspectRequestSchema>; export type ChatInspectRequest = z.infer<typeof ChatInspectRequestSchema>;