Files
paseo/packages/app/src/components/left-sidebar.tsx
T
Mohamed Boudra 792715e762 feat(usage): usage sources as built-in plugins, following the session's account (#5465)
* feat(server): run internal plugins in-process through the plugin runtime

* fix(server): harden internal plugin startup and boundary checks

* feat(usage): add plugin usage sources and Codex discovery

* fix(usage): share SDK helpers and bound source cache

* feat(usage): migrate usage fetchers to plugin sources

Serve the legacy usage RPC from discovered source reports so older apps retain the same host usage list while new clients use the source registry. Keep credential reads and normalization inside each source.

* refactor(usage): shape sources as fetch functions

Keep the plugin usage API limited to stable report and presentation helpers. Source-local parsers and timeout handling preserve credential and response parity while the cases move alongside each source.

* feat(usage): resolve reports from active session accounts

Resolve source inputs at fetch time so model and credential changes select the account each session spends. Keep the legacy discovered-account RPC for existing clients. Sync OpenCode model events outside foreground turns so usage follows model switches.

* fix usage source discovery and session report parity

* test adapter usage references through daemon RPC

* name usage reference contract across server

* refactor(usage): own usage RPCs and validate session auth

* feat(omp): resolve session usage from active OAuth credential

* fix(omp): match usage source input and valid credential fallback

* test(omp): use upstream credential database schema

* test(omp): verify live session usage references

* fix(usage): declare YAML parser and consolidate credential validation

* feat(app): show usage sources in the composer and a Usage screen

Replace the provider-matched usage in the context ring tooltip with a
composer pill driven by agent.get_usage_report, add a sidebar Usage
screen over usage.list_reports grouped by host, and move the host
settings Usage page to usage.list_reports. Hosts without
features.usageSources show an update message. Source icons come from
the report's catalog SVG. The app no longer calls provider.usage.list.

* fix(usage): declare the Paseo requirement on internal usage-source plugins

A manifest without requirements.paseo targets Paseo before 0.8, so the daemon
refused to start eight of the nine usage sources.

* fix(app): keep host connection statuses live under the React Compiler

The hook read a store version only as a memo trigger (void version); the
compiler dropped it, so pages that mounted before a host came online kept
showing it as connecting.

* fix(app): place the context ring slot before the usage pill

The ring reserves its slot before it has data. With the pill first, the empty
slot sat between the pill and the mic; now it falls in the flexible space.

* refactor(app): extract the settings page layout into PageLayout

Desktop puts the title in the page above a centered column; compact moves it
into a back header. Settings renders the same pixels through it.

* feat(app): lay out the Usage screen like a settings page

The title moves from the header bar into the page on desktop; compact gets the
settings back header, which returns home when the screen was opened by URL.

* refactor(app): drop the unused actions slot from PageLayout

* test(plugin): classify the usage source SDK entry

* fix(usage): preserve platform test contracts

* fix(settings): preserve page title test contract

* feat(usage): identify and cache reports by account ID

Keep report identity stable across input routes and token rotation, and make selective refresh operate on known account reports.

* feat(app): read usage reports through report IDs

Share report data between agent pills and host views while preserving the existing popover interaction for the next UI phase.

* fix(usage): keep account identity aligned with credentials

Read labels from local token claims and use one credential lookup for usage fetches and account identification. Bound cached profile identities across token rotation.

* fix(usage): isolate failure IDs and prune expired reports

Failure reports live outside the account-key namespace, and cache writes discard entries past the five-minute TTL.

* refactor(app): share the compact time-ago prose formatter

Move the tab tooltip's "3m" -> "3m ago" wording into utils/time so the
usage card can reuse it.

* feat(usage): show when each report was fetched

The usage card reads fetchedAt as "Updated 3m ago": a tooltip on the
card header where hover exists (web, desktop), printed in the card
footer on native and compact. Shared by the pill popover and the Usage
screen.

* feat(usage): open the pill popover from cache and refresh on demand

Opening the popover no longer forces a vendor fetch; it shows the cached
report. A Refresh control force-refreshes only this report
(usage.list_reports with its single ID and forceRefresh), shows
Refreshing... while it runs, keeps the previous report visible, and says
"Unable to refresh usage" if the request fails.

* feat(app): let ToolbarButton show a tooltip other than its label

An icon action can now say a status in its tooltip ("Updated 3m ago")
while keeping its label for accessibility.

* feat(usage): refresh a report from its card header

Refresh is an icon button at the right edge of every usage card header,
in the pill popover and on the Usage screen. It force-refreshes only that
report, writes the result to the report's cache and its host list, shows
a spinner and disables itself while pending, and prints "Unable to
refresh usage" in the card on failure. Its tooltip carries the
freshness; without hover the freshness stays in the card footer. The
status indicator sits just left of the button, and the popover's
separate refresh row is gone.

* fix(server): include internal plugin assets in built output

* feat(server): built-in plugins in /plugins (#5544)

* feat(server): load compiled built-in Codex plugin

* test(server): guard built-in plugin loading and installs

* test(server): verify built-in plugins from dist in CI

* refactor(server): migrate remaining usage sources to built-in plugins

* test(server): guard all packaged usage source registrations

* docs: describe usage validation across plugin hosts

* test(ci): include built-in plugins in routing contract

* test(server): import moved Codex usage source

* fix(server): retain ACP updates received with session creation
2026-09-28 16:36:34 +01:00

1013 lines
30 KiB
TypeScript

import { router } from "expo-router";
import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
Pressable,
StyleSheet as RNStyleSheet,
Text,
useWindowDimensions,
View,
type PressableStateCallbackType,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
import { useHosts } from "@/runtime/host-runtime";
import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import {
buildSettingsAddHostRoute,
buildSettingsRoute,
buildUsageRoute,
} from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
interface SidebarSharedProps {
theme: SidebarTheme;
workspaceGroups: SidebarWorkspaceGroup[];
projectIconTargets: SidebarProjectIconTarget[];
pinnedGroups: PinnedSidebarGroups;
projects: SidebarProjectEntry[];
hasProjectsBeforeFilter: boolean;
hasActiveProjectFilter: boolean;
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
groupMode: SidebarGroupMode;
collapsedProjectKeys: ReadonlySet<string>;
shortcutIndexByWorkspaceKey: Map<string, number>;
toggleProjectCollapsed: (projectViewKey: string) => void;
handleRefresh: () => void;
handleOpenProject: () => void;
handleImportSession: () => void;
handleSettings: () => void;
labels: SidebarLabels;
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}
interface SidebarLabels {
addProject: string;
hosts: string;
importSession: string;
settings: string;
searchHosts: string;
closeSidebar: string;
}
interface MobileSidebarProps extends SidebarSharedProps {
active: boolean;
insetsTop: number;
insetsBottom: number;
closeSidebar: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
active: boolean;
}
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isCompactLayout = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const {
projects,
hasProjectsBeforeFilter,
resolvedProjectFilters,
workspaceEntriesByKey,
isInitialLoad,
isRevalidating,
refreshAll,
workspaceGroups,
projectIconTargets,
pinnedGroups,
collapsedProjectKeys,
toggleProjectCollapsed,
groupMode,
shortcutModel,
} = useSidebarModel();
const { shortcutIndexByWorkspaceKey } = shortcutModel;
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
setIsManualRefresh(true);
refreshAll();
}, [refreshAll]);
useEffect(() => {
if (!isRevalidating && isManualRefresh) {
setIsManualRefresh(false);
}
}, [isRevalidating, isManualRefresh]);
const openProjectPicker = useOpenAddProject();
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
const handleOpenProjectMobile = useCallback(() => {
showMobileAgent();
void openProjectPicker();
}, [showMobileAgent, openProjectPicker]);
const handleOpenProjectDesktop = useCallback(() => {
void openProjectPicker();
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsRoute());
}, [showMobileAgent]);
const handleSettingsDesktop = useCallback(() => {
router.push(buildSettingsRoute());
}, []);
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
showMobileAgent();
openHostOverview(serverId);
},
[showMobileAgent],
);
const handleOpenHostSettingsDesktop = useCallback((serverId: string) => {
openHostOverview(serverId);
}, []);
const handleImportSessionMobile = useCallback(() => {
showMobileAgent();
openImportSession();
}, [openImportSession, showMobileAgent]);
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
hosts: t("sidebar.actions.hosts"),
importSession: t("importSession.title"),
settings: t("sidebar.actions.settings"),
searchHosts: t("sidebar.host.searchPlaceholder"),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
);
const sharedProps = {
theme,
workspaceGroups,
projectIconTargets,
pinnedGroups,
projects,
hasProjectsBeforeFilter,
hasActiveProjectFilter: resolvedProjectFilters.length > 0,
workspaceEntriesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
labels,
};
if (isCompactLayout) {
return (
<>
<RetainedPanelActivity active={active}>
<MobileSidebar
{...sharedProps}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
handleOpenProject={handleOpenProjectMobile}
handleImportSession={handleImportSessionMobile}
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
/>
</RetainedPanelActivity>
{importSessionSheet}
</>
);
}
return (
<>
<RetainedPanelActivity active={active}>
<DesktopSidebar
{...sharedProps}
insetsTop={insets.top}
active={active}
handleOpenProject={handleOpenProjectDesktop}
handleImportSession={openImportSession}
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
/>
</RetainedPanelActivity>
{importSessionSheet}
</>
);
});
function sidebarHostOptionTestID(serverId: string): string {
return `sidebar-host-row-${serverId}`;
}
function FooterIconButton({
buttonRef,
onPress,
testID,
label,
icon: Icon,
iconSize,
shortcutKeys,
theme,
}: {
onPress: () => void;
testID: string;
label: string;
icon: typeof FolderPlus;
iconSize?: number;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
collapsable={false}
accessible
accessibilityLabel={label}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => (
<Icon
size={iconSize ?? theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
);
}
function footerAddProjectButtonStyle({
hovered,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered];
}
function FooterAddProjectButton({
onPress,
label,
shortcutKeys,
theme,
}: {
onPress: () => void;
label: string;
shortcutKeys: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
}) {
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={footerAddProjectButtonStyle}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
accessible
accessibilityLabel={label}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => {
const isHovered = Boolean(hovered);
return (
<>
<FolderPlus
size={theme.iconSize.sm}
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
numberOfLines={1}
style={[
styles.footerAddProjectLabel,
isHovered && styles.footerAddProjectLabelHovered,
]}
>
{label}
</Text>
</>
);
}}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
</TooltipContent>
</Tooltip>
);
}
function SidebarHostPicker({
theme,
label,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
label: string;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}) {
const hosts = useHosts();
const triggerRef = useRef<View | null>(null);
const [isOpen, setIsOpen] = useState(false);
const handleSelect = useCallback(
(id: string) => {
onOpenHostSettings(id);
},
[onOpenHostSettings],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
return (
<HostPicker
hosts={hosts}
value=""
onSelect={handleSelect}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
includeAddHost
onAddHost={onAddHost}
showActiveConnection
onOpenHostSettings={onOpenHostSettings}
searchable
desktopPlacement="top-start"
desktopMinWidth={240}
addHostTestID="sidebar-host-add"
hostOptionTestID={sidebarHostOptionTestID}
>
<FooterIconButton
buttonRef={triggerRef}
onPress={handleOpen}
testID="sidebar-hosts-trigger"
label={label}
icon={Server}
iconSize={theme.iconSize.sm}
theme={theme}
/>
</HostPicker>
);
}
function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
const { t } = useTranslation();
const isCompactLayout = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const handlePress = useCallback(() => {
if (isCompactLayout) showMobileAgent();
router.push(buildUsageRoute());
}, [isCompactLayout, showMobileAgent]);
return (
<FooterIconButton
onPress={handlePress}
testID="sidebar-usage"
label={t("settings.hostSections.usage")}
icon={Gauge}
theme={theme}
/>
);
}
function IconTooltipContent({
label,
shortcutKeys,
}: {
label: string;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
);
}
function SidebarFooter({
theme,
handleOpenProject,
handleImportSession,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
handleImportSession: () => void;
handleSettings: () => void;
labels: {
addProject: string;
hosts: string;
importSession: string;
settings: string;
searchHosts: string;
};
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
return (
<View style={styles.sidebarFooter}>
<FooterAddProjectButton
onPress={handleOpenProject}
label={labels.addProject}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<View style={styles.footerIconRow}>
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<FooterIconButton
onPress={handleImportSession}
testID="sidebar-import-session"
label={labels.importSession}
icon={Import}
theme={theme}
/>
<SidebarUsageButton theme={theme} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
);
}
function MobileSidebar({
active,
theme,
workspaceGroups,
projectIconTargets,
pinnedGroups,
projects,
hasProjectsBeforeFilter,
hasActiveProjectFilter,
workspaceEntriesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleOpenProject,
handleImportSession,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
insetsBottom,
closeSidebar,
}: MobileSidebarProps) {
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const handleWorkspacePress = useCallback(() => {
closeSidebar();
}, [closeSidebar]);
const mobileSidebarInsetStyle = useMemo(
() => ({
paddingTop: insetsTop,
paddingBottom: insetsBottom,
backgroundColor: theme.colors.surfaceSidebar,
}),
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
);
return (
<MobilePanelOverlay
panel="agent-list"
closeGesture={closeGesture}
panelStyle={mobileSidebarInsetStyle}
>
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable
style={styles.mobileCloseButton}
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
accessible
accessibilityRole="button"
accessibilityLabel={labels.closeSidebar}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={theme.iconSize.md}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</WindowChromeSafeArea>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onWorkspacePress={handleWorkspacePress}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleImportSession={handleImportSession}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
</View>
</MobilePanelOverlay>
);
}
function DesktopSidebar({
theme,
workspaceGroups,
projectIconTargets,
pinnedGroups,
projects,
hasProjectsBeforeFilter,
hasActiveProjectFilter,
workspaceEntriesByKey,
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
handleOpenProject,
handleImportSession,
handleSettings,
labels,
handleAddHost,
handleOpenHostSettings,
insetsTop,
active,
}: DesktopSidebarProps) {
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
});
const startWidthRef = useRef(visibleSidebarWidth);
const resizeWidth = useSharedValue(visibleSidebarWidth);
const [resizePressed, setResizePressed] = useState(false);
const showResizeGrip = useCallback(() => setResizePressed(true), []);
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
useEffect(() => {
resizeWidth.value = visibleSidebarWidth;
}, [resizeWidth, visibleSidebarWidth]);
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => {
scheduleOnRN(showResizeGrip);
})
// Horizontal intent only, so a finger dragging down the touch grip scrolls
// the workspace list instead of resizing. Anchoring the start width to the
// activation translation keeps the extra threshold from jumping the edge.
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
.onStart((event) => {
startWidthRef.current = visibleSidebarWidth - event.translationX;
resizeWidth.value = visibleSidebarWidth;
})
.onUpdate((event) => {
// Dragging right (positive translationX) increases width
const newWidth = startWidthRef.current + event.translationX;
resizeWidth.value = resolveDesktopSidebarWidth({
requestedWidth: newWidth,
viewportWidth,
});
})
.onEnd(() => {
runOnJS(setSidebarWidth)(resizeWidth.value);
})
.onFinalize(() => {
scheduleOnRN(hideResizeGrip);
}),
[
hideResizeGrip,
resizeWidth,
setSidebarWidth,
showResizeGrip,
viewportWidth,
visibleSidebarWidth,
],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const desktopSidebarStyle = useMemo(
() => [
staticStyles.desktopSidebar,
!active && staticStyles.desktopSidebarHidden,
resizeAnimatedStyle,
],
[active, resizeAnimatedStyle],
);
const desktopSidebarBorderStyle = useMemo(
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
[insetsTop],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
[ownsTopLeft],
);
return (
<Animated.View
accessibilityElementsHidden={!active}
importantForAccessibility={active ? "auto" : "no-hide-descendants"}
pointerEvents={active ? "auto" : "none"}
style={desktopSidebarStyle}
>
<View style={desktopSidebarBorderStyle}>
<View style={styles.sidebarDragArea}>
{ownsTopLeft || DEV_BUILD_LABEL ? (
<View style={styles.desktopChromeRow}>
<TitlebarDragRegion />
{DEV_BUILD_LABEL ? (
<View
pointerEvents="none"
style={styles.devBuildBadge}
testID="dev-build-label"
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
>
<GitBranch size={12} color={theme.colors.accentForeground} />
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
{DEV_BUILD_LABEL}
</Text>
</View>
) : null}
</View>
) : (
<TitlebarDragRegion />
)}
<SidebarNavRows style={sidebarHeaderGroupStyle} />
</View>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
<SidebarWorkspaceList
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
workspaceGroups={workspaceGroups}
projectIconTargets={projectIconTargets}
pinnedGroups={pinnedGroups}
projects={projects}
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
hasActiveProjectFilter={hasActiveProjectFilter}
workspaceEntriesByKey={workspaceEntriesByKey}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
<SidebarCalloutSlot />
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleImportSession={handleImportSession}
handleSettings={handleSettings}
labels={labels}
handleAddHost={handleAddHost}
handleOpenHostSettings={handleOpenHostSettings}
/>
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
</View>
</Animated.View>
);
}
function WorkspacesSectionHeader() {
return (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
);
}
// Stable element so the sidebar list's listHeaderComponent prop keeps identity across
// renders (WorkspacesSectionHeader takes no props).
const workspacesSectionHeaderElement = <WorkspacesSectionHeader />;
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
const staticStyles = RNStyleSheet.create({
desktopSidebar: {
position: "relative" as const,
},
desktopSidebarHidden: {
display: "none",
},
});
const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroup: {
paddingTop: theme.spacing[2],
gap: 2,
paddingBottom: theme.spacing[1.5],
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
workspacesSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
paddingLeft: theme.spacing[2],
paddingRight: 4,
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[1],
},
workspacesSectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
workspacesSectionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
sidebarContent: {
flex: 1,
minHeight: 0,
},
mobileCloseButtonRow: {
position: "absolute",
top: theme.spacing[3],
left: 0,
right: 0,
zIndex: 2,
alignItems: "flex-end",
pointerEvents: "box-none",
},
mobileCloseButton: {
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
// This optical inset puts their painted right edges on the same sidebar rail.
marginRight: theme.spacing[2] + 1.5,
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surfaceSidebar,
},
desktopSidebarBorder: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
sidebarDragArea: {
position: "relative",
},
desktopChromeRow: {
position: "relative",
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: "transparent",
},
devBuildBadge: {
maxWidth: "60%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
},
devBuildBadgeText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.accentForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[3],
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
footerIconRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexShrink: 0,
},
footerAddProjectButton: {
minWidth: 0,
minHeight: 32,
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1.5],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
footerAddProjectButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
footerAddProjectLabel: {
minWidth: 0,
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
footerAddProjectLabelHovered: {
color: theme.colors.foreground,
},
footerIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
}));