Polish schedules list and editing (#1860)
* feat(schedules): polish schedules list and editing Adds host filtering, status grouping, and target resolution to the schedules screen. Extracts a shared HostFilter from sessions. Improves schedule editing with project targets and model hydration. Handles missing targets server-side instead of retrying forever. * test(mcp): update schedule mocks for createOrReplace * fix(schedules): only show host name when multiple hosts exist
This commit is contained in:
@@ -1,14 +1,40 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||
import { ScheduleFormSheet } from "@/components/schedules/schedule-form-sheet";
|
||||
import { SchedulesTable } from "@/components/schedules/schedules-table";
|
||||
import { SchedulesTable, type ScheduleRowView } from "@/components/schedules/schedules-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useSchedules, type ScheduleHostSection } from "@/hooks/use-schedules";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
useSchedules,
|
||||
type AggregatedSchedule,
|
||||
type ScheduleHostError,
|
||||
} from "@/hooks/use-schedules";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolveSchedule,
|
||||
type ScheduleBucket,
|
||||
type ScheduleTargetAgent,
|
||||
} from "@/schedules/schedule-derivation";
|
||||
import {
|
||||
buildProjectNameByCwd,
|
||||
buildScheduleProjectTargets,
|
||||
} from "@/schedules/schedule-project-targets";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
type FormState =
|
||||
@@ -16,6 +42,11 @@ type FormState =
|
||||
| { mode: "create" }
|
||||
| { mode: "edit"; serverId: string; schedule: ScheduleSummary };
|
||||
|
||||
const STATUS_FILTER_OPTIONS: { value: ScheduleBucket; label: string; testID: string }[] = [
|
||||
{ value: "runnable", label: "Active", testID: "schedules-filter-active" },
|
||||
{ value: "ended", label: "Ended", testID: "schedules-filter-ended" },
|
||||
];
|
||||
|
||||
export function SchedulesScreen(): ReactElement {
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
@@ -27,20 +58,101 @@ export function SchedulesScreen(): ReactElement {
|
||||
}
|
||||
|
||||
function SchedulesScreenContent(): ReactElement {
|
||||
const { sections, isLoading, isError, error, refetch } = useSchedules();
|
||||
const { schedules, hostErrors, isInitialLoad, isError, refetch } = useSchedules();
|
||||
const { agents } = useAggregatedAgents({ includeArchived: true });
|
||||
const { projects } = useProjects();
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
|
||||
// Per-host agent-directory readiness from the runtime, not the aggregate agent
|
||||
// flag: the aggregate `isInitialLoad` flips false as soon as *any* host has
|
||||
// agents, so a still-loading host would falsely mark its agent-target
|
||||
// schedules "gone". `hasEverLoadedAgentDirectory` is true only once that
|
||||
// host's directory has loaded at least once.
|
||||
const agentDirReadyHosts = useMemo(() => {
|
||||
void runtimeVersion;
|
||||
const ready = new Set<string>();
|
||||
for (const host of hosts) {
|
||||
if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) {
|
||||
ready.add(host.serverId);
|
||||
}
|
||||
}
|
||||
return ready;
|
||||
}, [hosts, runtime, runtimeVersion]);
|
||||
|
||||
const [form, setForm] = useState<FormState>({ mode: "closed" });
|
||||
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
|
||||
const [statusFilter, setStatusFilter] = useState<ScheduleBucket>("runnable");
|
||||
|
||||
const openCreate = useCallback(() => {
|
||||
setForm({ mode: "create" });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedHost !== ALL_HOSTS_OPTION_ID &&
|
||||
!hosts.some((host) => host.serverId === selectedHost)
|
||||
) {
|
||||
setSelectedHost(ALL_HOSTS_OPTION_ID);
|
||||
}
|
||||
}, [hosts, selectedHost]);
|
||||
|
||||
const openEdit = useCallback((serverId: string, schedule: ScheduleSummary) => {
|
||||
setForm({ mode: "edit", serverId, schedule });
|
||||
const openCreate = useCallback(() => setForm({ mode: "create" }), []);
|
||||
const openEdit = useCallback((schedule: AggregatedSchedule) => {
|
||||
setForm({ mode: "edit", serverId: schedule.serverId, schedule });
|
||||
}, []);
|
||||
const closeForm = useCallback(() => setForm({ mode: "closed" }), []);
|
||||
|
||||
const closeForm = useCallback(() => {
|
||||
setForm({ mode: "closed" });
|
||||
}, []);
|
||||
const agentsByKey = useMemo(() => {
|
||||
const map = new Map<string, ScheduleTargetAgent>();
|
||||
for (const agent of agents) {
|
||||
map.set(`${agent.serverId}:${agent.id}`, { title: agent.title, provider: agent.provider });
|
||||
}
|
||||
return map;
|
||||
}, [agents]);
|
||||
|
||||
const projectNameByCwd = useMemo(
|
||||
() => buildProjectNameByCwd(buildScheduleProjectTargets(projects)),
|
||||
[projects],
|
||||
);
|
||||
|
||||
// Resolve every schedule's derived state and target line once, then partition
|
||||
// by the host and status filters. Sorted newest-first for a stable order
|
||||
// across hosts.
|
||||
const resolvedRows = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return schedules.map((schedule) => ({
|
||||
schedule,
|
||||
resolved: resolveSchedule({
|
||||
schedule,
|
||||
serverId: schedule.serverId,
|
||||
now,
|
||||
agentsByKey,
|
||||
projectNameByCwd,
|
||||
agentDataLoaded: agentDirReadyHosts.has(schedule.serverId),
|
||||
}),
|
||||
}));
|
||||
}, [schedules, agentsByKey, projectNameByCwd, agentDirReadyHosts]);
|
||||
|
||||
const visibleRows = useMemo<ScheduleRowView[]>(() => {
|
||||
const singleHost = hosts.length <= 1;
|
||||
return resolvedRows
|
||||
.filter(
|
||||
({ schedule, resolved }) =>
|
||||
(selectedHost === ALL_HOSTS_OPTION_ID || schedule.serverId === selectedHost) &&
|
||||
resolved.bucket === statusFilter,
|
||||
)
|
||||
.sort((a, b) => Date.parse(b.schedule.createdAt) - Date.parse(a.schedule.createdAt))
|
||||
.map(({ schedule, resolved }) => ({
|
||||
schedule,
|
||||
targetLabel: resolved.target.label,
|
||||
provider: resolved.target.provider,
|
||||
state: resolved.state,
|
||||
serverName: schedule.serverName,
|
||||
singleHost,
|
||||
}));
|
||||
}, [resolvedRows, selectedHost, statusFilter, hosts.length]);
|
||||
|
||||
const headerAction = useMemo(
|
||||
() => (
|
||||
@@ -51,14 +163,24 @@ function SchedulesScreenContent(): ReactElement {
|
||||
[openCreate],
|
||||
);
|
||||
|
||||
const showLoadError = isError && schedules.length === 0;
|
||||
const showHostFilter = hosts.length > 1;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MenuHeader title="Schedules" rightContent={headerAction} />
|
||||
<SchedulesBody
|
||||
sections={sections}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
<SchedulesScreenBody
|
||||
rows={visibleRows}
|
||||
hostErrors={hostErrors}
|
||||
hasSchedules={schedules.length > 0}
|
||||
isInitialLoad={isInitialLoad}
|
||||
showLoadError={showLoadError}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
showHostFilter={showHostFilter}
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={setSelectedHost}
|
||||
onRetry={refetch}
|
||||
onCreate={openCreate}
|
||||
onEdit={openEdit}
|
||||
@@ -74,24 +196,38 @@ function SchedulesScreenContent(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function SchedulesBody({
|
||||
sections,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
function SchedulesScreenBody({
|
||||
rows,
|
||||
hostErrors,
|
||||
hasSchedules,
|
||||
isInitialLoad,
|
||||
showLoadError,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
showHostFilter,
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
onRetry,
|
||||
onCreate,
|
||||
onEdit,
|
||||
}: {
|
||||
sections: ScheduleHostSection[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
rows: ScheduleRowView[];
|
||||
hostErrors: ScheduleHostError[];
|
||||
hasSchedules: boolean;
|
||||
isInitialLoad: boolean;
|
||||
showLoadError: boolean;
|
||||
statusFilter: ScheduleBucket;
|
||||
onStatusFilterChange: (value: ScheduleBucket) => void;
|
||||
showHostFilter: boolean;
|
||||
hosts: ReturnType<typeof useHosts>;
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
onRetry: () => void;
|
||||
onCreate: () => void;
|
||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
||||
onEdit: (schedule: AggregatedSchedule) => void;
|
||||
}): ReactElement {
|
||||
if (isLoading) {
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<LoadingSpinner size="large" color={styles.spinner.color} />
|
||||
@@ -99,85 +235,95 @@ function SchedulesBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
if (showLoadError) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.message}>{error?.message ?? "Could not load schedules"}</Text>
|
||||
<Text style={styles.message}>Unable to load schedules</Text>
|
||||
<Button variant="ghost" onPress={onRetry} testID="schedules-retry">
|
||||
Retry
|
||||
Try again
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
if (!hasSchedules) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<View style={styles.centered} testID="schedules-empty">
|
||||
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
|
||||
<Text style={styles.message}>No schedules yet</Text>
|
||||
<Button leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||
New schedule
|
||||
<Button variant="ghost" leftIcon={Plus} onPress={onCreate} testID="schedules-empty-new">
|
||||
Create a schedule
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
testID="schedules-sections"
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<ScheduleHostSectionView key={section.serverId} section={section} onEdit={onEdit} />
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleHostSectionView({
|
||||
section,
|
||||
onEdit,
|
||||
}: {
|
||||
section: ScheduleHostSection;
|
||||
onEdit: (serverId: string, schedule: ScheduleSummary) => void;
|
||||
}): ReactElement {
|
||||
const handleEdit = useCallback(
|
||||
(schedule: ScheduleSummary) => {
|
||||
onEdit(section.serverId, schedule);
|
||||
},
|
||||
[onEdit, section.serverId],
|
||||
);
|
||||
const emptyMessage = section.isOnline ? "No schedules" : "Host offline";
|
||||
const emptyFilterText = statusFilter === "ended" ? "No ended schedules" : "No active schedules";
|
||||
|
||||
return (
|
||||
<View style={styles.section} testID={`schedules-section-${section.serverId}`}>
|
||||
<Text style={styles.sectionTitle} testID={`schedules-section-title-${section.serverId}`}>
|
||||
{section.serverName}
|
||||
</Text>
|
||||
{section.error ? <Text style={styles.sectionError}>{section.error}</Text> : null}
|
||||
{section.schedules.length > 0 ? (
|
||||
<SchedulesTable
|
||||
serverId={section.serverId}
|
||||
schedules={section.schedules}
|
||||
onEditSchedule={handleEdit}
|
||||
<View style={styles.body}>
|
||||
<View style={styles.filterRow}>
|
||||
{showHostFilter ? (
|
||||
<HostFilter
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={onSelectHost}
|
||||
triggerTestID="schedules-host-filter-trigger"
|
||||
/>
|
||||
) : null}
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={statusFilter}
|
||||
onValueChange={onStatusFilterChange}
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
testID="schedules-status-filter"
|
||||
/>
|
||||
) : null}
|
||||
{section.schedules.length === 0 && !section.error ? (
|
||||
<View style={styles.sectionEmpty}>
|
||||
<Text style={styles.sectionEmptyText}>{emptyMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
testID="schedules-list"
|
||||
>
|
||||
{hostErrors.length > 0 ? <ScheduleHostErrorsBanner errors={hostErrors} /> : null}
|
||||
{rows.length > 0 ? (
|
||||
<SchedulesTable rows={rows} onEditSchedule={onEdit} />
|
||||
) : (
|
||||
<View style={styles.filterEmpty}>
|
||||
<Text style={styles.filterEmptyText}>{emptyFilterText}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleHostErrorsBanner({ errors }: { errors: ScheduleHostError[] }): ReactElement {
|
||||
return (
|
||||
<View style={styles.errorsBannerWrap}>
|
||||
<View style={styles.errorsBanner} testID="schedules-host-errors">
|
||||
{errors.map((error) => (
|
||||
<Text key={error.serverId} style={styles.errorsBannerText}>
|
||||
{`${error.serverName}: Could not load schedules`}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CONTENT_MAX_WIDTH = 720;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
@@ -185,43 +331,52 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[6],
|
||||
padding: theme.spacing[6],
|
||||
},
|
||||
filterRow: {
|
||||
width: "100%",
|
||||
maxWidth: CONTENT_MAX_WIDTH,
|
||||
alignSelf: "center",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
gap: theme.spacing[4],
|
||||
gap: theme.spacing[3],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[6],
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
errorsBannerWrap: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
maxWidth: CONTENT_MAX_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
sectionError: {
|
||||
errorsBanner: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
errorsBannerText: {
|
||||
color: theme.colors.palette.red[300],
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
filterEmpty: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
maxWidth: CONTENT_MAX_WIDTH,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
color: theme.colors.destructive,
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[6],
|
||||
alignItems: "center",
|
||||
},
|
||||
sectionEmpty: {
|
||||
width: "100%",
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
sectionEmptyText: {
|
||||
filterEmptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, View, Text } from "react-native";
|
||||
import { useMemo, useState, useCallback, useEffect } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, ChevronLeft, Server } from "lucide-react-native";
|
||||
import { ChevronLeft } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { AgentList } from "@/components/agent-list";
|
||||
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
||||
import {
|
||||
ALL_HOSTS_OPTION_ID,
|
||||
getHostPickerLabel,
|
||||
HostPicker,
|
||||
} from "@/components/hosts/host-picker";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||
import { useAgentHistory } from "@/hooks/use-agent-history";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { type HostProfile } from "@/types/host-connection";
|
||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||
|
||||
export function SessionsScreen() {
|
||||
@@ -30,71 +25,6 @@ export function SessionsScreen() {
|
||||
return <SessionsScreenContent />;
|
||||
}
|
||||
|
||||
function SessionsHostFilter({
|
||||
hosts,
|
||||
selectedHost,
|
||||
onSelectHost,
|
||||
}: {
|
||||
hosts: HostProfile[];
|
||||
selectedHost: string;
|
||||
onSelectHost: (serverId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
const selectedHostLabel = useMemo(
|
||||
() => getHostPickerLabel(hosts, selectedHost, { includeAllHost: true }),
|
||||
[hosts, selectedHost],
|
||||
);
|
||||
|
||||
const handleFilterOpen = useCallback(() => setIsFilterOpen(true), []);
|
||||
|
||||
const filterTriggerStyle = useCallback(
|
||||
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.filterTrigger,
|
||||
Boolean(hovered) && styles.filterTriggerHovered,
|
||||
pressed && styles.filterTriggerPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<HostPicker
|
||||
hosts={hosts}
|
||||
value={selectedHost}
|
||||
onSelect={onSelectHost}
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
includeAllHost
|
||||
searchable={false}
|
||||
title="Filter by host"
|
||||
desktopPlacement="bottom-start"
|
||||
>
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="sessions-host-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedHostLabel}`}
|
||||
>
|
||||
{selectedHost === ALL_HOSTS_OPTION_ID ? (
|
||||
<Server size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<HostStatusDotSlot serverId={selectedHost} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedHostLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</HostPicker>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsScreenContent() {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -152,10 +82,11 @@ function SessionsScreenContent() {
|
||||
<MenuHeader title={t("sessions.title")} />
|
||||
{showHostFilter ? (
|
||||
<View style={styles.filterContainer}>
|
||||
<SessionsHostFilter
|
||||
<HostFilter
|
||||
hosts={hosts}
|
||||
selectedHost={selectedHost}
|
||||
onSelectHost={setSelectedHost}
|
||||
triggerTestID="sessions-host-filter-trigger"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -207,32 +138,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
paddingTop: theme.spacing[4],
|
||||
},
|
||||
filterTriggerWrap: {
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
filterTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
filterTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
filterTriggerPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
|
||||
Reference in New Issue
Block a user