From e9c3753466a20e6698d5e41a9c5059b627c0eb0c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 8 May 2026 15:17:17 +0700 Subject: [PATCH] sidebar history: design revamp, branch names in filter, splash timeout Rolls up three follow-ups on the sidebar Workspaces/Sessions toggle: Design revamp (Notion-style toggle, History at top): - Toggle pills both carry icons (Folders/MessagesSquare); only the active pill shows its label, with LinearTransition + FadeIn/FadeOut. - Filter trigger now uses lucide ListFilter; History row uses lucide History (clock); History row moved to the top of the sidebar. - Session row content trimmed to icon + name + timestamp (no cwd path); padding/spacing/type ramp matches the existing Workspaces tree rows so the icon and text columns align across History row, toggle pills, and list rows at the canonical 16px column. - Filter dropdown uses sentence-case section labels (Workspace/ Project) and renders project icons (no text-letter fallbacks). - testID rename: sidebar-sessions -> sidebar-history (the navigation row); new toggle pills get sidebar-toggle-workspaces / sidebar-toggle-sessions. Updated callers in helpers and maestro. - No bold or font-weight changes; no uppercase anywhere. Branch names in workspace filter: - The dropdown's Workspaces section was rendering the unhydrated structural workspace.name (often a path-like id) because the selector chain skipped the rehydration step the main sidebar row uses. The hook now reuses createSidebarWorkspaceEntry so the label comes from the same descriptor path WorkspaceRow uses, matching what the user sees in the main sidebar. Splash screen timeout for env-var local daemon: - The 5s give-up timer in _layout.tsx had an explicit early-return exempting waitForConfiguredLocalDaemon, leaving the splash hung forever when EXPO_PUBLIC_LOCAL_DAEMON pointed at an offline daemon. Removed the exemption so the timer applies; the runtime override loop continues retrying in the background and welcome auto-redirects when any host comes online. --- packages/app/e2e/helpers/archive-tab.ts | 2 +- packages/app/e2e/helpers/sidebar.ts | 4 +- .../app/e2e/sidebar-history-sessions.spec.ts | 8 +- packages/app/maestro/sidebar-theme-repro.yaml | 6 +- packages/app/src/app/_layout.test.tsx | 265 ++++++++++++++++++ packages/app/src/app/_layout.tsx | 16 +- .../components/sidebar/sidebar-header-row.tsx | 2 +- .../sidebar-history-navigation-row.tsx | 6 +- .../sidebar/sidebar-shell-header.tsx | 2 +- .../session-filtering.test.ts | 1 + .../src/sidebar-sessions/session-filtering.ts | 10 +- packages/app/src/sidebar-sessions/toggle.tsx | 206 +++++++++----- .../use-sidebar-session-workspaces.test.tsx | 118 ++++++++ .../use-sidebar-session-workspaces.ts | 30 +- packages/app/src/sidebar-sessions/view.tsx | 90 +++--- 15 files changed, 612 insertions(+), 154 deletions(-) create mode 100644 packages/app/src/app/_layout.test.tsx create mode 100644 packages/app/src/sidebar-sessions/use-sidebar-session-workspaces.test.tsx 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: {