- + {showBackToProjects ? : null} @@ -290,12 +308,15 @@ function ProjectSettingsBody({ readQuery, loadedConfig, loadedRevision, + hasUncommittedWorktreeSetupChanges, readError, selectedHost, queryKey, client, onReload: handleReload, isHostGone, + onBackToProjects, + showBackToProjects, })} ); @@ -305,24 +326,30 @@ interface RenderContentInput { readQuery: ReturnType>; loadedConfig: PaseoConfigRaw | null; loadedRevision: PaseoConfigRevision | null; + hasUncommittedWorktreeSetupChanges: boolean; readError: ProjectConfigRpcError | null; selectedHost: ProjectHostEntry; queryKey: readonly [string, string, string]; client: DaemonClient; onReload: () => void; isHostGone: boolean; + onBackToProjects: () => void; + showBackToProjects: boolean; } function renderContent({ readQuery, loadedConfig, loadedRevision, + hasUncommittedWorktreeSetupChanges, readError, selectedHost, queryKey, client, onReload, isHostGone, + onBackToProjects, + showBackToProjects, }: RenderContentInput) { if (readQuery.isLoading) { return ( @@ -341,7 +368,12 @@ function renderContent({ } if (isHostGone) { - return ; + return ( + + ); } if (!loadedConfig) { @@ -358,6 +390,7 @@ function renderContent({ key={formKey} baseConfig={loadedConfig} revision={loadedRevision} + hasUncommittedWorktreeSetupChanges={hasUncommittedWorktreeSetupChanges} repoRoot={selectedHost.repoRoot} queryKey={queryKey} client={client} @@ -438,6 +471,7 @@ function errorToDetail(error: unknown): string | null { interface ProjectConfigFormProps { baseConfig: PaseoConfigRaw; revision: PaseoConfigRevision | null; + hasUncommittedWorktreeSetupChanges: boolean; repoRoot: string; queryKey: readonly [string, string, string]; client: DaemonClient; @@ -447,6 +481,7 @@ interface ProjectConfigFormProps { function ProjectConfigForm({ baseConfig, revision, + hasUncommittedWorktreeSetupChanges, repoRoot, queryKey, client, @@ -479,6 +514,11 @@ function ProjectConfigForm({ revision: result.revision, requestId: "local-cache", repoRoot, + ...(result.hasUncommittedWorktreeSetupChanges === undefined + ? {} + : { + hasUncommittedWorktreeSetupChanges: result.hasUncommittedWorktreeSetupChanges, + }), }); setWriteError(null); queryClient.invalidateQueries({ queryKey: ["projects"] }); @@ -660,6 +700,13 @@ function ProjectConfigForm({ testID="worktree-setup-section" trailing={setupDocsLink} > + {hasUncommittedWorktreeSetupChanges ? ( + + ) : null} ({ }, noTargetText: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, }, body: { padding: theme.spacing[4], @@ -1097,7 +1144,7 @@ const styles = StyleSheet.create((theme) => ({ }, projectTitle: { color: theme.colors.foreground, - fontSize: theme.fontSize.lg, + fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, flexShrink: 1, }, @@ -1105,7 +1152,7 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[1], }, titleIconFallbackText: { - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, iconColor: { @@ -1122,7 +1169,7 @@ const styles = StyleSheet.create((theme) => ({ }, emptyScripts: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, }, scriptRow: { flexDirection: "row", @@ -1161,11 +1208,11 @@ const styles = StyleSheet.create((theme) => ({ }, modalLabel: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, }, modalInput: { color: theme.colors.foreground, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, @@ -1175,7 +1222,7 @@ const styles = StyleSheet.create((theme) => ({ }, modalMultilineInput: { color: theme.colors.foreground, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, @@ -1193,7 +1240,7 @@ const styles = StyleSheet.create((theme) => ({ }, fieldError: { color: theme.colors.palette.red[300], - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, }, serviceToggleRow: { flexDirection: "row", @@ -1207,12 +1254,12 @@ const styles = StyleSheet.create((theme) => ({ }, serviceToggleLabel: { color: theme.colors.foreground, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, }, modalHint: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, }, placeholderColor: { color: theme.colors.foregroundMuted, diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx index ffb10d7f991..0533d7c8105 100644 --- a/packages/app/src/screens/projects-screen.test.tsx +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects"; import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects"; -const { theme, projectsState, navigate } = vi.hoisted(() => ({ +const { theme, projectsState, push } = vi.hoisted(() => ({ theme: { spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, iconSize: { sm: 14, md: 20 }, @@ -37,7 +37,7 @@ const { theme, projectsState, navigate } = vi.hoisted(() => ({ refetch: vi.fn(), } as UseProjectsResult, }, - navigate: vi.fn(), + push: vi.fn(), })); vi.mock("react-native", () => { @@ -125,7 +125,7 @@ vi.mock("lucide-react-native", () => { }); vi.mock("expo-router", () => ({ - router: { navigate }, + router: { push }, })); vi.mock("react-i18next", () => ({ @@ -217,6 +217,7 @@ function workspaceSummary(overrides: Partial = {}): WorkspaceS workspaceKind: "directory", status: "done", currentBranch: "main", + changeRequestNumber: null, ...overrides, }; } @@ -281,7 +282,7 @@ describe("ProjectsScreen", () => { document.body.appendChild(container); root = createRoot(container); setProjectsState({}); - navigate.mockReset(); + push.mockReset(); }); afterEach(() => { @@ -334,8 +335,8 @@ describe("ProjectsScreen", () => { row.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); }); - expect(navigate).toHaveBeenCalledTimes(1); - expect(navigate).toHaveBeenCalledWith("/settings/hosts/host-a/projects/project-a"); + expect(push).toHaveBeenCalledTimes(1); + expect(push).toHaveBeenCalledWith("/settings/hosts/host-a/projects/project-a"); }); it("does not render a kebab menu on the row", () => { diff --git a/packages/app/src/screens/projects-screen.tsx b/packages/app/src/screens/projects-screen.tsx index 81e1da74299..0220bbf4308 100644 --- a/packages/app/src/screens/projects-screen.tsx +++ b/packages/app/src/screens/projects-screen.tsx @@ -1,6 +1,5 @@ import { useCallback, useMemo } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; -import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight } from "lucide-react-native"; import { useTranslation } from "react-i18next"; @@ -8,8 +7,9 @@ import { ProjectIconView } from "@/components/project-icon-view"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useProjects, type ProjectHostError } from "@/hooks/use-projects"; import { useProjectIcons } from "@/projects/icons"; +import { createProjectIconTarget } from "@/projects/icon-target"; import { settingsStyles } from "@/styles/settings"; -import { buildProjectSettingsRoute } from "@/utils/host-routes"; +import { openProjectSettings } from "@/navigation/settings-navigation"; import type { ProjectHostEntry, ProjectSummary } from "@/utils/projects"; interface ProjectsScreenProps { @@ -36,13 +36,13 @@ export default function ProjectsScreen({ serverId }: ProjectsScreenProps) { const scopedErrors = hostErrors.filter((error) => error.serverId === serverId); const iconTargets = useMemo( () => - hostProjects.map(({ project, host }) => ({ - serverId: host.serverId, - projectViewKey: project.viewKey, - projectId: host.projectId, - iconWorkingDir: host.repoRoot, - customIconRevision: host.customIconRevision, - })), + hostProjects.flatMap(({ project, host }) => { + const target = createProjectIconTarget({ + projectViewKey: project.viewKey, + placement: { ...host, iconWorkingDir: host.repoRoot }, + }); + return target ? [target] : []; + }), [hostProjects], ); const iconDataByProjectViewKey = useProjectIcons({ @@ -112,7 +112,7 @@ function ProjectRow({ project, host, isFirst, iconDataUri }: ProjectRowProps) { const { viewKey } = project; const { projectName } = host; const handleNavigate = useCallback(() => { - router.navigate(buildProjectSettingsRoute(host.serverId, host.projectId)); + openProjectSettings(host.serverId, host.projectId); }, [host.projectId, host.serverId]); const rowStyle = useCallback( @@ -181,7 +181,7 @@ const styles = StyleSheet.create((theme) => ({ }, emptyText: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, }, errorsBanner: { borderWidth: 1, @@ -193,7 +193,7 @@ const styles = StyleSheet.create((theme) => ({ }, errorsBannerText: { color: theme.colors.palette.red[300], - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, }, row: { gap: theme.spacing[3], @@ -218,7 +218,7 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", }, iconFallbackText: { - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, }, spinnerColor: { color: theme.colors.foregroundMuted, diff --git a/packages/app/src/screens/schedules-screen.tsx b/packages/app/src/screens/schedules-screen.tsx index 76498053a97..ce2f8b9dfde 100644 --- a/packages/app/src/screens/schedules-screen.tsx +++ b/packages/app/src/screens/schedules-screen.tsx @@ -408,7 +408,7 @@ const styles = StyleSheet.create((theme) => ({ }, errorsBannerText: { color: theme.colors.palette.red[300], - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, }, filterEmpty: { flexGrow: 1, @@ -439,12 +439,12 @@ const styles = StyleSheet.create((theme) => ({ }, emptyDescription: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.base, textAlign: "center", }, message: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.lg, + fontSize: theme.fontSize.base, textAlign: "center", }, // Static color holder read by the spinner; keeps the muted token without diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index 89ecfd4318e..7cc3294ef5f 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -1,20 +1,65 @@ -import { useMemo, useState, useCallback, useEffect } from "react"; +import { useMemo, useState, useCallback, useEffect, type ReactElement } 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 { ChevronLeft } from "lucide-react-native"; +import { ChevronLeft, Import } from "lucide-react-native"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "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 { SearchField } from "@/components/ui/search-field"; 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 { type AgentHistoryHostError, useAgentHistory } from "@/hooks/use-agent-history"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { useImportSession } from "@/hooks/use-import-session"; import { useHosts } from "@/runtime/host-runtime"; import { buildOpenProjectRoute } from "@/utils/host-routes"; +/** Long enough that a typed word is one request, short enough to feel live. */ +const SEARCH_DEBOUNCE_MS = 200; + +const sessionsHostOptionTestID = (serverId: string) => `sessions-host-filter-item-${serverId}`; + +/** + * A host that failed while others answered. Without this the list silently + * under-reports, and under a query "No sessions match" becomes a claim the app + * has no basis for. + */ +function SessionHostErrorsBanner({ + errors, + t, +}: { + errors: AgentHistoryHostError[]; + t: TFunction; +}): ReactElement { + return ( + + + {errors.map((error) => ( + + {t("sessions.hostLoadFailed", { host: error.serverName })} + + ))} + + + ); +} + +/** An empty list means something different once a query is narrowing it. */ +function resolveEmptyText(input: { + t: TFunction; + isSearching: boolean; + isAllHosts: boolean; +}): string { + if (input.isSearching) return input.t("sessions.noMatches"); + if (input.isAllHosts) return input.t("sessions.empty"); + return "No sessions for this host"; +} + export function SessionsScreen() { const isFocused = useIsFocused(); @@ -28,13 +73,29 @@ export function SessionsScreen() { function SessionsScreenContent() { const { theme } = useUnistyles(); const { t } = useTranslation(); + const importSession = useImportSession(); const hosts = useHosts(); const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID); + const [searchInput, setSearchInput] = useState(""); + const search = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS).trim(); const historyServerId = selectedHost === ALL_HOSTS_OPTION_ID ? null : selectedHost; - const { agents, hasMore, isInitialLoad, isLoadingMore, isError, loadMore, refreshAll } = - useAgentHistory({ - serverId: historyServerId, - }); + const { + agents, + hasMore, + isInitialLoad, + isLoadingMore, + isError, + isSearchSupported, + isSearchTruncated, + searchMatchesByAgentKey, + hostErrors, + loadMore, + refreshAll, + } = useAgentHistory({ + serverId: historyServerId, + search, + }); + const isSearching = isSearchSupported && search.length > 0; useEffect(() => { if ( @@ -52,44 +113,71 @@ function SessionsScreenContent() { void refreshAll().finally(() => setIsManualRefresh(false)); }, [refreshAll]); - const sortedAgents = useMemo(() => { - return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()); - }, [agents]); - - const emptyText = - selectedHost === ALL_HOSTS_OPTION_ID ? t("sessions.empty") : "No sessions for this host"; + // `useAgentHistory` owns the order: recency at rest, relevance under a query. + const emptyText = resolveEmptyText({ + t, + isSearching, + isAllHosts: selectedHost === ALL_HOSTS_OPTION_ID, + }); const showHostFilter = hosts.length > 1; - const showLoadError = isError && sortedAgents.length === 0; + const showFilterRow = showHostFilter || isSearchSupported; + const showLoadError = isError && agents.length === 0; const handleBack = useCallback(() => { router.navigate(buildOpenProjectRoute()); }, []); - const listFooterComponent = useMemo( - () => - hasMore ? ( + const handleClearSearch = useCallback(() => setSearchInput(""), []); + + const listFooterComponent = useMemo(() => { + // A ranked result set has no next page — reaching a weaker match means + // narrowing the query, so the footer says that instead of offering a button. + if (isSearchTruncated) { + return ( -