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:
Mohamed Boudra
2026-05-31 12:55:08 +08:00
committed by GitHub
parent c8ca503cc3
commit 0e3eb9cf75
13 changed files with 699 additions and 203 deletions
+2 -1
View File
@@ -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 (
+382 -87
View File
@@ -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,
},
}));
+139 -62
View File
@@ -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",
+16
View File
@@ -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;
}