Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -171,14 +171,24 @@ describe("resolveStartupRedirectRoute", () => {
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("redirects to the host root when the persisted workspace targets a different server", () => {
|
||||
it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/server-2");
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when another host is first online", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("redirects to the host root when no persisted workspace exists", () => {
|
||||
|
||||
@@ -79,11 +79,7 @@ export function resolveStartupWorkspaceSelection(
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!input.anyOnlineHostServerId ||
|
||||
!input.workspaceSelection ||
|
||||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
|
||||
) {
|
||||
if (!input.workspaceSelection) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaceSelection;
|
||||
|
||||
@@ -18,6 +18,7 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
||||
storeReady: false,
|
||||
} as HostRuntimeBootstrapState,
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||
};
|
||||
|
||||
@@ -51,7 +52,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
useActiveWorkspaceSelection: () => state.workspaceSelection,
|
||||
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
|
||||
useLastWorkspaceSelection: () => state.workspaceSelection,
|
||||
}));
|
||||
|
||||
describe("Index route startup navigation", () => {
|
||||
@@ -68,6 +70,7 @@ describe("Index route startup navigation", () => {
|
||||
storeReady: false,
|
||||
};
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
navigateToWorkspaceMock.mockReset();
|
||||
redirectMock.mockReset();
|
||||
@@ -98,6 +101,17 @@ describe("Index route startup navigation", () => {
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.isWorkspaceSelectionLoaded = false;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(navigateToWorkspaceMock).not.toHaveBeenCalled();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
@@ -111,13 +125,16 @@ describe("Index route startup navigation", () => {
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("navigates to the host root when the persisted workspace targets a different server", async () => {
|
||||
it("restores the persisted workspace even when the first online host is different", async () => {
|
||||
state.anyOnlineHostServerId = "server-2";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
|
||||
currentPathname: "/",
|
||||
});
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
@@ -18,20 +19,21 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const workspaceSelection = useActiveWorkspaceSelection();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ import {
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import {
|
||||
buildModelSelectorProviders,
|
||||
type ModelSelectorProvider,
|
||||
} from "@/components/combined-model-selector.utils";
|
||||
buildProviderSelectorProviders,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
@@ -97,7 +97,7 @@ interface ControlledAgentStatusBarProps {
|
||||
disabled?: boolean;
|
||||
isModelLoading?: boolean;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modelSelectorProviders?: ModelSelectorProvider[];
|
||||
modelSelectorProviders?: ProviderSelectorProvider[];
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||
features?: AgentFeature[];
|
||||
@@ -117,7 +117,7 @@ export interface DraftAgentStatusBarProps {
|
||||
selectedModel: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
isModelLoading: boolean;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
@@ -237,7 +237,7 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
|
||||
function buildFallbackModelSelectorProviders(
|
||||
provider: string,
|
||||
modelOptions: StatusOption[] | undefined,
|
||||
): ModelSelectorProvider[] {
|
||||
): ProviderSelectorProvider[] {
|
||||
if (!modelOptions || modelOptions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -245,13 +245,16 @@ function buildFallbackModelSelectorProviders(
|
||||
{
|
||||
id: provider,
|
||||
label: provider,
|
||||
rows: modelOptions.map((option) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
||||
provider,
|
||||
providerLabel: provider,
|
||||
modelId: option.id,
|
||||
modelLabel: option.label,
|
||||
})),
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: modelOptions.map((option) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
||||
provider,
|
||||
providerLabel: provider,
|
||||
modelId: option.id,
|
||||
modelLabel: option.label,
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -794,7 +797,7 @@ interface DesktopStatusBarContentProps {
|
||||
canSelectMode: boolean;
|
||||
canSelectModel: boolean;
|
||||
canSelectThinking: boolean;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
modelDisabled: boolean;
|
||||
comboboxProviderOptions: ComboboxOption[];
|
||||
comboboxModeOptions: ComboboxOption[];
|
||||
@@ -1058,7 +1061,7 @@ interface SheetStatusBarContentProps {
|
||||
canSelectMode: boolean;
|
||||
canSelectModel: boolean;
|
||||
canSelectThinking: boolean;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
modelDisabled: boolean;
|
||||
comboboxModeOptions: ComboboxOption[];
|
||||
comboboxThinkingOptions: ComboboxOption[];
|
||||
@@ -1662,7 +1665,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
[agent?.provider, models],
|
||||
);
|
||||
const agentModelSelectorProviders = useMemo(
|
||||
() => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels),
|
||||
() =>
|
||||
buildProviderSelectorProviders({
|
||||
providerDefinitions: agentProviderDefinitions,
|
||||
modelsByProvider: agentProviderModels,
|
||||
}),
|
||||
[agentProviderDefinitions, agentProviderModels],
|
||||
);
|
||||
|
||||
|
||||
@@ -47,9 +47,13 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
type ModelSelectorProvider,
|
||||
type SelectorModelRow,
|
||||
} from "./combined-model-selector.utils";
|
||||
getAllProviderModelRows,
|
||||
getProviderDefaultLabel,
|
||||
getProviderModelRows,
|
||||
resolveSelectedModelLabel,
|
||||
type ProviderSelectionModelRow,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
// TODO: this should be configured per provider in the provider manifest
|
||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||
@@ -59,7 +63,7 @@ type SelectorView =
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providers: ModelSelectorProvider[];
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||
@@ -79,7 +83,7 @@ interface CombinedModelSelectorProps {
|
||||
|
||||
interface SelectorContentProps {
|
||||
view: SelectorView;
|
||||
providers: ModelSelectorProvider[];
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
@@ -89,23 +93,16 @@ interface SelectorContentProps {
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function resolveDefaultModelLabel(rows: SelectorModelRow[] | undefined): string {
|
||||
if (!rows || rows.length === 0) {
|
||||
return "Select model";
|
||||
}
|
||||
return (rows.find((row) => row.isDefault) ?? rows[0])?.modelLabel ?? "Select model";
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function sortFavoritesFirst(
|
||||
rows: SelectorModelRow[],
|
||||
rows: ProviderSelectionModelRow[],
|
||||
favoriteKeys: Set<string>,
|
||||
): SelectorModelRow[] {
|
||||
const favorites: SelectorModelRow[] = [];
|
||||
const rest: SelectorModelRow[] = [];
|
||||
): ProviderSelectionModelRow[] {
|
||||
const favorites: ProviderSelectionModelRow[] = [];
|
||||
const rest: ProviderSelectionModelRow[] = [];
|
||||
for (const row of rows) {
|
||||
if (favoriteKeys.has(row.favoriteKey)) {
|
||||
favorites.push(row);
|
||||
@@ -124,7 +121,7 @@ function ModelRow({
|
||||
onPress,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
row: SelectorModelRow;
|
||||
row: ProviderSelectionModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
elevated?: boolean;
|
||||
@@ -200,7 +197,7 @@ function ModelRow({
|
||||
}
|
||||
|
||||
interface SelectableModelRowProps {
|
||||
row: SelectorModelRow;
|
||||
row: ProviderSelectionModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
elevated?: boolean;
|
||||
@@ -239,7 +236,7 @@ function FavoritesSection({
|
||||
onSelect,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
favoriteRows: SelectorModelRow[];
|
||||
favoriteRows: ProviderSelectionModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
@@ -274,7 +271,7 @@ interface GroupProviderButtonProps {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
rowCount: number;
|
||||
hasNoModels: boolean;
|
||||
defaultLabel: string | null;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}
|
||||
@@ -283,28 +280,28 @@ function GroupProviderButton({
|
||||
providerId,
|
||||
providerLabel,
|
||||
rowCount,
|
||||
hasNoModels,
|
||||
defaultLabel,
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProvIcon = getProviderIcon(providerId);
|
||||
const handlePress = useCallback(() => {
|
||||
if (hasNoModels) {
|
||||
if (defaultLabel) {
|
||||
onSelectDefault(providerId);
|
||||
return;
|
||||
}
|
||||
onDrillDown(providerId, providerLabel);
|
||||
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>
|
||||
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||
</Text>
|
||||
{hasNoModels ? null : (
|
||||
{defaultLabel ? null : (
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
@@ -317,22 +314,23 @@ function GroupedProviderRows({
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: {
|
||||
providers: ModelSelectorProvider[];
|
||||
providers: ProviderSelectorProvider[];
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{providers.map((provider, index) => {
|
||||
const hasNoModels = provider.rows.length === 0;
|
||||
const rows = getProviderModelRows(provider);
|
||||
const defaultLabel = getProviderDefaultLabel(provider);
|
||||
return (
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton
|
||||
providerId={provider.id}
|
||||
providerLabel={provider.label}
|
||||
rowCount={provider.rows.length}
|
||||
hasNoModels={hasNoModels}
|
||||
rowCount={rows.length}
|
||||
defaultLabel={defaultLabel}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={onSelectDefault}
|
||||
/>
|
||||
@@ -381,7 +379,7 @@ function ProviderModelRows({
|
||||
onToggleFavorite,
|
||||
normalizedQuery,
|
||||
}: {
|
||||
rows: SelectorModelRow[];
|
||||
rows: ProviderSelectionModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
@@ -396,7 +394,7 @@ function ProviderModelRows({
|
||||
[favoriteKeys, normalizedQuery, rows],
|
||||
);
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: SelectorModelRow }) => (
|
||||
({ item }: { item: ProviderSelectionModelRow }) => (
|
||||
<SelectableModelRow
|
||||
row={item}
|
||||
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
||||
@@ -407,7 +405,7 @@ function ProviderModelRows({
|
||||
),
|
||||
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
||||
);
|
||||
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
|
||||
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
|
||||
|
||||
if (useVirtualizedList) {
|
||||
return (
|
||||
@@ -455,15 +453,12 @@ function SelectorContent({
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
selectedViewProvider
|
||||
? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery)
|
||||
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
|
||||
: [],
|
||||
[normalizedQuery, selectedViewProvider],
|
||||
);
|
||||
const favoriteRows = useMemo(
|
||||
() =>
|
||||
providers.flatMap((provider) =>
|
||||
provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
),
|
||||
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
[favoriteKeys, providers],
|
||||
);
|
||||
const handleSelectDefaultProvider = useCallback(
|
||||
@@ -485,7 +480,7 @@ function SelectorContent({
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
|
||||
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
|
||||
return (
|
||||
<DefaultProviderRow
|
||||
providerId={view.providerId}
|
||||
@@ -607,30 +602,20 @@ export function CombinedModelSelector({
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
if (!selectedModel) {
|
||||
if (!hasSelectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
const provider = providers.find((entry) => entry.id === selectedProvider);
|
||||
if (provider?.rows.length === 0) {
|
||||
return "Default";
|
||||
}
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const provider = providers.find((entry) => entry.id === selectedProvider);
|
||||
if (!provider) {
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const model = provider.rows.find((entry) => entry.modelId === selectedModel);
|
||||
return model?.modelLabel ?? resolveDefaultModelLabel(provider.rows);
|
||||
}, [hasSelectedProvider, isLoading, providers, selectedModel, selectedProvider]);
|
||||
return resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
isLoading,
|
||||
});
|
||||
}, [isLoading, providers, selectedModel, selectedProvider]);
|
||||
|
||||
const desktopFixedHeight = useMemo(() => {
|
||||
if (view.kind !== "provider") {
|
||||
return undefined;
|
||||
}
|
||||
const modelCount =
|
||||
providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0;
|
||||
const provider = providers.find((entry) => entry.id === view.providerId);
|
||||
const modelCount = provider ? getProviderModelRows(provider).length : 0;
|
||||
return Math.min(80 + modelCount * 40, 400);
|
||||
}, [providers, view]);
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
|
||||
export type SelectorModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||
|
||||
export interface ModelSelectorProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
rows: SelectorModelRow[];
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function buildModelSelectorProviders(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||
): ModelSelectorProvider[] {
|
||||
return providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
rows: (allProviderModels.get(definition.id) ?? []).map((model) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
||||
provider: definition.id,
|
||||
providerLabel: definition.label,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSelectableModelSelectorProviders(
|
||||
entries: ProviderSnapshotEntry[] | undefined,
|
||||
): ModelSelectorProvider[] {
|
||||
return (entries ?? [])
|
||||
.filter((entry) => entry.enabled && entry.status === "ready")
|
||||
.map((entry) => ({
|
||||
id: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
rows: (entry.models ?? []).map((model) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider: entry.provider, modelId: model.id }),
|
||||
provider: entry.provider,
|
||||
providerLabel: entry.label ?? entry.provider,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||
return scoreModelRow(row, normalizedQuery) !== null;
|
||||
}
|
||||
|
||||
function getModelRowSearchFields(row: SelectorModelRow): string[] {
|
||||
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
|
||||
}
|
||||
|
||||
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
|
||||
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
|
||||
}
|
||||
|
||||
export function filterAndRankModelRows(
|
||||
rows: SelectorModelRow[],
|
||||
normalizedQuery: string,
|
||||
): SelectorModelRow[] {
|
||||
if (!normalizedQuery) return rows;
|
||||
const scored = rows
|
||||
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
|
||||
.filter((entry): entry is { row: SelectorModelRow; score: NonNullable<typeof entry.score> } =>
|
||||
Boolean(entry.score),
|
||||
);
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.row.modelLabel.localeCompare(b.row.modelLabel);
|
||||
});
|
||||
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { StyleProp, TextStyle } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
interface SettingsTextAreaProps {
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
testID?: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
export function SettingsTextArea({
|
||||
accessibilityLabel,
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
testID,
|
||||
style,
|
||||
}: SettingsTextAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputStyle = useMemo(() => [styles.input, style], [style]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
multiline
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsTextAreaCard(props: SettingsTextAreaProps) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
<SettingsTextArea {...props} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
input: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
}));
|
||||
@@ -9,9 +9,9 @@ import type {
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import {
|
||||
buildSelectableModelSelectorProviders,
|
||||
type ModelSelectorProvider,
|
||||
} from "@/components/combined-model-selector.utils";
|
||||
buildSelectableProviderSelectorProviders,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
SELECTABLE_PROVIDER_STATUSES,
|
||||
type FormInitialValues,
|
||||
type FormState,
|
||||
} from "./resolve-agent-form";
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
export type { FormInitialValues } from "./resolve-agent-form";
|
||||
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
export interface UseAgentFormStateOptions {
|
||||
initialServerId?: string | null;
|
||||
@@ -67,7 +67,7 @@ export interface UseAgentFormStateResult {
|
||||
modeOptions: AgentMode[];
|
||||
availableModels: AgentModelDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
isModelLoading: boolean;
|
||||
@@ -243,7 +243,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotModelSelectorProviders = useMemo(
|
||||
() => buildSelectableModelSelectorProviders(snapshotEntries),
|
||||
() => buildSelectableProviderSelectorProviders(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotSelectedEntry = useMemo(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export interface DraftKeyContext {
|
||||
selectedServerId: string | null;
|
||||
@@ -20,56 +18,6 @@ export function resolveDraftKey(input: {
|
||||
return input.draftKey;
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerModelId(input: {
|
||||
selectedModel: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
}): string {
|
||||
return input.selectedModel.trim();
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(input: {
|
||||
selectedThinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
effectiveModelId: string;
|
||||
}): string {
|
||||
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftComposerCommandConfig(input: {
|
||||
provider: AgentProvider | null;
|
||||
cwd: string;
|
||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||
selectedMode: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
...(input.modeOptions.length > 0 && input.selectedMode !== ""
|
||||
? { modeId: input.selectedMode }
|
||||
: {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftStatusControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
features?: DraftAgentStatusBarProps["features"];
|
||||
|
||||
@@ -85,16 +85,19 @@ vi.mock("./use-agent-form-state", () => ({
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
isAllModelsLoading: false,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDraftKey } from "./use-agent-input-draft-core";
|
||||
import {
|
||||
buildDraftComposerCommandConfig,
|
||||
resolveDraftKey,
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
} from "./use-agent-input-draft-core";
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
describe("resolveDraftKey", () => {
|
||||
it("returns a string draft key unchanged", () => {
|
||||
@@ -30,8 +30,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
||||
it("returns the selected model trimmed", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: " gpt-5.4-mini ",
|
||||
provider: "codex",
|
||||
modelId: " gpt-5.4-mini ",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
@@ -39,8 +43,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
||||
it("returns empty string when no model selected", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
provider: "codex",
|
||||
modelId: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
@@ -63,21 +71,33 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
|
||||
it("prefers the selected thinking option when present", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
resolveEffectiveComposerThinkingOptionId(
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
modeOptions: [],
|
||||
},
|
||||
"gpt-5.4",
|
||||
),
|
||||
).toBe("medium");
|
||||
});
|
||||
|
||||
it("falls back to the model default thinking option", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
resolveEffectiveComposerThinkingOptionId(
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: models,
|
||||
modeOptions: [],
|
||||
},
|
||||
"gpt-5.4",
|
||||
),
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -85,11 +105,16 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
},
|
||||
cwd: " ",
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
@@ -98,11 +123,16 @@ describe("buildDraftComposerCommandConfig", () => {
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
},
|
||||
cwd: "/repo",
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
selectedMode: "auto",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
|
||||
@@ -10,14 +10,17 @@ import {
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import {
|
||||
areAttachmentsEqual,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
hasDraftContent,
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type DraftKeyInput,
|
||||
} from "@/hooks/use-agent-input-draft-core";
|
||||
import {
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
|
||||
type AttachmentUpdater =
|
||||
@@ -199,23 +202,33 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
formState.setWorkingDir(lockedWorkingDir);
|
||||
}, [composerOptions, formState, lockedWorkingDir]);
|
||||
|
||||
const providerSelection = useMemo<ProviderSelectionState>(
|
||||
() => ({
|
||||
provider: formState.selectedProvider,
|
||||
modelId: formState.selectedModel,
|
||||
modeId: formState.selectedMode,
|
||||
thinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
modeOptions: formState.modeOptions,
|
||||
}),
|
||||
[
|
||||
formState.availableModels,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedModel,
|
||||
formState.selectedProvider,
|
||||
formState.selectedThinkingOptionId,
|
||||
],
|
||||
);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: formState.selectedModel,
|
||||
availableModels: formState.availableModels,
|
||||
}),
|
||||
[formState.availableModels, formState.selectedModel],
|
||||
() => resolveEffectiveComposerModelId(providerSelection),
|
||||
[providerSelection],
|
||||
);
|
||||
|
||||
const effectiveThinkingOptionId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
effectiveModelId,
|
||||
}),
|
||||
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
||||
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
|
||||
[effectiveModelId, providerSelection],
|
||||
);
|
||||
|
||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||
@@ -236,11 +249,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
? buildDraftComposerCommandConfig({
|
||||
provider: formState.selectedProvider,
|
||||
? buildDraftCommandConfig({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
@@ -251,10 +262,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatureValues,
|
||||
providerSelection,
|
||||
workingDir,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedProvider,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -303,6 +312,7 @@ export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftCommandConfig,
|
||||
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
};
|
||||
|
||||
+116
-34
@@ -5,12 +5,14 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
buildModelSelectorProviders,
|
||||
buildSelectableModelSelectorProviders,
|
||||
buildProviderSelectorProviders,
|
||||
buildSelectableProviderSelectorProviders,
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesSearch,
|
||||
} from "./combined-model-selector.utils";
|
||||
matchesModelSearch,
|
||||
resolveSelectedModelLabel,
|
||||
resolveSubmissionReadiness,
|
||||
} from "./provider-selection";
|
||||
|
||||
describe("combined model selector data", () => {
|
||||
const codexModel: AgentModelDefinition = {
|
||||
@@ -37,7 +39,7 @@ describe("combined model selector data", () => {
|
||||
|
||||
it("builds selector providers from ready enabled snapshot entries", () => {
|
||||
expect(
|
||||
buildSelectableModelSelectorProviders([
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
@@ -48,24 +50,27 @@ describe("combined model selector data", () => {
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
description: undefined,
|
||||
isDefault: undefined,
|
||||
},
|
||||
],
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
description: undefined,
|
||||
isDefault: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps ready enabled providers with no models as model-less providers", () => {
|
||||
it("represents ready enabled providers without explicit models as provider-default selection", () => {
|
||||
expect(
|
||||
buildSelectableModelSelectorProviders([
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
@@ -76,14 +81,14 @@ describe("combined model selector data", () => {
|
||||
{
|
||||
id: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
rows: [],
|
||||
modelSelection: { kind: "providerDefault", label: "Default" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes disabled providers from selector data", () => {
|
||||
expect(
|
||||
buildSelectableModelSelectorProviders([
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
@@ -96,7 +101,7 @@ describe("combined model selector data", () => {
|
||||
|
||||
it("excludes providers that are not ready", () => {
|
||||
expect(
|
||||
buildSelectableModelSelectorProviders([
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
||||
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
||||
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
||||
@@ -116,19 +121,25 @@ describe("combined model selector data", () => {
|
||||
];
|
||||
|
||||
expect(
|
||||
buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])),
|
||||
buildProviderSelectorProviders({
|
||||
providerDefinitions,
|
||||
modelsByProvider: new Map([["codex", [codexModel]]]),
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
rows: [
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
}),
|
||||
],
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -143,10 +154,10 @@ describe("combined model selector data", () => {
|
||||
description: "OpenCode Zen - kimi",
|
||||
};
|
||||
|
||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
||||
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
|
||||
});
|
||||
|
||||
it("ranks model search results by fuzzy match quality", () => {
|
||||
@@ -180,4 +191,75 @@ describe("combined model selector data", () => {
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
});
|
||||
|
||||
it("resolves selected labels from explicit provider model-selection state", () => {
|
||||
const providers = buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
models: [codexModel],
|
||||
}),
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
models: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider: "codex",
|
||||
selectedModel: "gpt-5.4",
|
||||
isLoading: false,
|
||||
}),
|
||||
).toBe("GPT-5.4");
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider: "deepseek-tui",
|
||||
selectedModel: "",
|
||||
isLoading: false,
|
||||
}),
|
||||
).toBe("Default");
|
||||
});
|
||||
|
||||
it("returns observable submission readiness reasons", () => {
|
||||
expect(
|
||||
resolveSubmissionReadiness({
|
||||
text: "hello",
|
||||
allowsEmptyAutoSubmit: false,
|
||||
providerCount: 1,
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "",
|
||||
availableModels: [codexModel],
|
||||
isModelLoading: false,
|
||||
},
|
||||
autoSubmitConfig: null,
|
||||
workspaceDirectory: "/repo",
|
||||
hasClient: true,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "No model is available for the selected provider",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSubmissionReadiness({
|
||||
text: "hello",
|
||||
allowsEmptyAutoSubmit: false,
|
||||
providerCount: 1,
|
||||
selection: {
|
||||
provider: "deepseek-tui",
|
||||
modelId: "",
|
||||
availableModels: [],
|
||||
isModelLoading: false,
|
||||
},
|
||||
autoSubmitConfig: null,
|
||||
workspaceDirectory: "/repo",
|
||||
hasClient: true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
|
||||
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||
|
||||
export type ProviderModelSelection =
|
||||
| { kind: "models"; rows: ProviderSelectionModelRow[] }
|
||||
| { kind: "providerDefault"; label: string }
|
||||
| { kind: "loading" }
|
||||
| { kind: "unavailable"; reason: string };
|
||||
|
||||
export interface ProviderSelectorProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
modelSelection: ProviderModelSelection;
|
||||
}
|
||||
|
||||
export interface ProviderSelectionState {
|
||||
provider: AgentProvider | null;
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
modeOptions: AgentMode[];
|
||||
}
|
||||
|
||||
export interface ProviderSelectionReadiness {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function buildModelRows(
|
||||
provider: string,
|
||||
providerLabel: string,
|
||||
models: AgentModelDefinition[],
|
||||
): ProviderSelectionModelRow[] {
|
||||
return models.map((model) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }),
|
||||
provider,
|
||||
providerLabel,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildModelSelection(
|
||||
provider: string,
|
||||
providerLabel: string,
|
||||
models: AgentModelDefinition[] | null,
|
||||
): ProviderModelSelection {
|
||||
if (models === null) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return { kind: "providerDefault", label: "Default" };
|
||||
}
|
||||
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
|
||||
}
|
||||
|
||||
function isSelectableProvider(entry: ProviderSnapshotEntry): boolean {
|
||||
return entry.enabled && entry.status === "ready";
|
||||
}
|
||||
|
||||
export function buildProviderSelectorProviders(input: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modelsByProvider: Map<string, AgentModelDefinition[]>;
|
||||
}): ProviderSelectorProvider[] {
|
||||
return input.providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
modelSelection: buildModelSelection(
|
||||
definition.id,
|
||||
definition.label,
|
||||
input.modelsByProvider.has(definition.id)
|
||||
? (input.modelsByProvider.get(definition.id) ?? [])
|
||||
: null,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSelectableProviderSelectorProviders(
|
||||
entries: ProviderSnapshotEntry[] | undefined,
|
||||
): ProviderSelectorProvider[] {
|
||||
return (entries ?? []).filter(isSelectableProvider).map((entry) => {
|
||||
const label = entry.label ?? entry.provider;
|
||||
return {
|
||||
id: entry.provider,
|
||||
label,
|
||||
modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getProviderModelRows(
|
||||
provider: ProviderSelectorProvider,
|
||||
): ProviderSelectionModelRow[] {
|
||||
return provider.modelSelection.kind === "models" ? provider.modelSelection.rows : [];
|
||||
}
|
||||
|
||||
export function getAllProviderModelRows(
|
||||
providers: ProviderSelectorProvider[],
|
||||
): ProviderSelectionModelRow[] {
|
||||
return providers.flatMap(getProviderModelRows);
|
||||
}
|
||||
|
||||
export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null {
|
||||
return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null;
|
||||
}
|
||||
|
||||
export function resolveSelectedModelLabel(input: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
isLoading: boolean;
|
||||
}): string {
|
||||
const selectedProvider = input.selectedProvider.trim();
|
||||
if (!selectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
|
||||
const provider = input.providers.find((entry) => entry.id === selectedProvider);
|
||||
if (!input.selectedModel) {
|
||||
if (provider?.modelSelection.kind === "providerDefault") {
|
||||
return provider.modelSelection.label;
|
||||
}
|
||||
if (provider?.modelSelection.kind === "loading") {
|
||||
return "Loading...";
|
||||
}
|
||||
return input.isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return input.isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
if (provider.modelSelection.kind !== "models") {
|
||||
return provider.modelSelection.kind === "providerDefault"
|
||||
? provider.modelSelection.label
|
||||
: "Select model";
|
||||
}
|
||||
|
||||
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
|
||||
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
|
||||
return (
|
||||
model?.modelLabel ??
|
||||
defaultModel?.modelLabel ??
|
||||
provider.modelSelection.rows[0]?.modelLabel ??
|
||||
"Select model"
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function matchesModelSearch(
|
||||
row: ProviderSelectionModelRow,
|
||||
normalizedQuery: string,
|
||||
): boolean {
|
||||
return scoreModelRow(row, normalizedQuery) !== null;
|
||||
}
|
||||
|
||||
function getModelRowSearchFields(row: ProviderSelectionModelRow): string[] {
|
||||
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
|
||||
}
|
||||
|
||||
export function scoreModelRow(row: ProviderSelectionModelRow, normalizedQuery: string) {
|
||||
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
|
||||
}
|
||||
|
||||
export function filterAndRankModelRows(
|
||||
rows: ProviderSelectionModelRow[],
|
||||
normalizedQuery: string,
|
||||
): ProviderSelectionModelRow[] {
|
||||
if (!normalizedQuery) return rows;
|
||||
const scored = rows
|
||||
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { row: ProviderSelectionModelRow; score: NonNullable<typeof entry.score> } =>
|
||||
Boolean(entry.score),
|
||||
);
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.row.modelLabel.localeCompare(b.row.modelLabel);
|
||||
});
|
||||
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string {
|
||||
return selection.modelId.trim();
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(
|
||||
selection: ProviderSelectionState,
|
||||
effectiveModelId: string,
|
||||
): string {
|
||||
const selectedThinkingOptionId = selection.thinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
selection.availableModels.find((model) => model.id === effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftCommandConfig(input: {
|
||||
selection: ProviderSelectionState;
|
||||
cwd: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.selection.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.selection.provider,
|
||||
cwd,
|
||||
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
||||
? { modeId: input.selection.modeId }
|
||||
: {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSubmissionReadiness(input: {
|
||||
text: string;
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
providerCount: number;
|
||||
selection: {
|
||||
provider: AgentProvider | string | null;
|
||||
modelId: string;
|
||||
availableModels: readonly unknown[];
|
||||
isModelLoading: boolean;
|
||||
};
|
||||
autoSubmitConfig: { provider: string; model: string | null } | null;
|
||||
workspaceDirectory: string | null;
|
||||
hasClient: boolean;
|
||||
}): ProviderSelectionReadiness {
|
||||
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
|
||||
return { ok: false, reason: "Initial prompt is required" };
|
||||
}
|
||||
if (input.providerCount === 0) {
|
||||
return { ok: false, reason: "No available providers on the selected host" };
|
||||
}
|
||||
if (!(input.autoSubmitConfig?.provider ?? input.selection.provider)) {
|
||||
return { ok: false, reason: "Select a model" };
|
||||
}
|
||||
if (input.selection.isModelLoading) {
|
||||
return { ok: false, reason: "Model defaults are still loading" };
|
||||
}
|
||||
const hasSelectedModel = Boolean(input.autoSubmitConfig?.model ?? input.selection.modelId);
|
||||
if (!hasSelectedModel && input.selection.availableModels.length > 0) {
|
||||
return { ok: false, reason: "No model is available for the selected provider" };
|
||||
}
|
||||
if (!input.workspaceDirectory) {
|
||||
return { ok: false, reason: "Workspace directory not found" };
|
||||
}
|
||||
if (!input.hasClient) {
|
||||
return { ok: false, reason: "Host is not connected" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
mergeProviderPreferences,
|
||||
type FormPreferences,
|
||||
type ProviderPreferences,
|
||||
} from "./use-form-preferences";
|
||||
} from "@/hooks/use-form-preferences";
|
||||
|
||||
export interface FormInitialValues {
|
||||
serverId?: string | null;
|
||||
@@ -23,6 +23,7 @@ import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { SettingsGroup } from "@/screens/settings/settings-group";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -621,18 +622,13 @@ function ProjectConfigForm({
|
||||
testID="worktree-group"
|
||||
>
|
||||
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
multiline
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
@@ -641,18 +637,13 @@ function ProjectConfigForm({
|
||||
trailing={teardownDocsLink}
|
||||
flush
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
multiline
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
multiline
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1344,14 +1330,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
errorBlock: {
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
lifecycleInput: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
emptyScripts: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
@@ -379,9 +380,9 @@ function ConnectionRow({
|
||||
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Operations">
|
||||
<RestartDaemonCard host={host} />
|
||||
<SettingsSection title="Daemon settings">
|
||||
<InjectPaseoToolsCard serverId={host.serverId} />
|
||||
<AppendSystemPromptCard serverId={host.serverId} />
|
||||
</SettingsSection>
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
@@ -544,9 +545,9 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Inject Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowTitle}>Enable Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Automatically inject Paseo MCP tools into new agents
|
||||
Agents will be able to manage worktrees, agents and schedules
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
@@ -559,6 +560,111 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const persistedPrompt = config?.appendSystemPrompt ?? "";
|
||||
const [draft, setDraft] = useState(persistedPrompt);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: "Append system prompt" }), []);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(persistedPrompt);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
const hasChanges = draft !== persistedPrompt;
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setDraft(persistedPrompt);
|
||||
setIsEditing(true);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setDraft(persistedPrompt);
|
||||
setIsEditing(false);
|
||||
}, [isSaving, persistedPrompt]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
void patchConfig({ appendSystemPrompt: draft })
|
||||
.then(() => {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostPage] Failed to save append system prompt", error);
|
||||
})
|
||||
.finally(() => setIsSaving(false));
|
||||
}, [draft, patchConfig]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setDraft(persistedPrompt);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
if (!isConnected) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.card} testID="host-page-append-system-prompt-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>System prompt</Text>
|
||||
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleOpen}
|
||||
testID="host-page-append-system-prompt-edit"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isEditing ? (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible
|
||||
onClose={handleClose}
|
||||
testID="host-page-append-system-prompt-sheet"
|
||||
desktopMaxWidth={560}
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="host-page-append-system-prompt-input"
|
||||
accessibilityLabel="Append system prompt"
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Always keep replies concise."
|
||||
/>
|
||||
<View style={styles.appendPromptActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-reset"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-save"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PairDeviceRow() {
|
||||
const { theme } = useUnistyles();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -631,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
||||
|
||||
return (
|
||||
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
|
||||
<RestartDaemonCard host={host} />
|
||||
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
@@ -755,6 +863,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
appendPromptActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
|
||||
@@ -202,7 +202,12 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
|
||||
};
|
||||
|
||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||
return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false };
|
||||
return {
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers,
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
function descendants(el: HTMLElement): HTMLElement[] {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
|
||||
|
||||
export interface WorkspaceDraftAutoSubmitConfig {
|
||||
provider: string;
|
||||
model: string | null;
|
||||
@@ -25,27 +27,19 @@ export function validateDraftSubmission(input: {
|
||||
workspaceDirectory,
|
||||
hasClient,
|
||||
} = input;
|
||||
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
||||
return "Initial prompt is required";
|
||||
}
|
||||
if (composerState.providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
||||
return "Select a model";
|
||||
}
|
||||
if (composerState.isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
|
||||
if (!hasSelectedModel && composerState.availableModels.length > 0) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (!workspaceDirectory) {
|
||||
return "Workspace directory not found";
|
||||
}
|
||||
if (!hasClient) {
|
||||
return "Host is not connected";
|
||||
}
|
||||
return null;
|
||||
const readiness = resolveSubmissionReadiness({
|
||||
text,
|
||||
allowsEmptyAutoSubmit,
|
||||
providerCount: composerState.providerDefinitions.length,
|
||||
selection: {
|
||||
provider: composerState.selectedProvider,
|
||||
modelId: composerState.effectiveModelId ?? "",
|
||||
availableModels: composerState.availableModels,
|
||||
isModelLoading: composerState.isModelLoading,
|
||||
},
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory,
|
||||
hasClient,
|
||||
});
|
||||
return readiness.ok ? null : (readiness.reason ?? null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createLastWorkspaceSelectionStore,
|
||||
type ActiveWorkspaceSelection,
|
||||
type LastWorkspaceSelectionStorage,
|
||||
} from "./last-workspace-selection";
|
||||
|
||||
class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage {
|
||||
private finishRead: (value: string | null) => void = () => {};
|
||||
private readonly pendingRead = new Promise<string | null>((resolve) => {
|
||||
this.finishRead = resolve;
|
||||
});
|
||||
private saved: string | null = null;
|
||||
|
||||
read(): Promise<string | null> {
|
||||
return this.pendingRead;
|
||||
}
|
||||
|
||||
async write(value: string): Promise<void> {
|
||||
this.saved = value;
|
||||
}
|
||||
|
||||
finishHydrationWith(selection: ActiveWorkspaceSelection | null) {
|
||||
this.finishRead(selection ? JSON.stringify(selection) : null);
|
||||
}
|
||||
|
||||
getSavedSelection(): ActiveWorkspaceSelection | null {
|
||||
return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("last workspace selection", () => {
|
||||
it("hydrates the saved workspace selection", async () => {
|
||||
const storage = new DelayedWorkspaceSelectionStorage();
|
||||
const store = createLastWorkspaceSelectionStore(storage);
|
||||
const hydration = store.hydrate();
|
||||
|
||||
storage.finishHydrationWith({ serverId: "server-saved", workspaceId: "workspace-saved" });
|
||||
await hydration;
|
||||
|
||||
expect(store.getSelection()).toEqual({
|
||||
serverId: "server-saved",
|
||||
workspaceId: "workspace-saved",
|
||||
});
|
||||
expect(store.isHydrated()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a newer workspace selection when storage hydration finishes late", async () => {
|
||||
const storage = new DelayedWorkspaceSelectionStorage();
|
||||
const store = createLastWorkspaceSelectionStore(storage);
|
||||
const hydration = store.hydrate();
|
||||
|
||||
store.remember({ serverId: "server-new", workspaceId: "workspace-new" });
|
||||
storage.finishHydrationWith({ serverId: "server-old", workspaceId: "workspace-old" });
|
||||
await hydration;
|
||||
|
||||
expect(store.getSelection()).toEqual({
|
||||
serverId: "server-new",
|
||||
workspaceId: "workspace-new",
|
||||
});
|
||||
expect(storage.getSavedSelection()).toEqual({
|
||||
serverId: "server-new",
|
||||
workspaceId: "workspace-new",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface LastWorkspaceSelectionStorage {
|
||||
read(): Promise<string | null>;
|
||||
write(value: string): Promise<void>;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return null;
|
||||
}
|
||||
const record = input as Record<string, unknown>;
|
||||
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
||||
const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : "";
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeWorkspaceSelection(JSON.parse(stored));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectionStorage) {
|
||||
let selection: ActiveWorkspaceSelection | null = null;
|
||||
let hydrated = false;
|
||||
let hydrationPromise: Promise<void> | null = null;
|
||||
let revision = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notifyListeners() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function remember(next: ActiveWorkspaceSelection) {
|
||||
const normalized = normalizeWorkspaceSelection(next);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selection?.serverId === normalized.serverId &&
|
||||
selection.workspaceId === normalized.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
selection = normalized;
|
||||
revision += 1;
|
||||
notifyListeners();
|
||||
void storage.write(JSON.stringify(normalized)).catch(() => {});
|
||||
}
|
||||
|
||||
function hydrate(): Promise<void> {
|
||||
if (hydrationPromise) {
|
||||
return hydrationPromise;
|
||||
}
|
||||
const hydrationRevision = revision;
|
||||
hydrationPromise = storage
|
||||
.read()
|
||||
.then((stored) => {
|
||||
if (revision === hydrationRevision) {
|
||||
selection = parseStoredWorkspaceSelection(stored);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
if (revision === hydrationRevision) {
|
||||
selection = null;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
hydrated = true;
|
||||
notifyListeners();
|
||||
});
|
||||
return hydrationPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
getSelection: () => selection,
|
||||
hydrate,
|
||||
isHydrated: () => hydrated,
|
||||
remember,
|
||||
subscribe: (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,10 @@ const pathnameState = vi.hoisted(() => ({
|
||||
const localParamsState = vi.hoisted(() => ({
|
||||
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
|
||||
}));
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(async () => null as string | null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: routerMock,
|
||||
@@ -20,6 +24,10 @@ vi.mock("expo-router", () => ({
|
||||
usePathname: () => pathnameState.value,
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
navigateToWorkspace,
|
||||
@@ -29,6 +37,7 @@ import {
|
||||
describe("workspace navigation", () => {
|
||||
beforeEach(() => {
|
||||
routerMock.dismissTo.mockReset();
|
||||
asyncStorageMock.setItem.mockClear();
|
||||
pathnameState.value = "/";
|
||||
localParamsState.value = {};
|
||||
});
|
||||
@@ -41,6 +50,10 @@ describe("workspace navigation", () => {
|
||||
navigateToWorkspace("server-1", "workspace-a");
|
||||
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"paseo:last-workspace-route-selection",
|
||||
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import {
|
||||
createLastWorkspaceSelectionStore,
|
||||
type ActiveWorkspaceSelection,
|
||||
type LastWorkspaceSelectionStorage,
|
||||
} from "@/stores/last-workspace-selection";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||
@@ -12,16 +19,34 @@ import {
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
interface NavigateToWorkspaceOptions {
|
||||
currentPathname?: string | null;
|
||||
}
|
||||
|
||||
let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null;
|
||||
const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||
|
||||
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
|
||||
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
||||
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
|
||||
};
|
||||
|
||||
const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
|
||||
lastWorkspaceSelectionStorage,
|
||||
);
|
||||
|
||||
export function hydrateLastWorkspaceSelection(): Promise<void> {
|
||||
return lastWorkspaceSelectionStore.hydrate();
|
||||
}
|
||||
|
||||
export function getLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return lastWorkspaceSelectionStore.getSelection();
|
||||
}
|
||||
|
||||
export function getIsLastWorkspaceSelectionHydrated(): boolean {
|
||||
return lastWorkspaceSelectionStore.isHydrated();
|
||||
}
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
@@ -74,16 +99,17 @@ export function navigateToWorkspace(
|
||||
});
|
||||
}
|
||||
|
||||
lastWorkspaceSelection = { serverId, workspaceId };
|
||||
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
|
||||
router.dismissTo(route);
|
||||
}
|
||||
|
||||
export function navigateToLastWorkspace(): boolean {
|
||||
if (!lastWorkspaceSelection) {
|
||||
const selection = lastWorkspaceSelectionStore.getSelection();
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
|
||||
navigateToWorkspace(selection.serverId, selection.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -95,8 +121,31 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
const selection =
|
||||
parseHostWorkspaceRouteFromPathname(usePathname()) ??
|
||||
parseWorkspaceSelectionFromRouteParams(params);
|
||||
if (selection) {
|
||||
lastWorkspaceSelection = selection;
|
||||
}
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
|
||||
}, [serverId, workspaceId]);
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return useSyncExternalStore(
|
||||
lastWorkspaceSelectionStore.subscribe,
|
||||
getLastWorkspaceSelection,
|
||||
getLastWorkspaceSelection,
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsLastWorkspaceSelectionHydrated(): boolean {
|
||||
return useSyncExternalStore(
|
||||
lastWorkspaceSelectionStore.subscribe,
|
||||
getIsLastWorkspaceSelectionHydrated,
|
||||
getIsLastWorkspaceSelectionHydrated,
|
||||
);
|
||||
}
|
||||
|
||||
void hydrateLastWorkspaceSelection();
|
||||
|
||||
Reference in New Issue
Block a user