sidebar history: post-merge fixes (header, kebab, +) and deeper abstractions

Three behavior fixes from user feedback after the parity pass landed.

R1 — Non-git project headers were missing in grouped Sessions while their
child rows still rendered. Sessions had inherited the workspace tree's
isSidebarProjectFlattened branch, but only half of it: the header was
suppressed and the children continued. Sessions doesn't share the
workspace tree's flatten semantics — workspaces flatten because
"1 workspace = the project IS the workspace, no children to expand";
in Sessions, even a single-workspace non-git project can still have
multiple agents worth grouping. Drop the flatten branch from Sessions
entirely. SidebarSessionGroup loses isFlattened. Workspace tree's
FlattenedProjectRow stays untouched.

R2 — The agent kebab had Close + Archive. Sessions agent rows aren't
tabs; there's no open tab to close. Drop Close and the orphan chain
that came with it (workspace-tab menu imports, session store reach,
confirm dialog wiring, resolveSidebarSessionWorkspaceId, etc.). The
menu is now Archive-only; useArchiveAgent is the only handler.

R3 — Each project header in grouped Sessions now renders a "+" that
opens the new-workspace flow scoped to that project's directory. The
shared SidebarProjectTrailingActions is the home — Sessions wires its
own createButton config (Plus icon, "New session", route via
buildHostNewWorkspaceRoute(serverId, sourceDirectory, headerTitle)).

R4 — Meta-audit on the shared sidebar/* abstractions found two depth
issues that let R1 and R3 silently slip through:

1. SidebarProjectHeaderRow was a chrome primitive. Consumers could
   render children without rendering a header (R1 bug). Added
   SidebarProjectSection — an opinionated wrapper that owns
   "header + children/footer when expanded" so the silent failure
   mode no longer compiles. Both consumers refactored.

2. SidebarProjectTrailingActions had showNewWorktreeButton: boolean
   defaulting false. Sessions silently lost the affordance with no
   warning (R3 bug). Replaced the boolean with a required createButton
   prop (literal null to opt out). Each consumer must say what it
   wants — no default suppression.

Surfaced for future iterations (not fixed here): hand-wired hover
state in both consumers, chevron native visibility, missing
project/agent selection state in Sessions, zero-agent-project filter
divergence.

Tests cover the new wrapper, the trailing-actions API change, the
header-always-renders behavior, and the per-project + button
navigation. Workspace tree behavior unchanged.
This commit is contained in:
Mohamed Boudra
2026-05-08 23:04:27 +07:00
parent d00bd666a7
commit bcd32962f7
12 changed files with 534 additions and 414 deletions

View File

@@ -18,10 +18,11 @@ import {
useRef,
type ComponentType,
type ReactElement,
type ReactNode,
type MutableRefObject,
type Ref,
} from "react";
import { usePathname } from "expo-router";
import { router, usePathname, type Href } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
@@ -47,7 +48,10 @@ import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import {
buildHostNewWorkspaceRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
@@ -106,8 +110,11 @@ import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
import { isNative as platformIsNative } from "@/constants/platform";
import { SidebarProjectIcon } from "@/components/sidebar/sidebar-project-row-visual";
import { SidebarProjectHeaderRow } from "@/components/sidebar/sidebar-collapsible-project-section";
import { SidebarProjectTrailingActions } from "@/components/sidebar/sidebar-project-trailing-actions";
import { SidebarProjectSection } from "@/components/sidebar/sidebar-collapsible-project-section";
import {
SidebarProjectTrailingActions,
type SidebarProjectCreateButtonConfig,
} from "@/components/sidebar/sidebar-project-trailing-actions";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -230,6 +237,8 @@ interface ProjectHeaderRowProps {
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
dragHandleProps?: DraggableListDragHandleProps;
isCollapsed?: boolean;
children?: ReactNode;
}
interface WorkspaceRowInnerProps {
@@ -708,6 +717,8 @@ function ProjectHeaderRow({
onRemoveProject,
removeProjectStatus = "idle",
dragHandleProps,
isCollapsed = true,
children,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
@@ -728,6 +739,36 @@ function ProjectHeaderRow({
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const handleBeginWorkspaceSetup = useCallback(() => {
if (!serverId || !project.iconWorkingDir) {
return;
}
router.navigate(
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
displayName,
}) as Href,
);
onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const createButton = useMemo<SidebarProjectCreateButtonConfig | null>(() => {
if (!canCreateWorktree) {
return null;
}
return {
onPress: handleBeginWorkspaceSetup,
accessibilityLabel: `Create a new workspace for ${displayName}`,
testID: `sidebar-project-new-worktree-${project.projectKey}`,
tooltipLabel: "New workspace",
icon: "folder-plus",
showShortcutHint: isProjectActive,
};
}, [
canCreateWorktree,
displayName,
handleBeginWorkspaceSetup,
isProjectActive,
project.projectKey,
]);
const showsWorkspaceStatus =
workspace !== null && (isArchiving || workspace.statusBucket !== "done");
@@ -755,13 +796,10 @@ function ProjectHeaderRow({
projectName={displayName}
serverId={serverId}
isHovered={isHovered}
showNewWorktreeButton={canCreateWorktree}
sourceDirectory={project.iconWorkingDir}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onRemoveProject={onRemoveProject}
removeProjectStatus={removeProjectStatus}
workspaces={project.workspaces}
createButton={createButton}
/>
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
@@ -774,6 +812,22 @@ function ProjectHeaderRow({
const PressableComponent = menuController
? (ProjectHeaderContextMenuTrigger as unknown as ComponentType<PressableProps>)
: undefined;
const headerProps = useMemo(
() => ({
leadingVisualOverride,
onPressIn: interaction.handlePressIn,
onTouchMove: interaction.handleTouchMove,
onPressOut: interaction.handlePressOut,
PressableComponent,
}),
[
interaction.handlePressIn,
interaction.handlePressOut,
interaction.handleTouchMove,
leadingVisualOverride,
PressableComponent,
],
);
return (
<View
@@ -783,22 +837,22 @@ function ProjectHeaderRow({
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<SidebarProjectHeaderRow
<SidebarProjectSection
projectKey={project.projectKey}
projectName={displayName}
iconDataUri={iconDataUri}
chevron={chevron}
isHovered={isHovered}
isDragging={isDragging}
selected={selected}
leadingVisualOverride={leadingVisualOverride}
trailingSlot={trailingSlot}
onPress={handlePress}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
testID={`sidebar-project-row-${project.projectKey}`}
PressableComponent={PressableComponent}
/>
isCollapsed={isCollapsed}
headerProps={headerProps}
>
{children}
</SidebarProjectSection>
</View>
);
}
@@ -1707,44 +1761,41 @@ function ProjectBlock({
selectionEnabled={selectionEnabled}
/>
) : (
<>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={null}
selected={false}
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps}
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={null}
selected={false}
chevron={rowModel.chevron}
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps}
isCollapsed={collapsed}
>
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
{!collapsed ? (
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.workspaceListContainer}
/>
) : null}
</>
</ProjectHeaderRow>
)}
</View>
);

View File

@@ -0,0 +1,90 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { Text } from "react-native";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SidebarProjectSection } from "./sidebar-collapsible-project-section";
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) =>
typeof styles === "function"
? styles({
borderRadius: { lg: 8 },
colors: {
border: "#dddddd",
surface2: "#eeeeee",
surfaceSidebarHover: "#f5f5f5",
},
iconSize: { md: 16 },
shadow: { md: {} },
spacing: { 1: 4, 2: 8, 3: 12 },
})
: styles,
},
}));
vi.mock("@/components/sidebar/sidebar-project-row-visual", () => ({
SidebarProjectIcon: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
SidebarProjectRowVisual: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
}));
afterEach(cleanup);
const collapsedFooter = <Text>Footer row</Text>;
describe("SidebarProjectSection", () => {
it("renders the header and expanded children", () => {
const { getByTestId, getByText } = render(
<SidebarProjectSection
projectKey="project-a"
projectName="Project A"
iconDataUri={null}
onPress={vi.fn()}
isHovered={false}
chevron="collapse"
isCollapsed={false}
testID="project-a-header"
>
<Text>Child row</Text>
</SidebarProjectSection>,
);
expect(getByTestId("project-a-header")).toBeTruthy();
expect(getByText("Project A")).toBeTruthy();
expect(getByText("Child row")).toBeTruthy();
});
it("keeps the header visible and hides children plus footer when collapsed", () => {
const { getByTestId, queryByText } = render(
<SidebarProjectSection
projectKey="project-a"
projectName="Project A"
iconDataUri={null}
onPress={vi.fn()}
isHovered={false}
chevron="expand"
isCollapsed
footer={collapsedFooter}
testID="project-a-header"
>
<Text>Child row</Text>
</SidebarProjectSection>,
);
expect(getByTestId("project-a-header")).toBeTruthy();
expect(queryByText("Child row")).toBeNull();
expect(queryByText("Footer row")).toBeNull();
});
});

View File

@@ -55,6 +55,70 @@ export interface SidebarProjectHeaderRowProps {
PressableComponent?: ComponentType<PressableProps & { ref?: Ref<View> }>;
}
export interface SidebarProjectSectionProps {
projectKey: string;
projectName: string;
iconDataUri: string | null;
onPress: () => void;
isHovered: boolean;
chevron: "expand" | "collapse" | null;
selected?: boolean;
isDragging?: boolean;
trailingSlot?: ReactNode;
testID?: string;
children?: ReactNode;
footer?: ReactNode;
isCollapsed: boolean;
headerProps?: Pick<
SidebarProjectHeaderRowProps,
| "accessibilityLabel"
| "leadingVisualOverride"
| "onPressIn"
| "onTouchMove"
| "onPressOut"
| "PressableComponent"
>;
}
export const SidebarProjectSection = memo(function SidebarProjectSection({
projectName,
iconDataUri,
onPress,
isHovered,
chevron,
selected = false,
isDragging = false,
trailingSlot = null,
testID,
children = null,
footer = null,
isCollapsed,
headerProps,
}: SidebarProjectSectionProps): ReactElement {
return (
<>
<SidebarProjectHeaderRow
projectName={projectName}
iconDataUri={iconDataUri}
chevron={chevron}
isHovered={isHovered}
isDragging={isDragging}
selected={selected}
trailingSlot={trailingSlot}
onPress={onPress}
testID={testID}
{...headerProps}
/>
{!isCollapsed ? (
<>
{children}
{footer}
</>
) : null}
</>
);
});
/**
* Shared chrome for a project row that names a section: pressable, hover/press/selected
* styles, on-hover chevron leading visual, and a trailing actions slot.

View File

@@ -1,19 +1,26 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, render } from "@testing-library/react";
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Pressable, View } from "react-native";
import { SidebarProjectTrailingActions } from "./sidebar-project-trailing-actions";
import {
SidebarProjectTrailingActions,
type SidebarProjectCreateButtonConfig,
} from "./sidebar-project-trailing-actions";
vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
const { navigateMock } = vi.hoisted(() => ({
navigateMock: vi.fn(),
}));
vi.mock("expo-router", () => ({
router: {
navigate: vi.fn(),
navigate: navigateMock,
},
}));
@@ -23,6 +30,7 @@ vi.mock("lucide-react-native", () => {
return {
FolderPlus: createIcon("FolderPlus"),
MoreVertical: createIcon("MoreVertical"),
Plus: createIcon("Plus"),
Settings: createIcon("Settings"),
Trash2: createIcon("Trash2"),
};
@@ -98,18 +106,26 @@ vi.mock("@/hooks/use-shortcut-keys", () => ({
afterEach(() => {
cleanup();
navigateMock.mockClear();
});
const newWorkspaceCreateButton: SidebarProjectCreateButtonConfig = {
onPress: () => {},
accessibilityLabel: "New workspace",
testID: "sidebar-project-new-worktree-project-a",
tooltipLabel: "New workspace",
icon: "folder-plus",
};
describe("SidebarProjectTrailingActions", () => {
it("renders the kebab and new-worktree button when requested", () => {
it("renders the kebab and create button when configured", () => {
const { getByTestId } = render(
<SidebarProjectTrailingActions
projectKey="project-a"
projectName="Project A"
serverId="server-1"
isHovered
showNewWorktreeButton
sourceDirectory="/repo/project-a"
createButton={newWorkspaceCreateButton}
onRemoveProject={vi.fn()}
/>,
);
@@ -118,13 +134,14 @@ describe("SidebarProjectTrailingActions", () => {
expect(getByTestId("sidebar-project-new-worktree-project-a")).toBeTruthy();
});
it("renders only the kebab when the new-worktree button is omitted", () => {
it("renders only the kebab when createButton is null", () => {
const { getByTestId, queryByTestId } = render(
<SidebarProjectTrailingActions
projectKey="project-a"
projectName="Project A"
serverId="server-1"
isHovered
createButton={null}
onRemoveProject={vi.fn()}
/>,
);
@@ -132,4 +149,39 @@ describe("SidebarProjectTrailingActions", () => {
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
});
it("uses the create button override instead of the default route", () => {
const createOnPress = vi.fn();
function Harness() {
const createButton = React.useMemo(
() => ({
onPress: createOnPress,
accessibilityLabel: "New session",
testID: "sidebar-project-project-a-new-session-button",
tooltipLabel: "New session",
icon: "plus" as const,
}),
[],
);
return (
<SidebarProjectTrailingActions
projectKey="project-a"
projectName="Project A"
serverId="server-1"
isHovered
createButton={createButton}
onRemoveProject={vi.fn()}
/>
);
}
const { getByTestId } = render(<Harness />);
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
expect(createOnPress).toHaveBeenCalledOnce();
expect(navigateMock).not.toHaveBeenCalled();
});
});

View File

@@ -7,8 +7,8 @@ import {
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { router, type Href } from "expo-router";
import { FolderPlus, MoreVertical, Settings, Trash2 } from "lucide-react-native";
import { router } from "expo-router";
import { FolderPlus, MoreVertical, Plus, Settings, Trash2 } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
@@ -31,26 +31,33 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { buildHostNewWorkspaceRoute, buildProjectSettingsRoute } from "@/utils/host-routes";
import { buildProjectSettingsRoute } from "@/utils/host-routes";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
export interface SidebarProjectCreateButtonConfig {
onPress: () => void;
accessibilityLabel: string;
testID: string;
tooltipLabel: string;
icon: "folder-plus" | "plus";
showShortcutHint?: boolean;
}
export interface SidebarProjectTrailingActionsProps {
projectKey: string;
serverId: string | null;
isHovered: boolean;
showNewWorktreeButton?: boolean;
projectName?: string;
sourceDirectory?: string;
isProjectActive?: boolean;
onWorkspacePress?: () => void;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending" | "success";
workspaces?: readonly SidebarWorkspaceEntry[];
createButton: SidebarProjectCreateButtonConfig | null;
}
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedPlus = withUnistyles(Plus);
const ThemedSettings = withUnistyles(Settings);
const ThemedTrash2 = withUnistyles(Trash2);
@@ -66,14 +73,11 @@ export function SidebarProjectTrailingActions({
projectKey,
serverId,
isHovered,
showNewWorktreeButton = false,
projectName,
sourceDirectory,
isProjectActive = false,
onWorkspacePress,
onRemoveProject,
removeProjectStatus,
workspaces = [],
createButton,
}: SidebarProjectTrailingActionsProps): ReactElement {
const isMobileBreakpoint = useIsCompactFormFactor();
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
@@ -86,26 +90,10 @@ export function SidebarProjectTrailingActions({
const resolvedRemoveProject = onRemoveProject ?? fallbackRemoval.onRemoveProject;
const resolvedRemoveProjectStatus = removeProjectStatus ?? fallbackRemoval.removeProjectStatus;
const handleBeginWorkspaceSetup = useCallback(() => {
if (!serverId || !sourceDirectory) {
return;
}
router.navigate(
buildHostNewWorkspaceRoute(serverId, sourceDirectory, { displayName: projectName }) as Href,
);
onWorkspacePress?.();
}, [onWorkspacePress, projectName, serverId, sourceDirectory]);
return (
<View style={styles.projectTrailingActions}>
{showNewWorktreeButton && serverId && sourceDirectory ? (
<NewWorktreeButton
displayName={projectName ?? projectKey}
onPress={handleBeginWorkspaceSetup}
visible={actionsVisible}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${projectKey}`}
/>
{createButton ? (
<ProjectCreateButton actionsVisible={actionsVisible} createButton={createButton} />
) : null}
<View
style={!actionsVisible && styles.projectKebabButtonHidden}
@@ -121,6 +109,26 @@ export function SidebarProjectTrailingActions({
);
}
function ProjectCreateButton({
actionsVisible,
createButton,
}: {
actionsVisible: boolean;
createButton: SidebarProjectCreateButtonConfig;
}): ReactElement {
return (
<NewWorktreeButton
accessibilityLabel={createButton.accessibilityLabel}
icon={createButton.icon}
onPress={createButton.onPress}
visible={actionsVisible}
showShortcutHint={createButton.showShortcutHint ?? false}
testID={createButton.testID}
tooltipLabel={createButton.tooltipLabel}
/>
);
}
function useSidebarProjectRemoval({
projectKey,
projectName,
@@ -299,19 +307,23 @@ function ProjectKebabMenu({
}
function NewWorktreeButton({
displayName,
accessibilityLabel,
icon,
onPress,
visible,
loading = false,
testID,
showShortcutHint = false,
tooltipLabel,
}: {
displayName: string;
accessibilityLabel: string;
icon: "folder-plus" | "plus";
onPress: () => void;
visible: boolean;
loading?: boolean;
testID: string;
showShortcutHint?: boolean;
tooltipLabel: string;
}) {
const newWorktreeKeys = useShortcutKeys("new-worktree");
@@ -332,6 +344,20 @@ function NewWorktreeButton({
[onPress],
);
const renderIcon = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => {
if (loading) {
return <ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />;
}
const uniProps = hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "plus") {
return <ThemedPlus size={15} uniProps={uniProps} />;
}
return <ThemedFolderPlus size={15} uniProps={uniProps} />;
},
[icon, loading],
);
return (
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
@@ -341,26 +367,15 @@ function NewWorktreeButton({
onPress={handlePress}
disabled={loading}
accessibilityRole="button"
accessibilityLabel={`Create a new workspace for ${displayName}`}
accessibilityLabel={accessibilityLabel}
testID={testID}
>
{({ hovered, pressed }) =>
loading ? (
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedFolderPlus
size={15}
uniProps={
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
}
/>
)
}
{renderIcon}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<View style={styles.projectActionTooltipRow}>
<Text style={styles.projectActionTooltipText}>New workspace</Text>
<Text style={styles.projectActionTooltipText}>{tooltipLabel}</Text>
{showShortcutHint && newWorktreeKeys ? (
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
) : null}

View File

@@ -1,10 +1,14 @@
import React, { memo, useCallback, useMemo, useState, type ReactElement, type Ref } from "react";
import { Pressable, Text, View } from "react-native";
import { router, type Href } from "expo-router";
import { StyleSheet } from "react-native-unistyles";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { SidebarProjectHeaderRow } from "@/components/sidebar/sidebar-collapsible-project-section";
import { SidebarProjectTrailingActions } from "@/components/sidebar/sidebar-project-trailing-actions";
import { SidebarProjectSection } from "@/components/sidebar/sidebar-collapsible-project-section";
import {
SidebarProjectTrailingActions,
type SidebarProjectCreateButtonConfig,
} from "@/components/sidebar/sidebar-project-trailing-actions";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
@@ -13,6 +17,7 @@ import type {
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSessionStore } from "@/stores/session-store";
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import {
deriveGroupedSidebarSessions,
type SidebarSessionAgentProject,
@@ -109,6 +114,8 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
drag,
dragHandleProps,
onToggleCollapsed,
children,
footer = null,
}: {
serverId: string | null;
projectKey: string;
@@ -120,6 +127,8 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
drag?: () => void;
dragHandleProps?: DraggableListDragHandleProps;
onToggleCollapsed: (projectKey: string) => void;
children?: React.ReactNode;
footer?: React.ReactNode;
}): ReactElement {
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd: projectIconKey ?? "" });
const dataUri = useMemo(() => {
@@ -132,6 +141,30 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const sourceDirectory = projectIconKey ?? undefined;
const handleCreateSessionPress = useCallback(() => {
if (!serverId || !sourceDirectory) {
return;
}
router.navigate(
buildHostNewWorkspaceRoute(serverId, sourceDirectory, {
headerTitle: "New session",
}) as Href,
);
}, [serverId, sourceDirectory]);
const createButton = useMemo<SidebarProjectCreateButtonConfig | null>(
() =>
serverId && sourceDirectory
? {
onPress: handleCreateSessionPress,
accessibilityLabel: "New session",
testID: `sidebar-project-${projectKey}-new-session-button`,
tooltipLabel: "New session",
icon: "plus" as const,
}
: null,
[handleCreateSessionPress, projectKey, serverId, sourceDirectory],
);
const interaction = useLongPressDragInteraction({
drag: drag ?? noopDrag,
menuController: null,
@@ -144,9 +177,10 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
serverId={serverId}
isHovered={isHovered}
workspaces={workspaces}
createButton={createButton}
/>
),
[isHovered, projectKey, projectName, serverId, workspaces],
[createButton, isHovered, projectKey, projectName, serverId, workspaces],
);
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
@@ -155,6 +189,22 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
}
onToggleCollapsed(projectKey);
}, [interaction.didLongPressRef, onToggleCollapsed, projectKey]);
const headerProps = useMemo(
() => ({
accessibilityLabel: isCollapsed ? `Expand ${projectName}` : `Collapse ${projectName}`,
onPressIn: drag ? interaction.handlePressIn : undefined,
onTouchMove: drag ? interaction.handleTouchMove : undefined,
onPressOut: drag ? interaction.handlePressOut : undefined,
}),
[
drag,
interaction.handlePressIn,
interaction.handlePressOut,
interaction.handleTouchMove,
isCollapsed,
projectName,
],
);
return (
<View
@@ -164,7 +214,8 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<SidebarProjectHeaderRow
<SidebarProjectSection
projectKey={projectKey}
projectName={projectName}
iconDataUri={dataUri}
chevron={isCollapsed ? "expand" : "collapse"}
@@ -172,12 +223,13 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
isDragging={isDragging}
trailingSlot={trailingSlot}
onPress={handlePress}
onPressIn={drag ? interaction.handlePressIn : undefined}
onTouchMove={drag ? interaction.handleTouchMove : undefined}
onPressOut={drag ? interaction.handlePressOut : undefined}
testID={`sidebar-session-group-header-${projectKey}`}
accessibilityLabel={isCollapsed ? `Expand ${projectName}` : `Collapse ${projectName}`}
/>
isCollapsed={isCollapsed}
footer={footer}
headerProps={headerProps}
>
{children}
</SidebarProjectSection>
</View>
);
});

View File

@@ -1,7 +1,7 @@
/**
* @vitest-environment jsdom
*/
import { act, fireEvent, render, renderHook } from "@testing-library/react";
import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
import React from "react";
import { useCallback, useMemo } from "react";
import { Pressable, View } from "react-native";
@@ -55,6 +55,10 @@ vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
const { routerNavigateMock } = vi.hoisted(() => ({
routerNavigateMock: vi.fn(),
}));
vi.mock("@/hooks/use-project-icon-query", () => ({
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
}));
@@ -67,6 +71,7 @@ vi.mock("lucide-react-native", () => {
ChevronRight: createIcon("ChevronRight"),
FolderPlus: createIcon("FolderPlus"),
MoreVertical: createIcon("MoreVertical"),
Plus: createIcon("Plus"),
Settings: createIcon("Settings"),
Trash2: createIcon("Trash2"),
};
@@ -74,7 +79,7 @@ vi.mock("lucide-react-native", () => {
vi.mock("expo-router", () => ({
router: {
navigate: vi.fn(),
navigate: routerNavigateMock,
},
}));
@@ -234,7 +239,9 @@ function useGroupedBoundaryHarness(input?: { collapsedProjectKeys?: ReadonlySet<
}
afterEach(() => {
cleanup();
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
routerNavigateMock.mockClear();
});
describe("sidebar session render boundaries", () => {
@@ -386,8 +393,8 @@ describe("sidebar session render boundaries", () => {
expect(onPress).toHaveBeenCalledWith("project-a");
});
it("renders the project kebab in the grouped header", () => {
const { getByTestId, queryByTestId } = render(
it("renders the project kebab and new-session button in the grouped header", () => {
const { getByTestId } = render(
<SidebarSessionGroupHeader
serverId="server-1"
projectKey="project-a"
@@ -400,6 +407,26 @@ describe("sidebar session render boundaries", () => {
);
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
expect(getByTestId("sidebar-project-project-a-new-session-button")).toBeTruthy();
});
it("opens a new session scoped to the grouped project root", () => {
const { getByTestId } = render(
<SidebarSessionGroupHeader
serverId="server-1"
projectKey="project-a"
projectName="Project A"
projectIconKey="/repo/a"
workspaces={HARNESS_PROJECTS[0].workspaces}
isCollapsed={false}
onToggleCollapsed={vi.fn()}
/>,
);
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
expect(routerNavigateMock).toHaveBeenCalledWith(
"/h/server-1/new?dir=%2Frepo%2Fa&title=New+session",
);
});
});

View File

@@ -264,7 +264,6 @@ describe("deriveGroupedSidebarSessions", () => {
hiddenCount: 0,
isExpanded: false,
isCollapsed: false,
isFlattened: false,
totalCount: 3,
},
]);
@@ -389,7 +388,7 @@ describe("deriveGroupedSidebarSessions", () => {
expect(groups.map((group) => group.projectKey)).toEqual(["project-a"]);
});
it("flattens a single non-git workspace project (no header, ignores collapse)", () => {
it("keeps a single-workspace non-git project grouped and collapsible", () => {
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
@@ -399,13 +398,29 @@ describe("deriveGroupedSidebarSessions", () => {
});
expect(groups[0]).toMatchObject({
isFlattened: true,
projectKey: "project-a",
isCollapsed: true,
visibleIds: [],
});
});
it("renders a single-workspace non-git project group when expanded", () => {
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
projects: [projectA],
previewExpandedProjects: new Set(),
});
expect(groups[0]).toMatchObject({
projectKey: "project-a",
projectName: "project-a",
isCollapsed: false,
visibleIds: ["a1", "a2"],
});
});
it("keeps multi-workspace non-git projects collapsible", () => {
it("renders multi-workspace non-git project groups too", () => {
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 2 });
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
@@ -415,12 +430,12 @@ describe("deriveGroupedSidebarSessions", () => {
});
expect(groups[0]).toMatchObject({
isFlattened: false,
isCollapsed: true,
visibleIds: [],
});
});
it("keeps single-workspace git projects collapsible (not flattened)", () => {
it("keeps single-workspace git projects collapsible", () => {
const projectA = testProject("project-a", { projectKind: "git", workspaceCount: 1 });
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
@@ -430,7 +445,6 @@ describe("deriveGroupedSidebarSessions", () => {
});
expect(groups[0]).toMatchObject({
isFlattened: false,
isCollapsed: true,
});
});

View File

@@ -1,7 +1,6 @@
import type { SidebarSessionAgent, SidebarSessionFilter, SidebarSessionWorkspace } from "./types";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export interface SidebarSessionWorkspaceLookup {
@@ -20,7 +19,6 @@ export interface SidebarSessionGroup {
hiddenCount: number;
isExpanded: boolean;
isCollapsed: boolean;
isFlattened: boolean;
totalCount: number;
}
@@ -167,8 +165,7 @@ export function deriveGroupedSidebarSessions(input: {
if (!ids || ids.length === 0) {
continue;
}
const isFlattened = isSidebarProjectFlattened(project);
const isCollapsed = !isFlattened && collapsedProjectKeys.has(project.projectKey);
const isCollapsed = collapsedProjectKeys.has(project.projectKey);
const isExpanded = !isCollapsed && previewExpandedProjects.has(project.projectKey);
groups.push({
projectKey: project.projectKey,
@@ -178,7 +175,6 @@ export function deriveGroupedSidebarSessions(input: {
hiddenCount: hiddenCountFor({ totalCount: ids.length, isCollapsed, isExpanded, limit }),
isExpanded,
isCollapsed,
isFlattened,
totalCount: ids.length,
});
}

View File

@@ -1,33 +1,19 @@
/**
* @vitest-environment jsdom
*/
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import type { DaemonClient } from "@server/client/daemon-client";
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { Pressable, Text, View } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { SidebarSessionRowKebabMenu } from "./session-row-actions";
vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
const {
archiveAgentMock,
confirmDialogMock,
platformState,
closeTabMock,
hideAgentMock,
unpinAgentMock,
} = vi.hoisted(() => ({
const { archiveAgentMock, platformState } = vi.hoisted(() => ({
archiveAgentMock: vi.fn(),
confirmDialogMock: vi.fn(),
platformState: { isNative: false },
closeTabMock: vi.fn(),
hideAgentMock: vi.fn(),
unpinAgentMock: vi.fn(),
}));
vi.mock("@/hooks/use-archive-agent", () => ({
@@ -37,10 +23,6 @@ vi.mock("@/hooks/use-archive-agent", () => ({
}),
}));
vi.mock("@/utils/confirm-dialog", () => ({
confirmDialog: confirmDialogMock,
}));
vi.mock("@/constants/platform", () => ({
get isNative() {
return platformState.isNative;
@@ -84,7 +66,6 @@ vi.mock("lucide-react-native", () => {
return {
Archive: createIcon("Archive"),
MoreVertical: createIcon("MoreVertical"),
X: createIcon("X"),
};
});
@@ -109,94 +90,16 @@ vi.mock("react-native-unistyles", () => ({
}));
const SERVER_ID = "server-1";
const WORKSPACE_ID = "workspace-1";
const AGENT_ID = "agent-1";
const CWD = "/repo/project/workspace";
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const AGENT_DEFAULTS: Agent = {
serverId: SERVER_ID,
id: AGENT_ID,
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: "Menu agent",
cwd: CWD,
model: null,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
labels: {},
projectPlacement: null,
};
function makeAgent(input: Partial<Agent> = {}): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
function workspace(input: Partial<WorkspaceDescriptor> = {}): WorkspaceDescriptor {
return {
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo/project",
workspaceDirectory: CWD,
projectKind: "git",
workspaceKind: "worktree",
name: "workspace",
status: "done",
archivingAt: null,
diffStat: null,
scripts: [],
...input,
};
}
function seedState(agent: Agent = makeAgent()) {
useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]]));
useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, workspace()]]));
}
beforeEach(() => {
archiveAgentMock.mockReset();
archiveAgentMock.mockResolvedValue(undefined);
confirmDialogMock.mockReset();
confirmDialogMock.mockResolvedValue(true);
closeTabMock.mockReset();
hideAgentMock.mockReset();
unpinAgentMock.mockReset();
platformState.isNative = false;
useWorkspaceLayoutStore.setState({
closeTab: closeTabMock,
hideAgent: hideAgentMock,
unpinAgent: unpinAgentMock,
});
});
afterEach(() => {
cleanup();
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
});
describe("SidebarSessionRowKebabMenu", () => {
@@ -224,35 +127,15 @@ describe("SidebarSessionRowKebabMenu", () => {
});
it("archives the agent from the archive item", () => {
const { getByTestId } = render(
const { getAllByTestId, getByTestId, queryByTestId } = render(
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
);
expect(getAllByTestId(/^sidebar-session-menu-server-1-agent-1-/)).toHaveLength(1);
expect(queryByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-close`)).toBeNull();
fireEvent.click(getByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-archive`));
expect(archiveAgentMock).toHaveBeenCalledWith({ serverId: SERVER_ID, agentId: AGENT_ID });
});
it("uses the existing close semantics for the close item", async () => {
seedState(makeAgent({ status: "running" }));
const { getByTestId } = render(
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
);
fireEvent.click(getByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-close`));
await waitFor(() => {
expect(archiveAgentMock).toHaveBeenCalledWith({ serverId: SERVER_ID, agentId: AGENT_ID });
});
expect(confirmDialogMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Archive running agent?",
confirmLabel: "Archive",
}),
);
expect(unpinAgentMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, AGENT_ID);
expect(hideAgentMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, AGENT_ID);
expect(closeTabMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, `agent_${AGENT_ID}`);
});
});

View File

@@ -1,6 +1,6 @@
import React, { memo, useCallback, useMemo, type ReactElement } from "react";
import { Text, type PressableStateCallbackType } from "react-native";
import { Archive, MoreVertical, X } from "lucide-react-native";
import React, { memo, useCallback, type ReactElement } from "react";
import { type PressableStateCallbackType } from "react-native";
import { Archive, MoreVertical } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
@@ -10,19 +10,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { isNative as isTouchPlatform } from "@/constants/platform";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import {
buildWorkspaceTabMenuEntries,
type WorkspaceTabMenuEntry,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { useSessionStore } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { resolveSidebarSessionWorkspaceId } from "./session-filtering";
export interface SidebarSessionRowKebabMenuProps {
serverId: string;
@@ -32,7 +20,6 @@ export interface SidebarSessionRowKebabMenuProps {
const ThemedArchive = withUnistyles(Archive);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedX = withUnistyles(X);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -54,8 +41,6 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
);
}
function noop() {}
export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMenu({
serverId,
agentId,
@@ -63,95 +48,12 @@ export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMe
}: SidebarSessionRowKebabMenuProps): ReactElement | null {
const visible = isHovered || isTouchPlatform;
const { archiveAgent } = useArchiveAgent();
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
const tab = useMemo<WorkspaceTabDescriptor>(
() => ({
key: `agent_${agentId}`,
tabId: `agent_${agentId}`,
kind: "agent",
target: { kind: "agent", agentId },
}),
[agentId],
);
const handleArchiveAgent = useCallback(() => {
void archiveAgent({ serverId, agentId }).catch(() => {});
}, [agentId, archiveAgent, serverId]);
const handleCloseAgentTab = useCallback(async () => {
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId) ?? null;
const isRunning = agent?.status === "running" || agent?.status === "initializing";
if (isRunning) {
const confirmed = await confirmDialog({
title: "Archive running agent?",
message: "This agent is still running. Archiving it will stop the agent and close the tab.",
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
}
const workspaceId = agent?.cwd
? resolveSidebarSessionWorkspaceId({
agent: {
id: agentId,
serverId,
cwd: agent.cwd,
archivedAt: null,
},
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
})
: null;
const persistenceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
if (persistenceKey) {
unpinWorkspaceAgent(persistenceKey, agentId);
hideWorkspaceAgent(persistenceKey, agentId);
closeWorkspaceTab(persistenceKey, tab.tabId);
}
void archiveAgent({ serverId, agentId }).catch(() => {});
}, [
agentId,
archiveAgent,
closeWorkspaceTab,
hideWorkspaceAgent,
serverId,
tab.tabId,
unpinWorkspaceAgent,
]);
const closeEntry = useMemo(
() =>
buildWorkspaceTabMenuEntries({
surface: "desktop",
tab,
index: 0,
tabCount: 1,
menuTestIDBase: `sidebar-session-menu-${serverId}-${agentId}`,
onCopyResumeCommand: noop,
onCopyAgentId: noop,
onReloadAgent: noop,
onCloseTab: handleCloseAgentTab,
onCloseTabsBefore: noop,
onCloseTabsAfter: noop,
onCloseOtherTabs: noop,
}).find((entry) => entry.kind === "item" && entry.key === "close") as
| Extract<WorkspaceTabMenuEntry, { kind: "item" }>
| undefined,
[agentId, handleCloseAgentTab, serverId, tab],
);
if (!visible || !closeEntry) {
if (!visible) {
return null;
}
@@ -167,7 +69,6 @@ export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMe
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
<SidebarSessionMenuItem entry={closeEntry} />
<DropdownMenuItem
testID={`sidebar-session-menu-${serverId}-${agentId}-archive`}
leading={archiveLeadingIcon}
@@ -180,37 +81,6 @@ export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMe
);
});
function SidebarSessionMenuItem({
entry,
}: {
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
}) {
const leading = useMemo(() => {
if (entry.icon === "x") {
return <ThemedX size={14} uniProps={foregroundMutedColorMapping} />;
}
return undefined;
}, [entry.icon]);
const trailing = useMemo(
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
[entry.hint],
);
return (
<DropdownMenuItem
testID={entry.testID}
disabled={entry.disabled}
destructive={entry.destructive}
onSelect={entry.onSelect}
tooltip={entry.tooltip}
leading={leading}
trailing={trailing}
>
{entry.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
kebabButton: {
padding: 2,
@@ -220,8 +90,4 @@ const styles = StyleSheet.create((theme) => ({
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
menuItemHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));

View File

@@ -287,35 +287,45 @@ const SidebarSessionGroupSection = memo(function SidebarSessionGroupSection({
onProjectPreviewExpandedToggle: (projectKey: string) => void;
}): ReactElement {
const showFooter = !group.isCollapsed && group.totalCount > GROUPED_SESSION_LIMIT;
return (
<View>
{!group.isFlattened ? (
<SidebarSessionGroupHeader
serverId={serverId}
projectKey={group.projectKey}
projectName={group.projectName}
projectIconKey={group.projectIconKey}
workspaces={project.workspaces}
isCollapsed={group.isCollapsed}
isDragging={isDragging}
drag={drag}
dragHandleProps={dragHandleProps}
onToggleCollapsed={onProjectCollapsedToggle}
/>
) : null}
{serverId
? group.visibleIds.map((id) => (
<SidebarSessionRow key={id} id={id} serverId={serverId} indented={!group.isFlattened} />
))
: null}
{showFooter ? (
const footer = useMemo(
() =>
showFooter ? (
<SidebarSessionGroupFooter
projectKey={group.projectKey}
hiddenCount={group.hiddenCount}
isExpanded={group.isExpanded}
onPress={onProjectPreviewExpandedToggle}
/>
) : null}
) : null,
[
group.hiddenCount,
group.isExpanded,
group.projectKey,
onProjectPreviewExpandedToggle,
showFooter,
],
);
return (
<View>
<SidebarSessionGroupHeader
serverId={serverId}
projectKey={group.projectKey}
projectName={group.projectName}
projectIconKey={group.projectIconKey}
workspaces={project.workspaces}
isCollapsed={group.isCollapsed}
isDragging={isDragging}
drag={drag}
dragHandleProps={dragHandleProps}
onToggleCollapsed={onProjectCollapsedToggle}
footer={footer}
>
{serverId
? group.visibleIds.map((id) => (
<SidebarSessionRow key={id} id={id} serverId={serverId} indented />
))
: null}
</SidebarSessionGroupHeader>
</View>
);
});