Flatten settings sidebar with a host picker (#1241)
* Flatten settings sidebar with a host picker Settings had two implicit tiers: app-level sections as flat sidebar rows, and per-host settings hidden behind a drill-in to one monolithic host page. This flattens both into a single sidebar with an "App" group and a "Host" group, where the host's settings are top-level rows scoped by a host picker. - Split the monolithic HostPage into four section pages: Connections, Orchestration (was the unnamed inject-tools + system-prompt block), Providers, and Daemon (status, updates, restart, remove). - Host picker reuses the canonical <Combobox> sidebar host switcher; defaults to the local host so a single-local-host user never thinks about "the daemon". - Host sections become real sub-routes (/settings/hosts/[id]/[section]); the old /settings/hosts/[id] redirects to the connections section, so existing deep links (pairing, workspace, open-project) keep working. - "Open providers" from the project screen now lands on the Providers section. * Fix e2e host-settings navigation for the flat sidebar The flat-settings refactor moved host settings onto separate section pages, but three e2e helpers still expected the old monolithic host page: - openDesktopSettings: the daemon-lifecycle card now lives on the Daemon section — navigate there before asserting it. - acp catalog: "Add provider" now lives on the Providers section — open that section before opening the add-provider modal. - openAddHostFlow: "Add host" is now an item inside the host picker (Combobox), not a standalone button — open the picker first. Test-only; no product changes. Fixes the 8 playwright failures on this branch.
This commit is contained in:
@@ -867,7 +867,8 @@ function RootStack() {
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default function SettingsHostRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen view={view} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsHostSectionRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const rawSection = typeof params.hostSection === "string" ? params.hostSection : "";
|
||||
const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections";
|
||||
const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SettingsScreen view={view} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { buildSettingsHostSectionRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsHostIndexRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
|
||||
if (!serverId) {
|
||||
return <Redirect href={buildSettingsRoute()} />;
|
||||
}
|
||||
|
||||
return <Redirect href={buildSettingsHostSectionRoute(serverId, "connections")} />;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||
import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
@@ -62,7 +62,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
);
|
||||
|
||||
const handleOpenProviders = useCallback(() => {
|
||||
router.push(buildSettingsHostRoute(serverId));
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
|
||||
}, [router, serverId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { ComponentType, ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
@@ -22,6 +30,9 @@ import {
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Server,
|
||||
Network,
|
||||
Workflow,
|
||||
Boxes,
|
||||
Keyboard,
|
||||
Stethoscope,
|
||||
Info,
|
||||
@@ -72,6 +83,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
|
||||
import { IntegrationsSection } from "@/desktop/components/integrations-section";
|
||||
import { isElectronRuntime } from "@/desktop/host";
|
||||
@@ -81,7 +93,12 @@ import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
|
||||
import {
|
||||
HostConnectionsPage,
|
||||
HostDaemonPage,
|
||||
HostOrchestrationPage,
|
||||
HostProvidersPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -90,8 +107,9 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsSectionRoute,
|
||||
type HostSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -103,7 +121,7 @@ import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-st
|
||||
export type SettingsView =
|
||||
| { kind: "root" }
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string }
|
||||
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||
| { kind: "projects" }
|
||||
| { kind: "project"; projectKey: string };
|
||||
|
||||
@@ -123,6 +141,19 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
];
|
||||
|
||||
interface HostSectionItem {
|
||||
id: HostSectionSlug;
|
||||
label: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
}
|
||||
|
||||
const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
{ id: "connections", label: "Connections", icon: Network },
|
||||
{ id: "orchestration", label: "Orchestration", icon: Workflow },
|
||||
{ id: "providers", label: "Providers", icon: Boxes },
|
||||
{ id: "daemon", label: "Daemon", icon: Server },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme helpers (General section)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -709,6 +740,26 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local daemon first, then remaining hosts in their existing order. Lets the
|
||||
* picker and the active-host resolver agree on a stable "first" host.
|
||||
*/
|
||||
function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] {
|
||||
return useMemo(() => {
|
||||
if (!localServerId) {
|
||||
return hosts;
|
||||
}
|
||||
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
|
||||
if (localIndex <= 0) {
|
||||
return hosts;
|
||||
}
|
||||
const next = hosts.slice();
|
||||
const [local] = next.splice(localIndex, 1);
|
||||
next.unshift(local);
|
||||
return next;
|
||||
}, [hosts, localServerId]);
|
||||
}
|
||||
|
||||
interface SidebarSectionButtonProps {
|
||||
itemId: SettingsSectionSlug;
|
||||
label: string;
|
||||
@@ -751,6 +802,49 @@ function SidebarSectionButton({
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarHostSectionButtonProps {
|
||||
itemId: HostSectionSlug;
|
||||
label: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
isSelected: boolean;
|
||||
onSelect: (section: HostSectionSlug) => void;
|
||||
}
|
||||
|
||||
function SidebarHostSectionButton({
|
||||
itemId,
|
||||
label,
|
||||
icon: IconComponent,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: SidebarHostSectionButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(itemId);
|
||||
}, [onSelect, itemId]);
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
const labelStyle = useMemo(
|
||||
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
||||
[isSelected, theme.colors.foreground],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={handlePress}
|
||||
testID={`settings-host-section-${itemId}`}
|
||||
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
||||
>
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarProjectsButtonProps {
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
@@ -782,83 +876,213 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarHostItemProps {
|
||||
// Sentinel option id for the "Add host" row appended to the picker list.
|
||||
const ADD_HOST_OPTION_ID = "__add_host__";
|
||||
|
||||
interface HostPickerOptionProps {
|
||||
serverId: string;
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
isLocal: boolean;
|
||||
onSelect: (serverId: string) => void;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
function SidebarHostItem({ serverId, label, isSelected, isLocal, onSelect }: SidebarHostItemProps) {
|
||||
function HostPickerOption({
|
||||
serverId,
|
||||
label,
|
||||
isLocal,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: HostPickerOptionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(serverId);
|
||||
}, [onSelect, serverId]);
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
const labelStyle = useMemo(
|
||||
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
|
||||
[isSelected, theme.colors.foreground],
|
||||
const leadingSlot = useMemo(
|
||||
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={handlePress}
|
||||
testID={`settings-host-entry-${serverId}`}
|
||||
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
|
||||
>
|
||||
<Server
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
{isLocal ? (
|
||||
// The local host carries a "Local" marker; the active host is conveyed by the
|
||||
// row's selected check, so both can coexist on one row.
|
||||
const trailingSlot = useMemo(
|
||||
() =>
|
||||
isLocal ? (
|
||||
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
|
||||
Local
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
) : undefined,
|
||||
[isLocal],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
label={label}
|
||||
leadingSlot={leadingSlot}
|
||||
trailingSlot={trailingSlot}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID={`settings-host-picker-item-${serverId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const leadingSlot = useMemo(
|
||||
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
label="Add host"
|
||||
leadingSlot={leadingSlot}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
testID="settings-add-host"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface HostPickerProps {
|
||||
activeServerId: string | null;
|
||||
sortedHosts: HostProfile[];
|
||||
localServerId: string | null;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
onAddHost: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scopes the four host sections to a host. Reuses the canonical sidebar host
|
||||
* switcher pattern (left-sidebar.tsx): a quiet row-styled trigger opening a
|
||||
* <Combobox>. The local host is listed first and tagged "Local"; an "Add host"
|
||||
* row is always reachable from the list — even with a single host.
|
||||
*/
|
||||
function HostPicker({
|
||||
activeServerId,
|
||||
sortedHosts,
|
||||
localServerId,
|
||||
onSelectHost,
|
||||
onAddHost,
|
||||
}: HostPickerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const triggerRef = useRef<View | null>(null);
|
||||
const activeHost =
|
||||
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
|
||||
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label }));
|
||||
return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: "Add host" }];
|
||||
}, [sortedHosts]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (id === ADD_HOST_OPTION_ID) {
|
||||
onAddHost();
|
||||
return;
|
||||
}
|
||||
onSelectHost(id);
|
||||
},
|
||||
[onAddHost, onSelectHost],
|
||||
);
|
||||
|
||||
const renderOption = useCallback(
|
||||
({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}): ReactElement => {
|
||||
if (option.id === ADD_HOST_OPTION_ID) {
|
||||
return <AddHostOption active={active} onPress={onPress} />;
|
||||
}
|
||||
return (
|
||||
<HostPickerOption
|
||||
serverId={option.id}
|
||||
label={option.label}
|
||||
isLocal={localServerId !== null && option.id === localServerId}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[localServerId],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
const triggerStyle = useCallback(
|
||||
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
sidebarStyles.pickerTrigger,
|
||||
hovered && sidebarStyles.pickerTriggerHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
ref={triggerRef}
|
||||
style={triggerStyle}
|
||||
onPress={handleOpen}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Switch host"
|
||||
testID="settings-host-picker"
|
||||
>
|
||||
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
|
||||
{activeHost?.label ?? "Host"}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={options}
|
||||
value={activeServerId ?? ""}
|
||||
onSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
searchable={false}
|
||||
title="Switch host"
|
||||
desktopMinWidth={240}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
anchorRef={triggerRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
view: SettingsView;
|
||||
onSelectSection: (section: SettingsSectionSlug) => void;
|
||||
onSelectHostSection: (section: HostSectionSlug) => void;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
onSelectProjects: () => void;
|
||||
onAddHost: () => void;
|
||||
onBackToWorkspace: () => void;
|
||||
activeHostServerId: string | null;
|
||||
layout: "desktop" | "mobile";
|
||||
}
|
||||
|
||||
function SettingsSidebar({
|
||||
view,
|
||||
onSelectSection,
|
||||
onSelectHostSection,
|
||||
onSelectHost,
|
||||
onSelectProjects,
|
||||
onAddHost,
|
||||
onBackToWorkspace,
|
||||
activeHostServerId,
|
||||
layout,
|
||||
}: SettingsSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const sortedHosts = useMemo(() => {
|
||||
if (!localServerId) {
|
||||
return hosts;
|
||||
}
|
||||
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
|
||||
if (localIndex <= 0) {
|
||||
return hosts;
|
||||
}
|
||||
const next = hosts.slice();
|
||||
const [local] = next.splice(localIndex, 1);
|
||||
next.unshift(local);
|
||||
return next;
|
||||
}, [hosts, localServerId]);
|
||||
const sortedHosts = useSortedHosts(hosts, localServerId);
|
||||
const hasHosts = sortedHosts.length > 0;
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -872,7 +1096,7 @@ function SettingsSidebar({
|
||||
[insets.top, isDesktop],
|
||||
);
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const selectedHostSection = view.kind === "host" ? view.section : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
|
||||
@@ -893,6 +1117,7 @@ function SettingsSidebar({
|
||||
/>
|
||||
) : null}
|
||||
<View style={sidebarStyles.list}>
|
||||
<Text style={sidebarStyles.groupLabel}>App</Text>
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<SidebarSectionButton
|
||||
@@ -909,30 +1134,43 @@ function SettingsSidebar({
|
||||
))}
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
<View style={sidebarStyles.list}>
|
||||
{sortedHosts.map((host) => (
|
||||
<SidebarHostItem
|
||||
key={host.serverId}
|
||||
serverId={host.serverId}
|
||||
label={host.label}
|
||||
isSelected={selectedServerId === host.serverId}
|
||||
isLocal={localServerId !== null && host.serverId === localServerId}
|
||||
onSelect={onSelectHost}
|
||||
{hasHosts ? (
|
||||
<View style={sidebarStyles.list}>
|
||||
<Text style={sidebarStyles.groupLabel}>Host</Text>
|
||||
<HostPicker
|
||||
activeServerId={activeHostServerId}
|
||||
sortedHosts={sortedHosts}
|
||||
localServerId={localServerId}
|
||||
onSelectHost={onSelectHost}
|
||||
onAddHost={onAddHost}
|
||||
/>
|
||||
))}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add host"
|
||||
onPress={onAddHost}
|
||||
testID="settings-add-host"
|
||||
style={sidebarItemStyle}
|
||||
>
|
||||
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sidebarStyles.label} numberOfLines={1}>
|
||||
Add host
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{HOST_SECTION_ITEMS.map((item) => (
|
||||
<SidebarHostSectionButton
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
isSelected={selectedHostSection === item.id}
|
||||
onSelect={onSelectHostSection}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<View style={sidebarStyles.list}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Add host"
|
||||
onPress={onAddHost}
|
||||
testID="settings-add-host"
|
||||
style={sidebarItemStyle}
|
||||
>
|
||||
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={sidebarStyles.label} numberOfLines={1}>
|
||||
Add host
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -967,9 +1205,18 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[webScrollbarStyle],
|
||||
);
|
||||
const hosts = useHosts();
|
||||
const localServerId = useLocalDaemonServerId();
|
||||
const sortedHosts = useSortedHosts(hosts, localServerId);
|
||||
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
|
||||
|
||||
// The host the four sections scope to: the host on the active view, otherwise
|
||||
// the local daemon, otherwise the first available host.
|
||||
const activeHostServerId = useMemo(() => {
|
||||
if (view.kind === "host") return view.serverId;
|
||||
return localServerId ?? sortedHosts[0]?.serverId ?? null;
|
||||
}, [view, localServerId, sortedHosts]);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(nextTheme: AppSettings["theme"]) => {
|
||||
void updateSettings({ theme: nextTheme });
|
||||
@@ -1055,7 +1302,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
|
||||
const handleHostAdded = useCallback(
|
||||
({ serverId }: { serverId: string }) => {
|
||||
const target = buildSettingsHostRoute(serverId);
|
||||
const target = buildSettingsHostSectionRoute(serverId, "connections");
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
@@ -1077,16 +1324,34 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
[isCompactLayout, router],
|
||||
);
|
||||
|
||||
// Picker: swap the host but keep the section the user is already looking at.
|
||||
const handleSelectHost = useCallback(
|
||||
(serverId: string) => {
|
||||
const target = buildSettingsHostRoute(serverId);
|
||||
const section: HostSectionSlug = view.kind === "host" ? view.section : "connections";
|
||||
const target = buildSettingsHostSectionRoute(serverId, section);
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
router.replace(target);
|
||||
}
|
||||
},
|
||||
[isCompactLayout, router],
|
||||
[isCompactLayout, router, view],
|
||||
);
|
||||
|
||||
const handleSelectHostSection = useCallback(
|
||||
(section: HostSectionSlug) => {
|
||||
if (!activeHostServerId) {
|
||||
handleAddHost();
|
||||
return;
|
||||
}
|
||||
const target = buildSettingsHostSectionRoute(activeHostServerId, section);
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
router.replace(target);
|
||||
}
|
||||
},
|
||||
[activeHostServerId, handleAddHost, isCompactLayout, router],
|
||||
);
|
||||
|
||||
const handleSelectProjects = useCallback(() => {
|
||||
@@ -1140,13 +1405,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
titleAccessory?: ReactNode;
|
||||
} | null => {
|
||||
if (view.kind === "host") {
|
||||
const host = hosts.find((h) => h.serverId === view.serverId);
|
||||
if (!host) return null;
|
||||
return {
|
||||
title: host.label,
|
||||
Icon: Server,
|
||||
titleAccessory: <HostRenameButton host={host} />,
|
||||
};
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: item.label, Icon: item.icon };
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
@@ -1161,7 +1422,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
|
||||
const content = (() => {
|
||||
if (view.kind === "host") {
|
||||
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||
switch (view.section) {
|
||||
case "connections":
|
||||
return <HostConnectionsPage serverId={view.serverId} />;
|
||||
case "orchestration":
|
||||
return <HostOrchestrationPage serverId={view.serverId} />;
|
||||
case "providers":
|
||||
return <HostProvidersPage serverId={view.serverId} />;
|
||||
case "daemon":
|
||||
return <HostDaemonPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||
}
|
||||
}
|
||||
if (view.kind === "projects") {
|
||||
return <ProjectsScreen view={view} />;
|
||||
@@ -1251,10 +1521,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHostSection={handleSelectHostSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
activeHostServerId={activeHostServerId}
|
||||
layout="mobile"
|
||||
/>
|
||||
</ScrollView>
|
||||
@@ -1293,10 +1565,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
<SettingsSidebar
|
||||
view={view}
|
||||
onSelectSection={handleSelectSection}
|
||||
onSelectHostSection={handleSelectHostSection}
|
||||
onSelectHost={handleSelectHost}
|
||||
onSelectProjects={handleSelectProjects}
|
||||
onAddHost={handleAddHost}
|
||||
onBackToWorkspace={handleBackToWorkspace}
|
||||
activeHostServerId={activeHostServerId}
|
||||
layout="desktop"
|
||||
/>
|
||||
<View style={desktopStyles.contentPane}>
|
||||
@@ -1448,6 +1722,13 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
item: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -1472,11 +1753,25 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
localMarker: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
pickerTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
pickerTriggerHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
pickerTriggerLabel: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -74,25 +74,30 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
||||
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
||||
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
||||
|
||||
export interface HostPageProps {
|
||||
serverId: string;
|
||||
onHostRemoved?: () => void;
|
||||
function useHostProfile(serverId: string): HostProfile | null {
|
||||
const daemons = useHosts();
|
||||
return daemons.find((entry) => entry.serverId === serverId) ?? null;
|
||||
}
|
||||
|
||||
export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
||||
const daemons = useHosts();
|
||||
const host = daemons.find((entry) => entry.serverId === serverId) ?? null;
|
||||
function HostNotFound() {
|
||||
return (
|
||||
<View>
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Host not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostStatusBadges({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
|
||||
const daemonVersion = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.version ?? null,
|
||||
);
|
||||
|
||||
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
|
||||
const activeConnection = snapshot?.activeConnection ?? null;
|
||||
const lastError = snapshot?.lastError ?? null;
|
||||
const statusLabel = formatConnectionStatus(connectionStatus);
|
||||
const statusTone = getConnectionStatusTone(connectionStatus);
|
||||
let statusColor: string;
|
||||
@@ -117,8 +122,6 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
||||
}
|
||||
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
|
||||
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
|
||||
const connectionError =
|
||||
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
|
||||
const statusPillStyle = useMemo(
|
||||
() => [styles.statusPill, { backgroundColor: statusPillBg }],
|
||||
@@ -130,46 +133,125 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
||||
);
|
||||
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<View testID={`settings-host-page-${serverId}`}>
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Host not found</Text>
|
||||
</View>
|
||||
return (
|
||||
<View style={styles.identityBadges} testID="host-page-identity">
|
||||
<View style={statusPillStyle}>
|
||||
<View style={statusDotStyle} />
|
||||
<Text style={statusTextStyle}>{statusLabel}</Text>
|
||||
</View>
|
||||
);
|
||||
{connectionBadge ? (
|
||||
<View style={styles.badgePill}>
|
||||
{connectionBadge.icon}
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{connectionBadge.text}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{versionBadgeText ? (
|
||||
<View style={styles.badgePill}>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{versionBadgeText}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function HostConnectionError({ serverId }: { serverId: string }) {
|
||||
const snapshot = useHostRuntimeSnapshot(serverId);
|
||||
const lastError = snapshot?.lastError ?? null;
|
||||
const connectionError =
|
||||
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
if (!connectionError) return null;
|
||||
return <Text style={styles.errorText}>{connectionError}</Text>;
|
||||
}
|
||||
|
||||
export function HostConnectionsPage({ serverId }: { serverId: string }) {
|
||||
const host = useHostProfile(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID={`settings-host-page-${serverId}`}>
|
||||
<View style={styles.identityBadges} testID="host-page-identity">
|
||||
<View style={statusPillStyle}>
|
||||
<View style={statusDotStyle} />
|
||||
<Text style={statusTextStyle}>{statusLabel}</Text>
|
||||
</View>
|
||||
{connectionBadge ? (
|
||||
<View style={styles.badgePill}>
|
||||
{connectionBadge.icon}
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{connectionBadge.text}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{versionBadgeText ? (
|
||||
<View style={styles.badgePill}>
|
||||
<Text style={styles.badgeText} numberOfLines={1}>
|
||||
{versionBadgeText}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{connectionError ? <Text style={styles.errorText}>{connectionError}</Text> : null}
|
||||
|
||||
<View>
|
||||
<HostConnectionError serverId={serverId} />
|
||||
<ConnectionsSection host={host} />
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
<PairDeviceRow />
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
<DaemonSection host={host} isLocalDaemon={isLocalDaemon} />
|
||||
export function HostOrchestrationPage({ serverId }: { serverId: string }) {
|
||||
const host = useHostProfile(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{isConnected ? (
|
||||
<SettingsSection title="Orchestration">
|
||||
<InjectPaseoToolsCard serverId={serverId} />
|
||||
<AppendSystemPromptCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
) : (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Connect to this host to manage orchestration</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostProvidersPage({ serverId }: { serverId: string }) {
|
||||
const host = useHostProfile(serverId);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ProvidersSection serverId={serverId} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostDaemonPage({
|
||||
serverId,
|
||||
onHostRemoved,
|
||||
}: {
|
||||
serverId: string;
|
||||
onHostRemoved?: () => void;
|
||||
}) {
|
||||
const host = useHostProfile(serverId);
|
||||
const isLocalDaemon = useIsLocalDaemon(serverId);
|
||||
|
||||
if (!host) {
|
||||
return <HostNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.daemonHeader}>
|
||||
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
|
||||
{host.label}
|
||||
</Text>
|
||||
<HostRenameButton host={host} />
|
||||
</View>
|
||||
|
||||
<HostStatusBadges serverId={serverId} />
|
||||
|
||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||
|
||||
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
|
||||
</View>
|
||||
@@ -337,7 +419,7 @@ function ConnectionRow({
|
||||
if (latencyLoading) return "...";
|
||||
if (latencyError) return "Timeout";
|
||||
if (latencyMs != null) return formatLatency(latencyMs);
|
||||
return "\u2014";
|
||||
return "—";
|
||||
})();
|
||||
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
|
||||
|
||||
@@ -378,23 +460,6 @@ function ConnectionRow({
|
||||
);
|
||||
}
|
||||
|
||||
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Daemon settings">
|
||||
<InjectPaseoToolsCard serverId={host.serverId} />
|
||||
<AppendSystemPromptCard serverId={host.serverId} />
|
||||
</SettingsSection>
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
<PairDeviceRow />
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
@@ -612,7 +677,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
<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>
|
||||
<Text style={settingsStyles.rowHint}>Adds a system prompt to all agents</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -803,6 +868,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
daemonHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
daemonHeaderLabel: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
identityBadges: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -390,6 +390,14 @@ export function isSettingsSectionSlug(value: string): value is SettingsSectionSl
|
||||
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export const HOST_SECTION_SLUGS = ["connections", "orchestration", "providers", "daemon"] as const;
|
||||
|
||||
export type HostSectionSlug = (typeof HOST_SECTION_SLUGS)[number];
|
||||
|
||||
export function isHostSectionSlug(value: string): value is HostSectionSlug {
|
||||
return (HOST_SECTION_SLUGS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function buildSettingsRoute() {
|
||||
return "/settings" as const;
|
||||
}
|
||||
@@ -406,6 +414,14 @@ export function buildSettingsHostRoute(serverId: string) {
|
||||
return `/settings/hosts/${encodeSegment(normalized)}` as const;
|
||||
}
|
||||
|
||||
export function buildSettingsHostSectionRoute(serverId: string, section: HostSectionSlug) {
|
||||
const normalized = trimNonEmpty(serverId);
|
||||
if (!normalized) {
|
||||
throw new Error("buildSettingsHostSectionRoute requires a non-empty serverId");
|
||||
}
|
||||
return `/settings/hosts/${encodeSegment(normalized)}/${section}` as const;
|
||||
}
|
||||
|
||||
export function buildProjectsSettingsRoute() {
|
||||
return "/settings/projects" as const;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user