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.
This commit is contained in:
Mohamed Boudra
2026-05-08 17:58:50 +07:00
parent 9c1bea7135
commit e9c3753466
15 changed files with 612 additions and 154 deletions
+1 -1
View File
@@ -259,7 +259,7 @@ export async function reloadWorkspace(page: Page, workspaceId: string): Promise<
}
export async function openSessions(page: Page): Promise<void> {
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())}$`), {
+2 -2
View File
@@ -31,9 +31,9 @@ export async function closeMobileAgentSidebar(page: Page): Promise<void> {
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
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<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
await expect(page.getByTestId("sidebar-history")).not.toBeInViewport({ timeout: 5_000 });
}
@@ -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) {
@@ -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
+265
View File
@@ -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(
<layout.HostRuntimeBootstrapProvider>
<BootstrapStateProbe />
</layout.HostRuntimeBootstrapProvider>,
);
});
}
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);
});
});
+3 -13
View File
@@ -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<boolean> {
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() });
@@ -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: {
@@ -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 (
<SidebarHeaderRow
icon={MessagesSquare}
icon={History}
label="History"
onPress={onPress}
isActive={isSessionsActive}
testID="sidebar-sessions"
testID="sidebar-history"
/>
);
}
@@ -21,8 +21,8 @@ export function SidebarShellHeader({
<Fragment>
{withTitlebarDragRegion ? <TitlebarDragRegion /> : null}
{topSpacerStyle ? <View style={topSpacerStyle} /> : null}
{toggleSlot}
{children}
{toggleSlot}
</Fragment>
);
@@ -192,6 +192,7 @@ describe("sidebar session filtering", () => {
},
],
availability,
workspaceNameByKey: new Map([["server-1:workspace-1", "Main"]]),
});
expect(filterProjects.map(projectWorkspaceKeys)).toEqual([
@@ -109,6 +109,7 @@ export function deriveSidebarSessionFilterAvailability(input: {
export function deriveSidebarSessionFilterProjects(input: {
projects: readonly SidebarProjectEntry[];
availability: SidebarSessionFilterAvailability;
workspaceNameByKey: ReadonlyMap<string, string>;
}): 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;
}
+142 -64
View File
@@ -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({
<View style={styles.pillGroup}>
<TogglePill
label="Workspaces"
active={mode === "workspaces"}
icon={Folders}
active={!sessionsActive}
onPress={handleWorkspacesPress}
testID="sidebar-toggle-workspaces"
/>
<TogglePill
label="Sessions"
icon={MessagesSquare}
active={sessionsActive}
onPress={handleSessionsPress}
testID="sidebar-toggle-sessions"
/>
<TogglePill label="Sessions" active={sessionsActive} onPress={handleSessionsPress} />
</View>
{sessionsActive ? (
<SidebarSessionsFilterMenu
@@ -75,7 +87,6 @@ function SidebarSessionsFilterMenu({
}) {
const { theme } = useUnistyles();
const filterActive = filter.type !== "all";
const filterIconColor = filterActive ? theme.colors.accentForeground : theme.colors.foreground;
const visibleFilterProjects = useVisibleSidebarSessionFilterProjects({ serverId, projects });
const handleAllSelect = useCallback(() => {
@@ -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(
() => <Filter size={theme.iconSize.sm} color={filterIconColor} />,
[filterIconColor, theme.iconSize.sm],
() => (
<ListFilter
size={theme.iconSize.sm}
color={filterActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
),
[filterActive, theme.colors.foreground, theme.colors.foregroundMuted, theme.iconSize.sm],
);
return (
@@ -114,7 +129,7 @@ function SidebarSessionsFilterMenu({
{visibleFilterProjects.length > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
<FilterSectionHeading>Workspace</FilterSectionHeading>
{visibleFilterProjects.flatMap((project) =>
project.workspaces.map((workspace) => (
<WorkspaceFilterItem
@@ -130,13 +145,15 @@ function SidebarSessionsFilterMenu({
)),
)}
<DropdownMenuSeparator />
<DropdownMenuLabel>Projects</DropdownMenuLabel>
<FilterSectionHeading>Project</FilterSectionHeading>
{visibleFilterProjects.map((project) => (
<ProjectFilterItem
key={project.projectKey}
selected={filter.type === "project" && filter.projectKey === project.projectKey}
label={project.projectName}
projectKey={project.projectKey}
serverId={serverId}
iconWorkingDir={project.iconWorkingDir}
onFilterChange={onFilterChange}
/>
))}
@@ -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 (
<Animated.View layout={PILL_TRANSITION} style={pillStyle}>
<Icon size={theme.iconSize.sm} color={iconColor} />
{active ? (
<Animated.Text
entering={LABEL_FADE.in}
exiting={LABEL_FADE.out}
style={styles.pillTextActive}
>
{label}
</Animated.Text>
) : null}
</Animated.View>
);
},
[Icon, active, iconColor, label, pillStyleActive, pillStyleHovered, theme.iconSize.sm],
);
const textStyle = useMemo(() => [styles.pillText, active && styles.pillTextActive], [active]);
return (
<Pressable accessibilityRole="button" onPress={onPress} style={pillStyle}>
<Text style={textStyle}>{label}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
onPress={onPress}
testID={testID}
>
{renderInner}
</Pressable>
);
}
function FilterItem({
selected,
label,
description,
onSelect,
}: {
selected: boolean;
label: string;
description?: string;
onSelect: () => void;
}) {
function FilterSectionHeading({ children }: { children: string }) {
return (
<DropdownMenuItem selected={selected} description={description} onSelect={onSelect}>
{label}
</DropdownMenuItem>
<View style={styles.filterSectionHeading}>
<Text style={styles.filterSectionHeadingText}>{children}</Text>
</View>
);
}
@@ -209,12 +243,9 @@ function WorkspaceFilterItem({
}, [onFilterChange, workspaceKey]);
return (
<FilterItem
selected={selected}
label={label}
description={description}
onSelect={handleSelect}
/>
<DropdownMenuItem selected={selected} description={description} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
@@ -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 <FilterItem selected={selected} label={label} onSelect={handleSelect} />;
const leading = useMemo(
() => <ProjectIconLeading serverId={serverId} cwd={iconWorkingDir} />,
[serverId, iconWorkingDir],
);
return (
<DropdownMenuItem selected={selected} leading={leading} onSelect={handleSelect}>
{label}
</DropdownMenuItem>
);
}
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 (
<View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
{imageSource ? (
<Image source={imageSource} style={styles.projectIconImage} />
) : (
<View style={styles.projectIconFallback}>
<Folder size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
)}
</View>
);
}
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",
},
}));
@@ -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");
});
});
@@ -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<string, string> = {};
const fieldsById: Record<string, { name: string; workspaceDirectory: string | undefined }> =
{};
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],
);
}
+41 -49
View File
@@ -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 (
<Pressable
accessibilityRole="button"
onPress={handlePress}
style={pressableStyle}
testID={`sidebar-session-row-${serverId}-${id}`}
>
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.rowContent}>
<View style={styles.titleRow}>
<Text style={styles.title} numberOfLines={1}>
{agent.title || "New session"}
</Text>
<Text style={styles.timeAgo}>{formatTimeAgo(agent.lastActivityAt)}</Text>
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable
accessibilityRole="button"
accessibilityLabel={agent.title || "New session"}
onPress={handlePress}
style={pressableStyle}
testID={`sidebar-session-row-${serverId}-${id}`}
>
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={styles.path} numberOfLines={1}>
{shortenPath(agent.cwd)}
<Text style={titleStyle} numberOfLines={1}>
{agent.title || "New session"}
</Text>
</View>
</Pressable>
<Text style={styles.timeAgo} numberOfLines={1}>
{formatTimeAgo(agent.lastActivityAt)}
</Text>
</Pressable>
</View>
);
});
@@ -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: {