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:
+162
@@ -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.
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user