diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 772566efdc..d0410b2e13 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -259,7 +259,7 @@ export async function reloadWorkspace(page: Page, workspaceId: string): Promise< } export async function openSessions(page: Page): Promise { - const sessionsButton = page.getByTestId("sidebar-sessions"); + const sessionsButton = page.getByTestId("sidebar-history"); await expect(sessionsButton).toBeVisible({ timeout: 30_000 }); await sessionsButton.click(); await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), { diff --git a/packages/app/e2e/helpers/sidebar.ts b/packages/app/e2e/helpers/sidebar.ts index eaea7c09b2..c3a7180726 100644 --- a/packages/app/e2e/helpers/sidebar.ts +++ b/packages/app/e2e/helpers/sidebar.ts @@ -31,9 +31,9 @@ export async function closeMobileAgentSidebar(page: Page): Promise { // The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position. export async function expectMobileAgentSidebarVisible(page: Page): Promise { - await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 }); + await expect(page.getByTestId("sidebar-history")).toBeInViewport({ timeout: 5_000 }); } export async function expectMobileAgentSidebarHidden(page: Page): Promise { - await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 }); + await expect(page.getByTestId("sidebar-history")).not.toBeInViewport({ timeout: 5_000 }); } diff --git a/packages/app/e2e/sidebar-history-sessions.spec.ts b/packages/app/e2e/sidebar-history-sessions.spec.ts index c65a89096d..9e4d3e8553 100644 --- a/packages/app/e2e/sidebar-history-sessions.spec.ts +++ b/packages/app/e2e/sidebar-history-sessions.spec.ts @@ -289,11 +289,9 @@ async function expectWorkspaceFocusedOnSession( }); await waitForWorkspaceTabsVisible(page); await expectWorkspaceTabVisible(page, input.agent.id); - await expect(page.getByRole("button", { name: input.agent.title, exact: true })).toHaveAttribute( - "aria-selected", - "true", - { timeout: 30_000 }, - ); + await expect( + page.getByTestId(`workspace-tab-agent_${input.agent.id}`).filter({ visible: true }).first(), + ).toHaveAttribute("aria-selected", "true", { timeout: 30_000 }); } async function openHistoryFromSidebar(page: Page) { diff --git a/packages/app/maestro/sidebar-theme-repro.yaml b/packages/app/maestro/sidebar-theme-repro.yaml index 3c9dff9e96..e2cf1105ee 100644 --- a/packages/app/maestro/sidebar-theme-repro.yaml +++ b/packages/app/maestro/sidebar-theme-repro.yaml @@ -1,10 +1,10 @@ appId: sh.paseo --- -# Ensure sidebar is closed: if sidebar-sessions is visible, close it via swipe left +# Ensure sidebar is closed: if sidebar-history is visible, close it via swipe left - runFlow: when: visible: - id: "sidebar-sessions" + id: "sidebar-history" commands: - swipe: direction: LEFT @@ -21,7 +21,7 @@ appId: sh.paseo # Verify sidebar opened - assertVisible: - id: "sidebar-sessions" + id: "sidebar-history" - takeScreenshot: 01-sidebar-opened # Close sidebar via swipe left diff --git a/packages/app/src/app/_layout.test.tsx b/packages/app/src/app/_layout.test.tsx new file mode 100644 index 0000000000..4f0b75d828 --- /dev/null +++ b/packages/app/src/app/_layout.test.tsx @@ -0,0 +1,265 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "@testing-library/react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { HostRuntimeBootstrapState } from "./_layout"; + +const { state, startHostRuntimeBootstrapMock } = vi.hoisted(() => { + return { + state: { + anyOnlineHostServerId: null as string | null, + daemonStartError: null as string | null, + daemonStartIsRunning: false, + latestBootstrapState: null as HostRuntimeBootstrapState | null, + }, + startHostRuntimeBootstrapMock: vi.fn(), + }; +}); + +vi.mock("@/styles/unistyles", () => ({})); +vi.mock("@gorhom/portal", () => ({ + PortalProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@tanstack/react-query", () => ({ + QueryClientProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("expo-linking", () => ({ addEventListener: vi.fn(), getInitialURL: vi.fn() })); +vi.mock("expo-notifications", () => ({ + addNotificationResponseReceivedListener: vi.fn(), + getLastNotificationResponseAsync: vi.fn(), + setNotificationHandler: vi.fn(), +})); +vi.mock("expo-router", () => ({ + Stack: Object.assign(() => null, { Protected: () => null, Screen: () => null }), + useGlobalSearchParams: () => ({}), + usePathname: () => "/", + useRouter: () => ({ navigate: vi.fn(), replace: vi.fn() }), +})); +vi.mock("react-native", () => ({ + View: ({ children }: { children?: React.ReactNode }) => children, +})); +vi.mock("react-native-gesture-handler", () => ({ + Gesture: { Pan: () => ({}) }, + GestureDetector: ({ children }: { children: React.ReactNode }) => children, + GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("react-native-keyboard-controller", () => ({ + KeyboardProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("react-native-reanimated", () => ({ + Extrapolation: { CLAMP: "clamp" }, + interpolate: vi.fn(), + runOnJS: (fn: () => void) => fn, + useSharedValue: (value: unknown) => ({ value }), +})); +vi.mock("react-native-safe-area-context", () => ({ + SafeAreaProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" + ? factory({ colors: { surface0: "#000", foreground: "#fff" } }) + : factory, + }, + UnistylesRuntime: { setAdaptiveThemes: vi.fn(), setTheme: vi.fn() }, + useUnistyles: () => ({ theme: { colors: { surface0: "#000", foreground: "#fff" } } }), +})); + +vi.mock("@/components/command-center", () => ({ CommandCenter: () => null })); +vi.mock("@/components/worktree-setup-callout-source", () => ({ + WorktreeSetupCalloutSource: () => null, +})); +vi.mock("@/components/download-toast", () => ({ DownloadToast: () => null })); +vi.mock("@/components/quitting-overlay", () => ({ QuittingOverlay: () => null })); +vi.mock("@/components/keyboard-shortcuts-dialog", () => ({ KeyboardShortcutsDialog: () => null })); +vi.mock("@/components/left-sidebar", () => ({ LeftSidebar: () => null })); +vi.mock("@/components/project-picker-modal", () => ({ ProjectPickerModal: () => null })); +vi.mock("@/components/workspace-setup-dialog", () => ({ WorkspaceSetupDialog: () => null })); +vi.mock("@/components/workspace-shortcut-targets-subscriber", () => ({ + WorkspaceShortcutTargetsSubscriber: () => null, +})); +vi.mock("@/constants/layout", () => ({ + getIsElectronRuntime: () => false, + useIsCompactFormFactor: () => false, +})); +vi.mock("@/constants/platform", () => ({ isNative: false, isWeb: true })); +vi.mock("@/contexts/horizontal-scroll-context", () => ({ + HorizontalScrollProvider: ({ children }: { children: React.ReactNode }) => children, + useHorizontalScrollOptional: () => null, +})); +vi.mock("@/contexts/session-context", () => ({ + SessionProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@/contexts/sidebar-animation-context", () => ({ + SidebarAnimationProvider: ({ children }: { children: React.ReactNode }) => children, + useSidebarAnimation: () => ({ + animateToClose: vi.fn(), + animateToOpen: vi.fn(), + backdropOpacity: { value: 0 }, + gestureAnimatingRef: { current: false }, + isGesturing: { value: false }, + openGestureRef: {}, + translateX: { value: 0 }, + windowWidth: 320, + }), +})); +vi.mock("@/contexts/sidebar-callout-context", () => ({ + SidebarCalloutProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@/contexts/toast-context", () => ({ + ToastProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@/contexts/voice-context", () => ({ + VoiceProvider: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@/app/host-runtime-bootstrap", () => ({ + startDaemonIfGateAllows: vi.fn(), + startHostRuntimeBootstrap: startHostRuntimeBootstrapMock, +})); +vi.mock("@/desktop/daemon/desktop-daemon", () => ({ shouldUseDesktopDaemon: () => false })); +vi.mock("@/desktop/electron/events", () => ({ listenToDesktopEvent: vi.fn() })); +vi.mock("@/desktop/electron/window", () => ({ updateDesktopWindowControls: vi.fn() })); +vi.mock("@/desktop/host", () => ({ getDesktopHost: () => null })); +vi.mock("@/desktop/settings/desktop-settings", () => ({ loadDesktopSettings: vi.fn() })); +vi.mock("@/desktop/updates/rosetta-callout-source", () => ({ RosettaCalloutSource: () => null })); +vi.mock("@/desktop/updates/update-callout-source", () => ({ UpdateCalloutSource: () => null })); +vi.mock("@/hooks/use-active-worktree-new-action", () => ({ useActiveWorktreeNewAction: vi.fn() })); +vi.mock("@/hooks/use-favicon-status", () => ({ useFaviconStatus: vi.fn() })); +vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useKeyboardShortcuts: vi.fn() })); +vi.mock("@/hooks/use-latched-boolean", () => ({ useLatchedBoolean: (value: boolean) => value })); +vi.mock("@/hooks/use-open-project", () => ({ useOpenProject: () => vi.fn() })); +vi.mock("@/hooks/use-settings", () => ({ + useAppSettings: () => ({ + isLoading: false, + settings: { theme: "dark" }, + updateSettings: vi.fn(), + }), +})); +vi.mock("@/hooks/use-stable-event", () => ({ useStableEvent: (fn: unknown) => fn })); +vi.mock("@/hooks/use-workspace-navigation", () => ({ navigateToWorkspace: vi.fn() })); +vi.mock("@/keyboard/keyboard-action-dispatcher", () => ({ + keyboardActionDispatcher: { dispatch: vi.fn() }, +})); +vi.mock("@/polyfills/crypto", () => ({ polyfillCrypto: vi.fn() })); +vi.mock("@/query/query-client", () => ({ queryClient: {} })); +vi.mock("@/runtime/host-runtime", () => ({ + getHostRuntimeStore: () => ({ + boot: vi.fn(), + getEarliestOnlineHostServerId: () => state.anyOnlineHostServerId, + subscribeAll: () => vi.fn(), + subscribeHostList: () => vi.fn(), + }), + useHostMutations: () => ({ upsertConnectionFromOfferUrl: vi.fn() }), + useHostRuntimeClient: () => null, + useHosts: () => [], +})); +vi.mock("@/runtime/daemon-start-service", () => ({ + getDaemonStartService: () => ({ + getLastError: () => state.daemonStartError, + isRunning: () => state.daemonStartIsRunning, + recordError: vi.fn(), + start: vi.fn(), + subscribe: () => vi.fn(), + }), +})); +vi.mock("@/stores/panel-store", () => ({ usePanelStore: vi.fn(() => vi.fn()) })); +vi.mock("@/stores/session-store", () => ({ + useSessionStore: { getState: () => ({ sessions: {} }) }, +})); +vi.mock("@/styles/theme", () => ({ THEME_TO_UNISTYLES: { dark: "dark" } })); +vi.mock("@/utils/active-host", () => ({ resolveActiveHost: () => null })); +vi.mock("@/utils/desktop-sidebar-toggle", () => ({ + toggleDesktopSidebarsWithCheckoutIntent: vi.fn(), +})); +vi.mock("@/utils/host-routes", () => ({ + buildHostRootRoute: (serverId: string) => `/h/${serverId}`, + mapPathnameToServer: () => null, + parseHostAgentRouteFromPathname: () => null, + parseServerIdFromPathname: () => null, + parseWorkspaceOpenIntent: () => null, +})); +vi.mock("@/utils/notification-routing", () => ({ + buildNotificationRoute: () => "/", + resolveNotificationTarget: () => ({}), +})); +vi.mock("@/utils/os-notifications", () => ({ + WEB_NOTIFICATION_CLICK_EVENT: "notification-click", + ensureOsNotificationPermission: vi.fn(), +})); +vi.mock("@/utils/workspace-execution", () => ({ + resolveWorkspaceIdByExecutionDirectory: () => null, +})); +vi.mock("@/utils/workspace-navigation", () => ({ prepareWorkspaceTab: vi.fn() })); + +let useHostRuntimeBootstrapState: typeof import("./_layout").useHostRuntimeBootstrapState; + +function BootstrapStateProbe() { + state.latestBootstrapState = useHostRuntimeBootstrapState(); + return null; +} + +describe("HostRuntimeBootstrapProvider startup give-up timer", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.useFakeTimers(); + state.anyOnlineHostServerId = null; + state.daemonStartError = null; + state.daemonStartIsRunning = false; + state.latestBootstrapState = null; + startHostRuntimeBootstrapMock.mockClear(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + async function renderProvider() { + const layout = await import("./_layout"); + useHostRuntimeBootstrapState = layout.useHostRuntimeBootstrapState; + await act(async () => { + root.render( + + + , + ); + }); + } + + it("gives up after 5s when the env override host is not online", async () => { + await renderProvider(); + + expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false); + + await act(async () => { + vi.advanceTimersByTime(5_000); + }); + + expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(true); + }); + + it("does not give up after 5s when an env override host is online", async () => { + state.anyOnlineHostServerId = "server-local"; + + await renderProvider(); + + await act(async () => { + vi.advanceTimersByTime(5_000); + }); + + expect(state.latestBootstrapState?.hasGivenUpWaitingForHost).toBe(false); + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 4d7269e710..83e29f8faf 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -5,6 +5,7 @@ import * as Linking from "expo-linking"; import * as Notifications from "expo-notifications"; import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router"; import { + default as React, createContext, type ReactNode, useCallback, @@ -65,7 +66,6 @@ import { polyfillCrypto } from "@/polyfills/crypto"; import { queryClient } from "@/query/query-client"; import { getHostRuntimeStore, - hasConfiguredLocalDaemonOverride, useHostMutations, useHostRuntimeClient, useHosts, @@ -312,7 +312,7 @@ async function shouldStartBuiltInDaemon(): Promise { return settings.daemon.manageBuiltInDaemon; } -function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { +export function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { useEffect(() => { const store = getHostRuntimeStore(); const daemonStartService = getDaemonStartService({ store }); @@ -327,16 +327,12 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { const anyOnlineHostServerId = useEarliestOnlineHostServerId(); const daemonStartError = useDaemonStartLastError(); const daemonStartIsRunning = useDaemonStartIsRunning(); - const waitForConfiguredLocalDaemon = - hasConfiguredLocalDaemonOverride() && !shouldUseDesktopDaemon(); - const [hasGivenUpWaitingForHost, setHasGivenUpWaitingForHost] = useState(false); useEffect(() => { if ( anyOnlineHostServerId || daemonStartError || daemonStartIsRunning || - waitForConfiguredLocalDaemon || hasGivenUpWaitingForHost ) { return; @@ -347,13 +343,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { return () => { clearTimeout(handle); }; - }, [ - anyOnlineHostServerId, - daemonStartError, - daemonStartIsRunning, - waitForConfiguredLocalDaemon, - hasGivenUpWaitingForHost, - ]); + }, [anyOnlineHostServerId, daemonStartError, daemonStartIsRunning, hasGivenUpWaitingForHost]); const retry = useCallback(() => { const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index d1d9c9b13c..1fb971a640 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -108,7 +108,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, buttonHovered: { diff --git a/packages/app/src/components/sidebar/sidebar-history-navigation-row.tsx b/packages/app/src/components/sidebar/sidebar-history-navigation-row.tsx index 7748144f74..11d8090a54 100644 --- a/packages/app/src/components/sidebar/sidebar-history-navigation-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-history-navigation-row.tsx @@ -1,5 +1,5 @@ import { usePathname } from "expo-router"; -import { MessagesSquare } from "lucide-react-native"; +import { History } from "lucide-react-native"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; interface SidebarHistoryNavigationRowProps { @@ -12,11 +12,11 @@ export function SidebarHistoryNavigationRow({ onPress }: SidebarHistoryNavigatio return ( ); } diff --git a/packages/app/src/components/sidebar/sidebar-shell-header.tsx b/packages/app/src/components/sidebar/sidebar-shell-header.tsx index 292d0620e9..7011ad23c3 100644 --- a/packages/app/src/components/sidebar/sidebar-shell-header.tsx +++ b/packages/app/src/components/sidebar/sidebar-shell-header.tsx @@ -21,8 +21,8 @@ export function SidebarShellHeader({ {withTitlebarDragRegion ? : null} {topSpacerStyle ? : null} - {toggleSlot} {children} + {toggleSlot} ); diff --git a/packages/app/src/sidebar-sessions/session-filtering.test.ts b/packages/app/src/sidebar-sessions/session-filtering.test.ts index 3e1d5b9b51..495f4a13c7 100644 --- a/packages/app/src/sidebar-sessions/session-filtering.test.ts +++ b/packages/app/src/sidebar-sessions/session-filtering.test.ts @@ -192,6 +192,7 @@ describe("sidebar session filtering", () => { }, ], availability, + workspaceNameByKey: new Map([["server-1:workspace-1", "Main"]]), }); expect(filterProjects.map(projectWorkspaceKeys)).toEqual([ diff --git a/packages/app/src/sidebar-sessions/session-filtering.ts b/packages/app/src/sidebar-sessions/session-filtering.ts index 97d76a14ac..62d7e9ea05 100644 --- a/packages/app/src/sidebar-sessions/session-filtering.ts +++ b/packages/app/src/sidebar-sessions/session-filtering.ts @@ -109,6 +109,7 @@ export function deriveSidebarSessionFilterAvailability(input: { export function deriveSidebarSessionFilterProjects(input: { projects: readonly SidebarProjectEntry[]; availability: SidebarSessionFilterAvailability; + workspaceNameByKey: ReadonlyMap; }): SidebarProjectEntry[] { if (input.projects.length === 0 || input.availability.workspaceKeys.length === 0) { return []; @@ -122,9 +123,12 @@ export function deriveSidebarSessionFilterProjects(input: { if (!visibleProjectKeys.has(project.projectKey)) { continue; } - const workspaces = project.workspaces.filter((workspace) => - visibleWorkspaceKeys.has(workspace.workspaceKey), - ); + const workspaces = project.workspaces + .filter((workspace) => visibleWorkspaceKeys.has(workspace.workspaceKey)) + .map((workspace) => { + const name = input.workspaceNameByKey.get(workspace.workspaceKey); + return name && name !== workspace.name ? Object.assign({}, workspace, { name }) : workspace; + }); if (workspaces.length === 0) { continue; } diff --git a/packages/app/src/sidebar-sessions/toggle.tsx b/packages/app/src/sidebar-sessions/toggle.tsx index 00a6722154..5dd3453647 100644 --- a/packages/app/src/sidebar-sessions/toggle.tsx +++ b/packages/app/src/sidebar-sessions/toggle.tsx @@ -1,15 +1,16 @@ -import { Filter } from "lucide-react-native"; +import { Folder, Folders, ListFilter, MessagesSquare, type LucideIcon } from "lucide-react-native"; import { memo, useCallback, useMemo, type ReactElement } from "react"; -import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import { Image, Pressable, Text, View, type PressableStateCallbackType } from "react-native"; +import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types"; import { useVisibleSidebarSessionFilterProjects } from "./use-sidebar-session-workspaces"; @@ -23,6 +24,9 @@ interface SidebarSessionsToggleProps { onFilterChange: (filter: SidebarSessionFilter) => void; } +const PILL_TRANSITION = LinearTransition.duration(160); +const LABEL_FADE = { in: FadeIn.duration(120), out: FadeOut.duration(80) }; + export const SidebarSessionsToggle = memo(function SidebarSessionsToggle({ serverId, mode, @@ -45,10 +49,18 @@ export const SidebarSessionsToggle = memo(function SidebarSessionsToggle({ + - {sessionsActive ? ( { @@ -85,15 +96,19 @@ function SidebarSessionsFilterMenu({ const filterTriggerStyle = useCallback( ({ hovered = false, open = false }: PressableStateCallbackType & { open?: boolean }) => [ styles.filterButton, - filterActive && styles.filterButtonActive, - (hovered || open) && !filterActive && styles.filterButtonHovered, + (hovered || open || filterActive) && styles.filterButtonHovered, ], [filterActive], ); const filterIcon = useMemo( - () => , - [filterIconColor, theme.iconSize.sm], + () => ( + + ), + [filterActive, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm], ); return ( @@ -114,7 +129,7 @@ function SidebarSessionsFilterMenu({ {visibleFilterProjects.length > 0 ? ( <> - Workspaces + Workspace {visibleFilterProjects.flatMap((project) => project.workspaces.map((workspace) => ( - Projects + Project {visibleFilterProjects.map((project) => ( ))} @@ -149,45 +166,62 @@ function SidebarSessionsFilterMenu({ function TogglePill({ label, + icon: Icon, active, onPress, + testID, }: { label: string; + icon: LucideIcon; active: boolean; onPress: () => void; + testID?: string; }) { - const pillStyle = useCallback( - ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.pill, - active && styles.pillActive, - hovered && !active && styles.pillHovered, - ], - [active], + const { theme } = useUnistyles(); + const iconColor = active ? theme.colors.foreground : theme.colors.foregroundMuted; + const pillStyleActive = useMemo(() => [styles.pill, styles.pillActive], []); + const pillStyleHovered = useMemo(() => [styles.pill, styles.pillHovered], []); + const renderInner = useCallback( + ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => { + let pillStyle; + if (active) pillStyle = pillStyleActive; + else if (hovered) pillStyle = pillStyleHovered; + else pillStyle = styles.pill; + return ( + + + {active ? ( + + {label} + + ) : null} + + ); + }, + [Icon, active, iconColor, label, pillStyleActive, pillStyleHovered, theme.iconSize.sm], ); - const textStyle = useMemo(() => [styles.pillText, active && styles.pillTextActive], [active]); return ( - - {label} + + {renderInner} ); } -function FilterItem({ - selected, - label, - description, - onSelect, -}: { - selected: boolean; - label: string; - description?: string; - onSelect: () => void; -}) { +function FilterSectionHeading({ children }: { children: string }) { return ( - - {label} - + + {children} + ); } @@ -209,12 +243,9 @@ function WorkspaceFilterItem({ }, [onFilterChange, workspaceKey]); return ( - + + {label} + ); } @@ -222,18 +253,55 @@ function ProjectFilterItem({ selected, label, projectKey, + serverId, + iconWorkingDir, onFilterChange, }: { selected: boolean; label: string; projectKey: string; + serverId: string | null; + iconWorkingDir: string; onFilterChange: (filter: SidebarSessionFilter) => void; }) { const handleSelect = useCallback(() => { onFilterChange({ type: "project", projectKey }); }, [onFilterChange, projectKey]); - return ; + const leading = useMemo( + () => , + [serverId, iconWorkingDir], + ); + + return ( + + {label} + + ); +} + +function ProjectIconLeading({ serverId, cwd }: { serverId: string | null; cwd: string }) { + const { theme } = useUnistyles(); + const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd }); + const dataUri = useMemo(() => { + if (!icon || !icon.mimeType || !icon.data) { + return null; + } + return `data:${icon.mimeType};base64,${icon.data}`; + }, [icon]); + const imageSource = useMemo(() => (dataUri ? { uri: dataUri } : null), [dataUri]); + + return ( + + {imageSource ? ( + + ) : ( + + + + )} + + ); } const styles = StyleSheet.create((theme) => ({ @@ -242,40 +310,33 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], - paddingHorizontal: { - xs: theme.spacing[3], - md: theme.spacing[4], - }, - paddingTop: theme.spacing[3], + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[2], paddingBottom: theme.spacing[2], }, pillGroup: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], - padding: 2, - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surfaceSidebarHover, minWidth: 0, }, pill: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], minHeight: 28, - justifyContent: "center", - paddingHorizontal: theme.spacing[3], + paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, }, pillActive: { - backgroundColor: theme.colors.surface1, + backgroundColor: theme.colors.surfaceSidebarHover, + paddingRight: theme.spacing[3], }, pillHovered: { - backgroundColor: theme.colors.surfaceSidebar, - }, - pillText: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foregroundMuted, + backgroundColor: theme.colors.surfaceSidebarHover, }, pillTextActive: { + fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, filterButton: { @@ -289,9 +350,6 @@ const styles = StyleSheet.create((theme) => ({ filterButtonHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, - filterButtonActive: { - backgroundColor: theme.colors.accent, - }, filterBadge: { position: "absolute", top: 5, @@ -299,6 +357,26 @@ const styles = StyleSheet.create((theme) => ({ width: 5, height: 5, borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.accentForeground, + backgroundColor: theme.colors.foreground, + }, + filterSectionHeading: { + paddingHorizontal: theme.spacing[3], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + }, + filterSectionHeadingText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + projectIconImage: { + width: theme.iconSize.sm, + height: theme.iconSize.sm, + borderRadius: theme.borderRadius.sm, + }, + projectIconFallback: { + width: theme.iconSize.sm, + height: theme.iconSize.sm, + alignItems: "center", + justifyContent: "center", }, })); diff --git a/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.test.tsx b/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.test.tsx new file mode 100644 index 0000000000..be81763726 --- /dev/null +++ b/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store"; +import { useVisibleSidebarSessionFilterProjects } from "./use-sidebar-session-workspaces"; + +vi.hoisted(() => { + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; +}); + +const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z"); + +const WORKSPACE: WorkspaceDescriptor = { + id: "workspace-1", + projectId: "project-a", + projectDisplayName: "Project A", + projectRootPath: "/abs/path/to/repo", + workspaceDirectory: "/abs/path/to/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "feat-foo", + status: "done", + archivingAt: null, + diffStat: null, + scripts: [], +}; + +const AGENT: Agent = { + serverId: "server-1", + id: "agent-1", + provider: "codex", + status: "idle", + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + lastUserMessageAt: null, + lastActivityAt: TIMESTAMP, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: "Agent", + cwd: "/abs/path/to/repo", + model: null, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + labels: {}, + projectPlacement: null, +}; + +const PROJECTS: SidebarProjectEntry[] = [ + { + projectKey: "project-a", + projectName: "Project A", + projectKind: "git", + iconWorkingDir: "/abs/path/to/repo", + workspaces: [ + { + workspaceKey: "server-1:workspace-1", + serverId: "server-1", + workspaceId: "workspace-1", + projectKey: "project-a", + projectRootPath: "/abs/path/to/repo", + workspaceDirectory: "/abs/path/to/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "/abs/path/to/repo", + statusBucket: "done", + archivingAt: null, + diffStat: null, + scripts: [], + hasRunningScripts: false, + }, + ], + }, +]; + +afterEach(() => { + useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); +}); + +describe("useVisibleSidebarSessionFilterProjects", () => { + it("uses the hydrated workspace branch label for workspace filter options", () => { + act(() => { + useSessionStore.getState().initializeSession("server-1", {} as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces("server-1", new Map([["workspace-1", WORKSPACE]])); + useSessionStore.getState().setAgents("server-1", new Map([["agent-1", AGENT]])); + }); + + const { result } = renderHook(() => + useVisibleSidebarSessionFilterProjects({ + serverId: "server-1", + projects: PROJECTS, + }), + ); + + expect(result.current[0]?.workspaces[0]?.name).toBe("feat-foo"); + expect(result.current[0]?.workspaces[0]?.name).not.toBe("/abs/path/to/repo"); + expect(result.current[0]?.workspaces[0]?.name).not.toBe("repo"); + }); +}); diff --git a/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts b/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts index 3beb87c8f4..5897c45bca 100644 --- a/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts +++ b/packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.ts @@ -2,7 +2,10 @@ import equal from "fast-deep-equal"; import { useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSessionStore } from "@/stores/session-store"; -import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { + createSidebarWorkspaceEntry, + type SidebarProjectEntry, +} from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarSessionWorkspace } from "./types"; import { createSidebarSessionWorkspaceLookup, @@ -17,7 +20,7 @@ export function useSidebarSessionWorkspaces(input: { serverId: string | null; projects: readonly SidebarProjectEntry[]; }): SidebarSessionWorkspace[] { - const workspaceDirectoryById = useStoreWithEqualityFn( + const workspaceFieldsById = useStoreWithEqualityFn( useSessionStore, (state) => { if (!input.serverId) { @@ -28,11 +31,16 @@ export function useSidebarSessionWorkspaces(input: { return {}; } - const directoryById: Record = {}; + const fieldsById: Record = + {}; for (const workspace of workspaces.values()) { - directoryById[workspace.id] = workspace.workspaceDirectory; + const entry = createSidebarWorkspaceEntry({ serverId: input.serverId, workspace }); + fieldsById[entry.workspaceId] = { + name: entry.name, + workspaceDirectory: entry.workspaceDirectory, + }; } - return directoryById; + return fieldsById; }, equal, ); @@ -45,7 +53,8 @@ export function useSidebarSessionWorkspaces(input: { const result: SidebarSessionWorkspace[] = []; for (const project of input.projects) { for (const workspace of project.workspaces) { - const workspaceDirectory = workspaceDirectoryById[workspace.workspaceId]; + const workspaceFields = workspaceFieldsById[workspace.workspaceId]; + const workspaceDirectory = workspaceFields?.workspaceDirectory; if (!workspaceDirectory) { continue; } @@ -53,7 +62,7 @@ export function useSidebarSessionWorkspaces(input: { serverId: input.serverId, workspaceId: workspace.workspaceId, workspaceKey: workspace.workspaceKey, - workspaceName: workspace.name, + workspaceName: workspaceFields.name, projectKey: project.projectKey, projectName: project.projectName, workspaceDirectory, @@ -61,7 +70,7 @@ export function useSidebarSessionWorkspaces(input: { } } return result; - }, [input.projects, input.serverId, workspaceDirectoryById]); + }, [input.projects, input.serverId, workspaceFieldsById]); } export function useVisibleSidebarSessionFilterProjects(input: { @@ -99,7 +108,10 @@ export function useVisibleSidebarSessionFilterProjects(input: { deriveSidebarSessionFilterProjects({ projects: input.projects, availability, + workspaceNameByKey: new Map( + workspaces.map((workspace) => [workspace.workspaceKey, workspace.workspaceName]), + ), }), - [availability, input.projects], + [availability, input.projects, workspaces], ); } diff --git a/packages/app/src/sidebar-sessions/view.tsx b/packages/app/src/sidebar-sessions/view.tsx index e0f52cd9b1..784be8a039 100644 --- a/packages/app/src/sidebar-sessions/view.tsx +++ b/packages/app/src/sidebar-sessions/view.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useMemo, type ReactElement } from "react"; +import React, { memo, useCallback, useMemo, useState, type ReactElement } from "react"; import { FlatList, Pressable, @@ -19,7 +19,6 @@ import { import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; import { useSessionStore } from "@/stores/session-store"; import { navigateToPreparedWorkspaceTab, prepareWorkspaceTab } from "@/utils/workspace-navigation"; -import { shortenPath } from "@/utils/shorten-path"; import { formatTimeAgo } from "@/utils/time"; import { createSidebarSessionWorkspaceLookup, @@ -100,6 +99,7 @@ const SidebarSessionRow = memo(function SidebarSessionRow({ serverId: string; }): ReactElement | null { const { theme } = useUnistyles(); + const [isHovered, setIsHovered] = useState(false); const agent = useSessionStore( useShallow((state) => selectSidebarSessionSlice(state, serverId, id)), ); @@ -135,15 +135,21 @@ const SidebarSessionRow = memo(function SidebarSessionRow({ target, }); }, [agentCwd, id, serverId]); + + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const pressableStyle = useCallback( - ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ + ({ pressed }: PressableStateCallbackType) => [ styles.row, - hovered && styles.rowHovered, + isHovered && styles.rowHovered, pressed && styles.rowPressed, ], - [], + [isHovered], ); + const titleStyle = useMemo(() => [styles.title, isHovered && styles.titleHovered], [isHovered]); + if (!agent || agent.archivedAt) { return null; } @@ -151,27 +157,25 @@ const SidebarSessionRow = memo(function SidebarSessionRow({ const ProviderIcon = getProviderIcon(agent.provider); return ( - - - - - - - - {agent.title || "New session"} - - {formatTimeAgo(agent.lastActivityAt)} + + + + - - {shortenPath(agent.cwd)} + + {agent.title || "New session"} - - + + {formatTimeAgo(agent.lastActivityAt)} + + + ); }); @@ -189,22 +193,19 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, listContent: { - paddingHorizontal: { - xs: theme.spacing[3], - md: theme.spacing[4], - }, + paddingHorizontal: theme.spacing[2], paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[6], - gap: theme.spacing[1], + paddingBottom: theme.spacing[4], }, row: { - minHeight: 52, + minHeight: 36, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[1], }, rowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, @@ -214,39 +215,30 @@ const styles = StyleSheet.create((theme) => ({ }, providerIconWrap: { width: theme.iconSize.md, + height: theme.iconSize.md, alignItems: "center", justifyContent: "center", flexShrink: 0, }, - rowContent: { - flex: 1, - minWidth: 0, - }, - titleRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minWidth: 0, - }, title: { flex: 1, minWidth: 0, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, + fontWeight: "400", + lineHeight: 20, color: theme.colors.foreground, + opacity: 0.76, + }, + titleHovered: { + opacity: 1, }, timeAgo: { flexShrink: 0, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, - path: { - marginTop: 2, - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, emptyState: { - paddingHorizontal: theme.spacing[3], + paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[6], }, emptyText: {