Customize top-level sidebar items (#4203)

* feat(app): customize top-level sidebar items

Let users reorder and hide built-in and plugin sidebar rows from Appearance settings while keeping the preference device-local. Add Search as a first-class row and standardize section explanations as header info tooltips.

* fix(app): preserve consecutive sidebar preference edits

Derive each sidebar change from the latest cached settings and retain unavailable plugin slots while editing the visible list. Keep E2E interaction selectors tied to accessible control names.

* test(app): isolate provider settings unit boundary

Keep the legacy provider-section test focused on provider behavior now that its shared section shell can render the settings info control.
This commit is contained in:
Mohamed Boudra
2026-09-02 11:42:39 +02:00
committed by GitHub
parent d67a6f5f6c
commit de8535c38b
37 changed files with 1370 additions and 311 deletions
+3
View File
@@ -120,6 +120,8 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
@@ -248,6 +250,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
- A muted paragraph between a section header and its card. Section-level explanation is the header's `info` tooltip (§7).
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
- The word "checkout" in UI strings or identifiers. The term is "workspace".
- New color tokens or hardcoded hex outside the palette. The identity color table is the documented exception (§13), not a license.
+1
View File
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -27,7 +27,10 @@ test("chooses a metadata model and can return to automatic selection", async ({
}, testInfo) => {
await openMetadataGenerationSettings(page);
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("metadata-generation-settings-info")).toHaveAccessibleName(
"About Metadata generation",
);
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
"aria-selected",
"true",
@@ -12,10 +12,7 @@ import { buildAgentRoute } from "../support/helpers/mock-agent";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
} from "../support/helpers/sidebar";
import { expectMobileAgentSidebarHidden } from "../support/helpers/sidebar";
import {
switchWorkspaceViaSidebar,
waitForWorkspaceInSidebar,
@@ -159,7 +156,7 @@ async function runCommand(page: Page, title: string): Promise<void> {
async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).click();
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible();
await expect(page.getByTestId("sidebar-search")).toBeVisible();
}
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
@@ -331,8 +328,9 @@ test.describe("plugin workspace panels and Command Center", () => {
await page.waitForURL(isSettledWorkspaceUrl, { timeout: 60_000 });
await expect(page.getByRole("button", { name: "Open composer review" })).toHaveCount(0);
await openCompactSidebar(page);
// The sidebar's Search row dismisses the compact sidebar on its way to the
// command center, so nothing has to close it after the command runs.
await runCommand(page, "Open plugin agent");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(page.getByText(`Agent bridge ${agent.id}`)).toBeVisible();
await expect(
@@ -0,0 +1,108 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
expectSidebarItemHidden,
expectSidebarNavSettingsOrder,
expectSidebarNavSettingsRow,
expectSidebarOrder,
expectStoredSidebarNav,
leaveSettings,
moveSidebarNavItemUp,
openSidebarNavSettings,
seedSidebarNavPreferences,
setSidebarNavItemVisible,
} from "../support/helpers/sidebar-nav-settings";
test.describe("Sidebar items in Appearance settings", () => {
test("owner reorders and hides top-level sidebar items", async ({ page }) => {
await gotoAppShell(page);
await test.step("the sidebar starts in the default order", async () => {
await expectSidebarOrder(page, ["new-workspace", "history", "search", "schedules"]);
});
await test.step("the Sidebar section lists every item in the same order", async () => {
await openSidebarNavSettings(page);
// The section explains itself through the header's info tooltip, not a paragraph.
await expect(page.getByTestId("sidebar-nav-section-info")).toHaveAccessibleName(
"About Sidebar",
);
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"history",
"search",
"schedules",
]);
await expectSidebarNavSettingsRow(page, {
key: "history",
label: "History",
visible: true,
});
// Items with a keyboard shortcut badge it next to their name. Chords render
// with Ctrl off macOS, which is what the browser project runs on.
await expect(
page.getByTestId("sidebar-nav-item-new-workspace").getByText("Ctrl+N", { exact: true }),
).toBeVisible();
});
await test.step("moving Schedules up twice lifts it above History", async () => {
await moveSidebarNavItemUp(page, "schedules");
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"history",
"schedules",
"search",
]);
await moveSidebarNavItemUp(page, "schedules");
await expectSidebarNavSettingsOrder(page, [
"new-workspace",
"schedules",
"history",
"search",
]);
await leaveSettings(page);
await expectSidebarOrder(page, ["new-workspace", "schedules", "history", "search"]);
});
await test.step("turning History off removes it from the sidebar", async () => {
await openSidebarNavSettings(page);
await setSidebarNavItemVisible(page, "history", false);
await expectStoredSidebarNav(page, [
{ key: "new-workspace", visible: true },
{ key: "schedules", visible: true },
{ key: "history", visible: false },
{ key: "search", visible: true },
]);
await leaveSettings(page);
await expectSidebarItemHidden(page, "history");
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
});
await test.step("the sidebar keeps that shape across a reload", async () => {
await page.reload();
await expectSidebarItemHidden(page, "history");
await expectSidebarOrder(page, ["new-workspace", "schedules", "search"]);
});
});
test("renders no top-level items when every one is turned off", async ({ page }) => {
await seedSidebarNavPreferences(page, [
{ key: "new-workspace", visible: false },
{ key: "history", visible: false },
{ key: "search", visible: false },
{ key: "schedules", visible: false },
]);
await gotoAppShell(page);
// The sidebar itself still renders; only its top-level nav items are gone.
await expect(page.locator('[data-testid="sidebar-settings"]:visible')).toBeVisible({
timeout: 30_000,
});
await expectSidebarItemHidden(page, "new-workspace");
await expectSidebarItemHidden(page, "history");
await expectSidebarItemHidden(page, "search");
await expectSidebarItemHidden(page, "schedules");
});
});
@@ -58,7 +58,7 @@ export async function observeCommandCenterScroll(page: Page): Promise<void> {
}
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("sidebar-search")).toBeVisible({ timeout: 30_000 });
await page.keyboard.press("Meta+K");
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
@@ -2,7 +2,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
// Opens the command center / global search palette from the sidebar and returns its panel.
export async function openCommandCenter(page: Page): Promise<Locator> {
await page.getByTestId("sidebar-command-center-search").click();
await page.getByTestId("sidebar-search").click();
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
return panel;
@@ -34,7 +34,7 @@ async function createRunningMockAgent(
}
async function openCommandCenter(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open command center" }).click();
await page.getByTestId("sidebar-search").click();
}
export class DirectoryBootstrapScenario {
@@ -0,0 +1,147 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { clickSettingsBackToWorkspace, openSettingsSection } from "./settings";
const APP_SETTINGS_KEY = "@paseo:app-settings";
/** Persisted nav key -> the testID the app shell renders that item with. */
const SHELL_ROW_TEST_IDS = {
"new-workspace": "sidebar-global-new-workspace",
history: "sidebar-sessions",
search: "sidebar-search",
schedules: "sidebar-schedules",
} as const;
export type SidebarNavKey = keyof typeof SHELL_ROW_TEST_IDS;
export interface SidebarNavPreference {
key: string;
visible: boolean;
}
function shellRow(page: Page, key: SidebarNavKey): Locator {
// `:visible` rather than a plain testID: the shell keeps a compact copy of the
// sidebar mounted, so the pinned row is the first visible match.
return page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`).first();
}
function settingsRow(page: Page, key: SidebarNavKey): Locator {
return page.getByTestId(`sidebar-nav-item-${key}`);
}
function itemLabel(key: SidebarNavKey): string {
return {
"new-workspace": "New workspace",
history: "History",
search: "Search",
schedules: "Schedules",
}[key];
}
async function rowTop(locator: Locator): Promise<number> {
const box = await locator.boundingBox();
if (!box) {
throw new Error("Expected a laid-out sidebar row to measure");
}
return box.y;
}
export async function seedSidebarNavPreferences(
page: Page,
preferences: SidebarNavPreference[],
): Promise<void> {
await page.addInitScript(
({ key, sidebarNavItems }) => {
localStorage.setItem(key, JSON.stringify({ sidebarNavItems }));
},
{ key: APP_SETTINGS_KEY, sidebarNavItems: preferences },
);
}
export async function openSidebarNavSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "appearance");
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
}
export async function leaveSettings(page: Page): Promise<void> {
await clickSettingsBackToWorkspace(page);
}
export async function moveSidebarNavItemUp(page: Page, key: SidebarNavKey): Promise<void> {
await settingsRow(page, key).getByRole("button", { name: "Move up", exact: true }).click();
}
export async function setSidebarNavItemVisible(
page: Page,
key: SidebarNavKey,
visible: boolean,
): Promise<void> {
const toggle = settingsRow(page, key).getByRole("switch", {
name: itemLabel(key),
exact: true,
});
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
}
export async function expectSidebarNavSettingsRow(
page: Page,
expected: { key: SidebarNavKey; label: string; visible: boolean },
): Promise<void> {
const row = settingsRow(page, expected.key);
await expect(row).toBeVisible();
await expect(row.getByText(expected.label, { exact: true })).toBeVisible();
const toggle = row.getByRole("switch", { name: expected.label, exact: true });
await expect(toggle).toHaveAccessibleName(expected.label);
await expect(toggle).toHaveAttribute("aria-checked", String(expected.visible));
}
export async function expectSidebarNavSettingsOrder(
page: Page,
keys: SidebarNavKey[],
): Promise<void> {
await expectVerticalOrder(keys, (key) => settingsRow(page, key), "sidebar nav settings rows");
}
export async function expectSidebarOrder(page: Page, keys: SidebarNavKey[]): Promise<void> {
await expectVerticalOrder(keys, (key) => shellRow(page, key), "app shell sidebar rows");
}
export async function expectSidebarItemHidden(page: Page, key: SidebarNavKey): Promise<void> {
await expect(page.locator(`[data-testid="${SHELL_ROW_TEST_IDS[key]}"]:visible`)).toHaveCount(0);
}
export async function expectStoredSidebarNav(
page: Page,
expected: SidebarNavPreference[],
): Promise<void> {
await expect
.poll(
() =>
page.evaluate((key) => {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw).sidebarNavItems ?? null) : null;
}, APP_SETTINGS_KEY),
{ timeout: 15_000 },
)
.toEqual(expected);
}
async function expectVerticalOrder(
keys: SidebarNavKey[],
locate: (key: SidebarNavKey) => Locator,
subject: string,
): Promise<void> {
await expect
.poll(
async () => {
const measured = await Promise.all(
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
);
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
},
{ message: `Expected ${subject} in order`, timeout: 15_000 },
)
.toEqual(keys);
}
+5 -222
View File
@@ -1,16 +1,5 @@
import { router, usePathname } from "expo-router";
import {
CalendarClock,
FolderPlus,
GitBranch,
History,
Home,
Plus,
Search,
Server,
Settings,
X,
} from "lucide-react-native";
import { router } from "expo-router";
import { FolderPlus, GitBranch, Home, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -33,8 +22,8 @@ import {
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
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";
@@ -42,8 +31,6 @@ 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 { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
@@ -54,28 +41,20 @@ 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 { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHosts } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
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 {
buildOpenProjectRoute,
buildNewWorkspaceRoute,
buildSchedulesRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildSettingsRoute,
} from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { PluginSidebarItems } from "@/plugins";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
@@ -102,20 +81,16 @@ interface SidebarSharedProps {
handleHome: () => void;
handleSettings: () => void;
labels: SidebarLabels;
newWorkspaceKeys: ShortcutKey[][] | null;
handleAddHost: () => void;
handleOpenHostSettings: (serverId: string) => void;
}
interface SidebarLabels {
addProject: string;
newWorkspace: string;
hosts: string;
home: string;
settings: string;
searchHosts: string;
sessions: string;
schedules: string;
closeSidebar: string;
}
@@ -124,15 +99,11 @@ interface MobileSidebarProps extends SidebarSharedProps {
insetsTop: number;
insetsBottom: number;
closeSidebar: () => void;
handleViewMoreNavigate: () => void;
handleViewSchedulesNavigate: () => void;
}
interface DesktopSidebarProps extends SidebarSharedProps {
insetsTop: number;
active: boolean;
handleViewMore: () => void;
handleViewSchedules: () => void;
}
export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boolean }) {
@@ -223,25 +194,13 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
router.push(buildOpenProjectRoute());
}, []);
const handleViewMoreNavigate = useCallback(() => {
router.push(buildSessionsRoute());
}, []);
const handleViewSchedulesNavigate = useCallback(() => {
router.push(buildSchedulesRoute());
}, []);
const newWorkspaceKeys = useShortcutKeys("new-workspace");
const labels = useMemo(
(): SidebarLabels => ({
addProject: t("sidebar.actions.addProject"),
newWorkspace: t("sidebar.actions.newWorkspace"),
hosts: t("sidebar.actions.hosts"),
home: t("sidebar.actions.home"),
settings: t("sidebar.actions.settings"),
searchHosts: t("sidebar.host.searchPlaceholder"),
sessions: t("sidebar.sections.sessions"),
schedules: t("sidebar.sections.schedules"),
closeSidebar: t("sidebar.actions.closeSidebar"),
}),
[t],
@@ -265,7 +224,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
toggleProjectCollapsed,
handleRefresh,
labels,
newWorkspaceKeys,
};
if (isCompactLayout) {
@@ -282,8 +240,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
handleSettings={handleSettingsMobile}
handleAddHost={handleAddHostMobile}
handleOpenHostSettings={handleOpenHostSettingsMobile}
handleViewMoreNavigate={handleViewMoreNavigate}
handleViewSchedulesNavigate={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
);
@@ -300,8 +256,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
handleSettings={handleSettingsDesktop}
handleAddHost={handleAddHostDesktop}
handleOpenHostSettings={handleOpenHostSettingsDesktop}
handleViewMore={handleViewMoreNavigate}
handleViewSchedules={handleViewSchedulesNavigate}
/>
</RetainedPanelActivity>
);
@@ -487,61 +441,6 @@ function IconTooltipContent({
);
}
const SidebarNewWorkspaceHeaderRow = memo(function SidebarNewWorkspaceHeaderRow({
label,
testID,
variant,
shortcutKeys,
onBeforeNavigate,
}: {
label: string;
testID: string;
variant: "header" | "compact";
shortcutKeys: ShortcutKey[][] | null;
onBeforeNavigate?: () => void;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
const supportsWorkspaceMultiplicity = useHostFeature(
activeWorkspaceServerId,
"workspaceMultiplicity",
);
const canUseActiveWorkspaceContext = Boolean(
activeWorkspace &&
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(
activeWorkspaceServerId
? buildNewWorkspaceRoute(
activeWorkspace && canUseActiveWorkspaceContext
? {
serverId: activeWorkspaceServerId,
sourceDirectory: activeWorkspace.projectRootPath,
projectId: activeWorkspace.projectId,
}
: { serverId: activeWorkspaceServerId },
)
: buildNewWorkspaceRoute(),
);
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={Plus}
label={label}
onPress={handlePress}
testID={testID}
variant={variant}
shortcutKeys={shortcutKeys}
/>
);
});
function SidebarFooter({
theme,
handleOpenProject,
@@ -622,7 +521,6 @@ function MobileSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
newWorkspaceKeys,
handleOpenProject,
handleHome,
handleSettings,
@@ -632,25 +530,10 @@ function MobileSidebar({
insetsTop,
insetsBottom,
closeSidebar,
handleViewMoreNavigate,
handleViewSchedulesNavigate,
}: MobileSidebarProps) {
const pathname = usePathname();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
const handleViewMore = useCallback(() => {
closeSidebar();
handleViewMoreNavigate();
}, [closeSidebar, handleViewMoreNavigate]);
const handleViewSchedules = useCallback(() => {
closeSidebar();
handleViewSchedulesNavigate();
}, [closeSidebar, handleViewSchedulesNavigate]);
const handleWorkspacePress = useCallback(() => {
closeSidebar();
}, [closeSidebar]);
@@ -672,32 +555,7 @@ function MobileSidebar({
>
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<View style={styles.sidebarHeaderGroup}>
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
onBeforeNavigate={closeSidebar}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems onBeforeNavigate={closeSidebar} />
</View>
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
<WindowChromeSafeArea placement="inline" style={styles.mobileCloseButtonRow}>
<Pressable
style={styles.mobileCloseButton}
@@ -774,7 +632,6 @@ function DesktopSidebar({
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
handleRefresh,
newWorkspaceKeys,
handleOpenProject,
handleHome,
handleSettings,
@@ -783,14 +640,9 @@ function DesktopSidebar({
handleOpenHostSettings,
insetsTop,
active,
handleViewMore,
handleViewSchedules,
}: DesktopSidebarProps) {
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const pathname = usePathname();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const isSessionsActive = pathname.includes("/sessions");
const isSchedulesActive = pathname.includes("/schedules");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: viewportWidth } = useWindowDimensions();
@@ -898,31 +750,7 @@ function DesktopSidebar({
) : (
<TitlebarDragRegion />
)}
<View style={sidebarHeaderGroupStyle}>
<SidebarNewWorkspaceHeaderRow
label={labels.newWorkspace}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={newWorkspaceKeys}
/>
<SidebarHeaderRow
icon={History}
label={labels.sessions}
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
variant="compact"
/>
<SidebarHeaderRow
icon={CalendarClock}
label={labels.schedules}
onPress={handleViewSchedules}
isActive={isSchedulesActive}
testID="sidebar-schedules"
variant="compact"
/>
<PluginSidebarItems />
</View>
<SidebarNavRows style={sidebarHeaderGroupStyle} />
</View>
{isInitialLoad && !hasActiveHostFilter ? (
@@ -971,45 +799,10 @@ function DesktopSidebar({
}
function WorkspacesSectionHeader() {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
const searchButtonStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.workspacesHeaderIconButton,
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
],
[],
);
return (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open command center"
testID="sidebar-command-center-search"
style={searchButtonStyle}
onPress={handleSearchPress}
>
{({ hovered, pressed }) => (
<Search
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
@@ -1075,16 +868,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[1],
},
workspacesHeaderIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
workspacesHeaderIconButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -0,0 +1,178 @@
import { router, usePathname } from "expo-router";
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
import { memo, useCallback, useMemo, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { PluginSidebarItemRow } from "@/plugins/sidebar-items";
import { canCreateWorktreeForProjectKind } from "@/projects/host-projects";
import { useHostFeature } from "@/runtime/host-features";
import {
builtinSidebarNavLabelKey,
builtinSidebarNavShortcutAction,
type BuiltinSidebarNavId,
} from "@/sidebar-nav/model";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import {
buildNewWorkspaceRoute,
buildSchedulesRoute,
buildSessionsRoute,
} from "@/utils/host-routes";
interface SidebarNavRowProps {
onBeforeNavigate?: () => void;
}
interface SidebarNavRowsProps extends SidebarNavRowProps {
/** Style for the group wrapper, which the sidebar owns. */
style?: StyleProp<ViewStyle>;
}
/**
* Top-level sidebar navigation, ordered and filtered by the user's
* `sidebarNavItems` preference. Renders nothing — not even the bordered group
* wrapper — when every item is hidden.
*/
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
const { items } = useSidebarNavItems();
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
if (visibleItems.length === 0) return null;
return (
<View style={style}>
{visibleItems.map((item) => {
if (item.kind === "plugin") {
return (
<PluginSidebarItemRow
key={item.key}
group={item.group}
onBeforeNavigate={onBeforeNavigate}
/>
);
}
const Row = BUILTIN_ROWS[item.id];
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
})}
</View>
);
}
const SidebarNewWorkspaceRow = memo(function SidebarNewWorkspaceRow({
onBeforeNavigate,
}: SidebarNavRowProps) {
const { t } = useTranslation();
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("new-workspace"));
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const activeWorkspaceServerId = activeWorkspaceSelection?.serverId ?? null;
const activeWorkspaceId = activeWorkspaceSelection?.workspaceId ?? null;
const activeWorkspace = useWorkspace(activeWorkspaceServerId, activeWorkspaceId);
const supportsWorkspaceMultiplicity = useHostFeature(
activeWorkspaceServerId,
"workspaceMultiplicity",
);
const canUseActiveWorkspaceContext = Boolean(
activeWorkspace &&
(supportsWorkspaceMultiplicity || canCreateWorktreeForProjectKind(activeWorkspace.projectKind)),
);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(
activeWorkspaceServerId
? buildNewWorkspaceRoute(
activeWorkspace && canUseActiveWorkspaceContext
? {
serverId: activeWorkspaceServerId,
sourceDirectory: activeWorkspace.projectRootPath,
projectId: activeWorkspace.projectId,
}
: { serverId: activeWorkspaceServerId },
)
: buildNewWorkspaceRoute(),
);
}, [activeWorkspace, activeWorkspaceServerId, canUseActiveWorkspaceContext, onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={Plus}
label={t(builtinSidebarNavLabelKey("new-workspace"))}
onPress={handlePress}
testID="sidebar-global-new-workspace"
variant="compact"
shortcutKeys={shortcutKeys}
/>
);
});
function SidebarHistoryRow({ onBeforeNavigate }: SidebarNavRowProps) {
const { t } = useTranslation();
const pathname = usePathname();
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(buildSessionsRoute());
}, [onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={History}
label={t(builtinSidebarNavLabelKey("history"))}
onPress={handlePress}
isActive={pathname.includes("/sessions")}
testID="sidebar-sessions"
variant="compact"
/>
);
}
function SidebarSearchRow({ onBeforeNavigate }: SidebarNavRowProps) {
const { t } = useTranslation();
const shortcutKeys = useShortcutKeys(builtinSidebarNavShortcutAction("search"));
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const handlePress = useCallback(() => {
onBeforeNavigate?.();
setCommandCenterOpen(true);
}, [onBeforeNavigate, setCommandCenterOpen]);
return (
<SidebarHeaderRow
icon={Search}
label={t(builtinSidebarNavLabelKey("search"))}
onPress={handlePress}
testID="sidebar-search"
variant="compact"
shortcutKeys={shortcutKeys}
/>
);
}
function SidebarSchedulesRow({ onBeforeNavigate }: SidebarNavRowProps) {
const { t } = useTranslation();
const pathname = usePathname();
const handlePress = useCallback(() => {
onBeforeNavigate?.();
router.push(buildSchedulesRoute());
}, [onBeforeNavigate]);
return (
<SidebarHeaderRow
icon={CalendarClock}
label={t(builtinSidebarNavLabelKey("schedules"))}
onPress={handlePress}
isActive={pathname.includes("/schedules")}
testID="sidebar-schedules"
variant="compact"
/>
);
}
const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps>> = {
"new-workspace": SidebarNewWorkspaceRow,
history: SidebarHistoryRow,
search: SidebarSearchRow,
schedules: SidebarSchedulesRow,
};
+5 -3
View File
@@ -36,6 +36,7 @@ import {
sanitizeFontFamily,
saveAppSettings as saveAppSettingsPure,
type AppSettings,
type AppSettingsUpdate,
type OpenInSidePanePreferences,
type PullRequestOpenLocation,
type DesktopSettingsBridge,
@@ -72,6 +73,7 @@ export {
};
export type {
AppSettings,
AppSettingsUpdate,
AppLanguage,
OpenInSidePanePreferences,
PullRequestOpenLocation,
@@ -116,7 +118,7 @@ export interface UseAppSettingsReturn {
settings: AppSettings;
isLoading: boolean;
error: unknown;
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
updateSettings: (updates: AppSettingsUpdate) => Promise<void>;
resetSettings: () => Promise<void>;
}
@@ -140,7 +142,7 @@ export function useAppSettings(): UseAppSettingsReturn {
});
const updateSettings = useCallback(
async (updates: Partial<AppSettings>) => {
async (updates: AppSettingsUpdate) => {
try {
await saveAppSettings({ queryClient, updates });
} catch (err) {
@@ -242,7 +244,7 @@ export async function persistAppSettings(updates: Partial<AppSettings>): Promise
export async function saveAppSettings(input: {
queryClient: QueryClient;
updates: Partial<AppSettings>;
updates: AppSettingsUpdate;
deps?: SettingsDeps;
}): Promise<void> {
await saveAppSettingsPure({
@@ -197,6 +197,46 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.chatOutlineEnabled).toBe(false);
});
it("defaults sidebar navigation items to an empty preference list", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.sidebarNavItems).toEqual([]);
});
it("loads stored sidebar navigation items in order", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({
sidebarNavItems: [
{ key: "history", visible: false },
{ key: "new-workspace", visible: true },
],
}),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sidebarNavItems).toEqual([
{ key: "history", visible: false },
{ key: "new-workspace", visible: true },
]);
});
it("falls back to the default sidebar navigation items when the stored list is malformed", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sidebarNavItems: [{ key: 3 }] }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sidebarNavItems).toEqual([]);
});
it("collapses legacy diff destinations into the former Explorer choice", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -380,6 +420,44 @@ describe("loadAppSettingsFromStorage", () => {
});
});
describe("saveAppSettings", () => {
it("applies consecutive functional updates to the latest cached settings", async () => {
const deps = makeDeps();
const queryClient = new QueryClient();
queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, DEFAULT_CLIENT_SETTINGS);
await Promise.all([
saveAppSettings({
queryClient,
updates: (current) => ({
sidebarNavItems: [...current.sidebarNavItems, { key: "history", visible: false }],
}),
deps,
}),
saveAppSettings({
queryClient,
updates: (current) => ({
sidebarNavItems: [...current.sidebarNavItems, { key: "search", visible: true }],
}),
deps,
}),
]);
expect(queryClient.getQueryData(APP_SETTINGS_QUERY_KEY)).toMatchObject({
sidebarNavItems: [
{ key: "history", visible: false },
{ key: "search", visible: true },
],
});
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({
sidebarNavItems: [
{ key: "history", visible: false },
{ key: "search", visible: true },
],
});
});
});
describe("loadSettingsFromStorage", () => {
it("defaults built-in daemon management to enabled when storage is empty", async () => {
const deps = makeDeps();
+12 -2
View File
@@ -3,6 +3,7 @@ import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { QueryClient } from "@tanstack/react-query";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import type { AppLanguage } from "@/i18n/locales";
import type { SidebarNavPreference } from "@/sidebar-nav/model";
import {
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
type SidebarChecksDisplay,
@@ -82,6 +83,8 @@ export interface AppSettings {
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
sidebarRowItems: SidebarRowItems;
sidebarChecksDisplay: SidebarChecksDisplay;
/** Top-level sidebar rows in display order; empty means the default order, all visible. */
sidebarNavItems: SidebarNavPreference[];
autoExpandReasoning: boolean;
toolCallDetailLevel: ToolCallDetailLevel;
chatOutlineEnabled: boolean;
@@ -91,6 +94,10 @@ export interface AppSettings {
pullRequestOpenLocation: PullRequestOpenLocation;
}
export type AppSettingsUpdate =
| Partial<AppSettings>
| ((current: AppSettings) => Partial<AppSettings>);
export interface OpenInSidePanePreferences {
explorerFiles: boolean;
diffs: boolean;
@@ -130,6 +137,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sidebarWorkspaceTrailing: "diff",
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
sidebarChecksDisplay: DEFAULT_SIDEBAR_CHECKS_DISPLAY,
sidebarNavItems: [],
autoExpandReasoning: false,
toolCallDetailLevel: "detailed",
chatOutlineEnabled: true,
@@ -218,6 +226,7 @@ const StoredAppSettingsSchema = z
.enum(["iconAndText", "icon", "none"])
.optional()
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
sidebarNavItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
autoExpandReasoning: z.boolean().catch(false),
toolCallDetailLevel: z
.enum(["overview", "detailed"])
@@ -319,14 +328,15 @@ export interface SettingsDeps {
export async function saveAppSettings(input: {
queryClient: QueryClient;
updates: Partial<AppSettings>;
updates: AppSettingsUpdate;
deps: SettingsDeps;
}): Promise<void> {
const storedCurrent =
input.queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ??
(await loadAppSettingsFromStorage(input.deps));
const current = normalizeAppSettings(storedCurrent);
const next = { ...current, ...input.updates };
const updates = typeof input.updates === "function" ? input.updates(current) : input.updates;
const next = { ...current, ...updates };
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
await writeAppSettings(
input.deps.storage,
+3 -1
View File
@@ -5,12 +5,14 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsElectronRuntime } from "@/constants/layout";
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
/** `null` action ids are accepted so callers can keep the hook unconditional. */
export function useShortcutKeys(actionId: string | null): ShortcutKey[][] | null {
const { overrides } = useKeyboardShortcutOverrides();
const isMac = getShortcutOs() === "mac";
const isDesktopApp = getIsElectronRuntime();
return useMemo(() => {
if (actionId === null) return null;
return resolveShortcutKeysForAction(actionId, overrides, {
isMac,
isDesktop: isDesktopApp,
+7
View File
@@ -1099,6 +1099,7 @@ export const ar: TranslationResources = {
},
sections: {
sessions: "السجل",
search: "بحث",
schedules: "الجداول",
},
worktreeSetup: {
@@ -2096,6 +2097,12 @@ export const ar: TranslationResources = {
title: "مخطط المحادثة",
description: "عرض مخطط للتنقل بين المطالبات",
},
sidebar: {
title: "الشريط الجانبي",
description: "اختر العناصر التي تظهر أعلى الشريط الجانبي وترتيبها",
moveUp: "نقل لأعلى",
moveDown: "نقل لأسفل",
},
fonts: {
title: "الخطوط",
systemDefault: "الافتراضي للنظام",
+7
View File
@@ -1108,6 +1108,7 @@ export const en = {
},
sections: {
sessions: "History",
search: "Search",
schedules: "Schedules",
},
worktreeSetup: {
@@ -2195,6 +2196,12 @@ export const en = {
title: "Chat outline",
description: "Show an outline for jumping between prompts",
},
sidebar: {
title: "Sidebar",
description: "Choose which items appear at the top of the sidebar and in what order",
moveUp: "Move up",
moveDown: "Move down",
},
fonts: {
title: "Fonts",
systemDefault: "System default",
+8
View File
@@ -1133,6 +1133,7 @@ export const es: TranslationResources = {
},
sections: {
sessions: "Historial",
search: "Buscar",
schedules: "Horarios",
},
worktreeSetup: {
@@ -2146,6 +2147,13 @@ export const es: TranslationResources = {
title: "Esquema del chat",
description: "Muestra un esquema para saltar entre instrucciones",
},
sidebar: {
title: "Barra lateral",
description:
"Elige qué elementos aparecen en la parte superior de la barra lateral y en qué orden",
moveUp: "Mover hacia arriba",
moveDown: "Mover hacia abajo",
},
fonts: {
title: "Fuentes",
systemDefault: "Valor predeterminado del sistema",
+7
View File
@@ -1133,6 +1133,7 @@ export const fr: TranslationResources = {
},
sections: {
sessions: "Historique",
search: "Rechercher",
schedules: "Planifications",
},
worktreeSetup: {
@@ -2150,6 +2151,12 @@ export const fr: TranslationResources = {
title: "Plan de la discussion",
description: "Afficher un plan pour passer d’une requête à l’autre",
},
sidebar: {
title: "Barre latérale",
description: "Choisissez les éléments affichés en haut de la barre latérale et leur ordre",
moveUp: "Déplacer vers le haut",
moveDown: "Déplacer vers le bas",
},
fonts: {
title: "Polices",
systemDefault: "Valeur par défaut du système",
+7
View File
@@ -1111,6 +1111,7 @@ export const ja: TranslationResources = {
},
sections: {
sessions: "履歴",
search: "検索",
schedules: "スケジュール",
},
worktreeSetup: {
@@ -2113,6 +2114,12 @@ export const ja: TranslationResources = {
title: "チャットのアウトライン",
description: "プロンプト間を移動するためのアウトラインを表示します",
},
sidebar: {
title: "サイドバー",
description: "サイドバー上部に表示する項目とその順序を選択します",
moveUp: "上に移動",
moveDown: "下に移動",
},
fonts: {
title: "フォント",
systemDefault: "システムデフォルト",
+7
View File
@@ -1106,6 +1106,7 @@ export const ko: TranslationResources = {
},
sections: {
sessions: "기록",
search: "검색",
schedules: "일정",
},
worktreeSetup: {
@@ -2107,6 +2108,12 @@ export const ko: TranslationResources = {
title: "채팅 개요",
description: "프롬프트 사이를 이동하기 위한 개요 표시",
},
sidebar: {
title: "사이드바",
description: "사이드바 상단에 표시할 항목과 순서를 선택하세요",
moveUp: "위로 이동",
moveDown: "아래로 이동",
},
fonts: {
title: "글꼴",
systemDefault: "시스템 기본값",
+7
View File
@@ -1124,6 +1124,7 @@ export const ptBR: TranslationResources = {
},
sections: {
sessions: "Histórico",
search: "Buscar",
schedules: "Agendamentos",
},
worktreeSetup: {
@@ -2130,6 +2131,12 @@ export const ptBR: TranslationResources = {
title: "Estrutura do chat",
description: "Mostrar uma estrutura para navegar entre prompts",
},
sidebar: {
title: "Barra lateral",
description: "Escolha quais itens aparecem no topo da barra lateral e em que ordem",
moveUp: "Mover para cima",
moveDown: "Mover para baixo",
},
fonts: {
title: "Fontes",
systemDefault: "Sistema padrão",
+8
View File
@@ -1115,6 +1115,7 @@ export const ru: TranslationResources = {
},
sections: {
sessions: "История",
search: "Поиск",
schedules: "Расписания",
},
worktreeSetup: {
@@ -2132,6 +2133,13 @@ export const ru: TranslationResources = {
title: "Оглавление чата",
description: "Показывать оглавление для перехода между запросами",
},
sidebar: {
title: "Боковая панель",
description:
"Выберите, какие элементы отображаются вверху боковой панели и в каком порядке",
moveUp: "Переместить вверх",
moveDown: "Переместить вниз",
},
fonts: {
title: "Шрифты",
systemDefault: "Системный шрифт",
+7
View File
@@ -1091,6 +1091,7 @@ export const zhCN: TranslationResources = {
},
sections: {
sessions: "历史",
search: "搜索",
schedules: "计划",
},
worktreeSetup: {
@@ -2072,6 +2073,12 @@ export const zhCN: TranslationResources = {
title: "聊天大纲",
description: "显示用于在提示词之间跳转的大纲",
},
sidebar: {
title: "侧边栏",
description: "选择侧边栏顶部显示的项目及其顺序",
moveUp: "上移",
moveDown: "下移",
},
fonts: {
title: "字体",
systemDefault: "系统默认",
-1
View File
@@ -1,6 +1,5 @@
export { PluginCatalogSync } from "./catalog-sync";
export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
export { PluginSidebarItems } from "./sidebar-items";
export { PluginSurfaceScreen } from "./surface-screen";
export { usePluginAttachmentPicker } from "./attachments/picker";
export { PluginResourceAttachmentPill } from "./attachments/pill";
+5 -30
View File
@@ -1,18 +1,13 @@
import { router, usePathname } from "expo-router";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { resolvePluginIcon } from "./icons";
import { useInstalledPlugins } from "./registry";
import { buildPluginSurfaceRoute, hostIdFromPathname } from "./routes";
import {
getPreferredPluginContributionHost,
rememberPluginContributionHost,
} from "./contribution-host";
import {
groupPluginSidebarContributions,
type PluginSidebarGroup,
type PluginSidebarTarget,
} from "./sidebar-groups";
import { type PluginSidebarGroup, type PluginSidebarTarget } from "./sidebar-groups";
function selectTarget(
group: PluginSidebarGroup,
@@ -25,35 +20,15 @@ function selectTarget(
return remembered ?? group.targets[0];
}
export function PluginSidebarItems({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const plugins = useInstalledPlugins();
const pathname = usePathname();
const groups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
const currentHostId = hostIdFromPathname(pathname);
return groups.map((group) => (
<PluginSidebarItemRow
key={group.key}
group={group}
currentHostId={currentHostId}
pathname={pathname}
onBeforeNavigate={onBeforeNavigate}
/>
));
}
function PluginSidebarItemRow({
export function PluginSidebarItemRow({
group,
currentHostId,
pathname,
onBeforeNavigate,
}: {
group: PluginSidebarGroup;
currentHostId: string | null;
pathname: string;
onBeforeNavigate?: () => void;
}) {
const target = selectTarget(group, currentHostId);
const pathname = usePathname();
const target = selectTarget(group, hostIdFromPathname(pathname));
const route = buildPluginSurfaceRoute(target.plugin.serverId, group.pluginId, {
kind: "sidebar",
id: group.contributionId,
@@ -46,6 +46,7 @@ import { isNative } from "@/constants/platform";
import type { PluginThemeOption } from "@/plugins/themes";
import { settingsStyles } from "@/styles/settings";
import { AppearancePreview } from "./appearance-preview";
import { SidebarNavSection } from "./sidebar-nav-section";
// ---------------------------------------------------------------------------
// Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no
@@ -709,6 +710,7 @@ export function AppearanceSection() {
) : null}
</View>
</SettingsSection>
<SidebarNavSection />
<SettingsSection title={t("settings.appearance.fonts.title")}>
<View style={settingsStyles.card}>
{showInterfaceFontFamilyRow ? (
@@ -0,0 +1,178 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import {
ArrowDown,
ArrowUp,
CalendarClock,
History,
Plus,
Search,
type LucideIcon,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Shortcut } from "@/components/ui/shortcut";
import { Switch } from "@/components/ui/switch";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { resolvePluginIcon } from "@/plugins/icons";
import { SettingsSection } from "@/screens/settings/settings-section";
import {
builtinSidebarNavLabelKey,
builtinSidebarNavShortcutAction,
type BuiltinSidebarNavId,
type SidebarNavItem,
} from "@/sidebar-nav/model";
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedArrowDown = withUnistyles(ArrowDown);
const moveUpIcon = <ThemedArrowUp size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const moveDownIcon = <ThemedArrowDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const BUILTIN_ICONS: Record<BuiltinSidebarNavId, LucideIcon> = {
"new-workspace": Plus,
history: History,
search: Search,
schedules: CalendarClock,
};
function NavIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
return <Icon size={ICON_SIZE.md} color={color} />;
}
const ThemedNavIcon = withUnistyles(NavIcon);
function navItemIcon(item: SidebarNavItem): LucideIcon {
return item.kind === "builtin" ? BUILTIN_ICONS[item.id] : resolvePluginIcon(item.group.icon);
}
function navItemLabel(t: TFunction, item: SidebarNavItem): string {
return item.kind === "builtin" ? t(builtinSidebarNavLabelKey(item.id)) : item.group.title;
}
/** Own component so the row can stay hook-free about which items have a shortcut. */
function NavItemShortcut({ item }: { item: SidebarNavItem }): ReactElement | null {
const chord = useShortcutKeys(
item.kind === "builtin" ? builtinSidebarNavShortcutAction(item.id) : null,
);
return chord ? <Shortcut chord={chord} /> : null;
}
interface SidebarNavRowProps {
item: SidebarNavItem;
isFirst: boolean;
isLast: boolean;
onMove: (key: string, direction: "up" | "down") => void;
onSetVisible: (key: string, visible: boolean) => void;
}
function SidebarNavRow({
item,
isFirst,
isLast,
onMove,
onSetVisible,
}: SidebarNavRowProps): ReactElement {
const { t } = useTranslation();
const label = navItemLabel(t, item);
const handleMoveUp = useCallback(() => onMove(item.key, "up"), [item.key, onMove]);
const handleMoveDown = useCallback(() => onMove(item.key, "down"), [item.key, onMove]);
const handleVisibleChange = useCallback(
(visible: boolean) => onSetVisible(item.key, visible),
[item.key, onSetVisible],
);
const rowStyle = useMemo(
() => [settingsStyles.row, isFirst ? null : settingsStyles.rowBorder, styles.row],
[isFirst],
);
return (
<View style={rowStyle} testID={`sidebar-nav-item-${item.key}`}>
<View style={styles.rowMain}>
<ThemedNavIcon Icon={navItemIcon(item)} uniProps={mutedColorMapping} />
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{label}
</Text>
<NavItemShortcut item={item} />
</View>
<View style={styles.rowActions}>
<Button
variant="ghost"
size="sm"
leftIcon={moveUpIcon}
onPress={handleMoveUp}
disabled={isFirst}
accessibilityLabel={t("settings.appearance.sidebar.moveUp")}
testID={`sidebar-nav-move-up-${item.key}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={moveDownIcon}
onPress={handleMoveDown}
disabled={isLast}
accessibilityLabel={t("settings.appearance.sidebar.moveDown")}
testID={`sidebar-nav-move-down-${item.key}`}
/>
<Switch
value={item.visible}
onValueChange={handleVisibleChange}
accessibilityLabel={label}
testID={`sidebar-nav-toggle-${item.key}`}
/>
</View>
</View>
);
}
export function SidebarNavSection(): ReactElement {
const { t } = useTranslation();
const { items, setVisible, move } = useSidebarNavItems();
return (
<SettingsSection
title={t("settings.appearance.sidebar.title")}
info={t("settings.appearance.sidebar.description")}
testID="sidebar-nav-section"
>
<View style={settingsStyles.card}>
{items.map((item, index) => (
<SidebarNavRow
key={item.key}
item={item}
isFirst={index === 0}
isLast={index === items.length - 1}
onMove={move}
onSetVisible={setVisible}
/>
))}
</View>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
gap: theme.spacing[2],
},
rowMain: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
rowActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
}));
@@ -110,10 +110,10 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) {
return (
<SettingsSection
title={t("settings.metadataGeneration.title")}
info={t("settings.metadataGeneration.description")}
trailing={docsLink}
testID="metadata-generation-settings"
>
<Text style={styles.description}>{t("settings.metadataGeneration.description")}</Text>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
@@ -164,12 +164,6 @@ export function MetadataGenerationPage({ serverId }: { serverId: string }) {
}
const styles = StyleSheet.create((theme) => ({
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.45,
marginHorizontal: theme.spacing[1],
},
loading: {
alignItems: "center",
justifyContent: "center",
@@ -166,6 +166,10 @@ vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
}));
vi.mock("@/screens/settings/settings-info-tip", () => ({
SettingsInfoTip: () => null,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
@@ -1,9 +1,7 @@
import { useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { useTranslation } from "react-i18next";
import { Info } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SettingsInfoTip } from "@/screens/settings/settings-info-tip";
interface SettingsGroupProps {
title: string;
@@ -28,8 +26,6 @@ export function SettingsGroup({
style,
children,
}: SettingsGroupProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const groupStyle = useMemo(() => [styles.group, style], [style]);
return (
<View style={groupStyle} testID={testID}>
@@ -37,22 +33,11 @@ export function SettingsGroup({
<View style={styles.titleRow}>
<Text style={styles.title}>{title}</Text>
{info ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("settings.groupInfo", { title })}
testID={testID ? `${testID}-info` : undefined}
hitSlop={8}
style={styles.infoButton}
>
<Info size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="start" offset={8}>
<Text style={styles.tooltipText}>{info}</Text>
</TooltipContent>
</Tooltip>
<SettingsInfoTip
title={title}
info={info}
testID={testID ? `${testID}-info` : undefined}
/>
) : null}
</View>
{trailing}
@@ -83,14 +68,4 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
infoButton: {
padding: theme.spacing[1],
marginLeft: -theme.spacing[1],
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
maxWidth: 280,
lineHeight: theme.fontSize.base * 1.4,
},
}));
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { useTranslation } from "react-i18next";
import { Info } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ICON_SIZE, type Theme } from "@/styles/theme";
const ThemedInfo = withUnistyles(Info);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
interface SettingsInfoTipProps {
/** Header the tip belongs to; used for the accessibility label. */
title: string;
info: ReactNode;
testID?: string;
}
/**
* The one way a settings group or section explains itself: an info icon on the
* header that opens a tooltip. Never a paragraph under the header — see
* docs/design.md §7.
*/
export function SettingsInfoTip({ title, info, testID }: SettingsInfoTipProps) {
const { t } = useTranslation();
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("settings.groupInfo", { title })}
testID={testID}
hitSlop={8}
style={styles.button}
>
<ThemedInfo size={ICON_SIZE.sm} uniProps={mutedIconMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="start" offset={8}>
<Text style={styles.tooltipText}>{info}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
button: {
padding: theme.spacing[1],
marginLeft: -theme.spacing[1],
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
maxWidth: 280,
lineHeight: theme.fontSize.base * 1.4,
},
}));
@@ -1,10 +1,16 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SettingsInfoTip } from "@/screens/settings/settings-info-tip";
import { settingsStyles } from "@/styles/settings";
interface SettingsSectionProps {
title: string;
/**
* What this section is for. Renders as an info tooltip on the header; a
* paragraph between the header and the card is wrong (docs/design.md §7).
*/
info?: ReactNode;
trailing?: ReactNode;
testID?: string;
style?: StyleProp<ViewStyle>;
@@ -23,6 +29,7 @@ interface SettingsSectionProps {
*/
export function SettingsSection({
title,
info,
trailing,
testID,
style,
@@ -36,7 +43,16 @@ export function SettingsSection({
return (
<View style={sectionStyle} testID={testID}>
<View style={styles.header}>
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
<View style={styles.titleRow}>
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
{info ? (
<SettingsInfoTip
title={title}
info={info}
testID={testID ? `${testID}-info` : undefined}
/>
) : null}
</View>
{trailing}
</View>
<View style={styles.content}>{children}</View>
@@ -53,6 +69,11 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[3],
marginLeft: theme.spacing[1],
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
content: {
gap: theme.spacing[3],
},
+243
View File
@@ -0,0 +1,243 @@
import { describe, expect, it } from "vitest";
import type { PluginSidebarGroup } from "@/plugins/sidebar-groups";
import {
builtinSidebarNavShortcutAction,
moveSidebarNavItem,
pluginSidebarNavKey,
resolveSidebarNavItems,
setSidebarNavItemVisible,
type SidebarNavItem,
type SidebarNavPreference,
} from "./model";
function group(pluginId: string, contributionId: string): PluginSidebarGroup {
return {
key: `${pluginId}/sidebar/${contributionId}`,
pluginId,
contributionId,
title: contributionId,
icon: "puzzle",
targets: [],
};
}
const kanban = group("kanban", "board");
const notes = group("notes", "inbox");
const kanbanKey = pluginSidebarNavKey(kanban);
const notesKey = pluginSidebarNavKey(notes);
function summarize(items: readonly SidebarNavItem[]): SidebarNavPreference[] {
return items.map(({ key, visible }) => ({ key, visible }));
}
describe("resolveSidebarNavItems", () => {
it("yields builtins then plugins, all visible, when nothing is stored", () => {
const items = resolveSidebarNavItems({ pluginGroups: [kanban, notes], preferences: [] });
expect(summarize(items)).toEqual([
{ key: "new-workspace", visible: true },
{ key: "history", visible: true },
{ key: "search", visible: true },
{ key: "schedules", visible: true },
{ key: kanbanKey, visible: true },
{ key: notesKey, visible: true },
]);
expect(items[4]).toEqual({ kind: "plugin", key: kanbanKey, group: kanban, visible: true });
expect(items[0]).toEqual({
kind: "builtin",
key: "new-workspace",
id: "new-workspace",
visible: true,
});
});
it("keeps the stored order and appends newly available items as visible", () => {
const items = resolveSidebarNavItems({
pluginGroups: [notes, kanban],
preferences: [
{ key: kanbanKey, visible: false },
{ key: "schedules", visible: true },
{ key: "new-workspace", visible: false },
],
});
expect(summarize(items)).toEqual([
{ key: kanbanKey, visible: false },
{ key: "schedules", visible: true },
{ key: "new-workspace", visible: false },
{ key: "history", visible: true },
{ key: "search", visible: true },
{ key: notesKey, visible: true },
]);
});
it("skips keys that are unknown or not currently available", () => {
const items = resolveSidebarNavItems({
pluginGroups: [],
preferences: [
{ key: notesKey, visible: false },
{ key: "bogus", visible: true },
{ key: "history", visible: true },
],
});
expect(items.map((item) => item.key)).toEqual([
"history",
"new-workspace",
"search",
"schedules",
]);
});
it("lets the first of duplicate keys win", () => {
const items = resolveSidebarNavItems({
pluginGroups: [],
preferences: [
{ key: "history", visible: false },
{ key: "history", visible: true },
],
});
expect(summarize(items)).toEqual([
{ key: "history", visible: false },
{ key: "new-workspace", visible: true },
{ key: "search", visible: true },
{ key: "schedules", visible: true },
]);
});
});
describe("setSidebarNavItemVisible", () => {
it("toggles one item and writes the full resolved order", () => {
const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] });
const next = setSidebarNavItemVisible({ items, key: "search", visible: false, previous: [] });
expect(next).toEqual([
{ key: "new-workspace", visible: true },
{ key: "history", visible: true },
{ key: "search", visible: false },
{ key: "schedules", visible: true },
{ key: kanbanKey, visible: true },
]);
});
it("carries preferences for unavailable plugins through an unrelated edit", () => {
const previous: SidebarNavPreference[] = [
{ key: notesKey, visible: false },
{ key: "history", visible: true },
];
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous });
const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous });
expect(next).toEqual([
{ key: notesKey, visible: false },
{ key: "history", visible: false },
{ key: "new-workspace", visible: true },
{ key: "search", visible: true },
{ key: "schedules", visible: true },
]);
});
it("keeps an unavailable plugin in its configured position", () => {
const previous: SidebarNavPreference[] = [
{ key: "new-workspace", visible: true },
{ key: notesKey, visible: false },
{ key: "history", visible: true },
{ key: "search", visible: true },
{ key: "schedules", visible: true },
];
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: previous });
const next = setSidebarNavItemVisible({ items, key: "history", visible: false, previous });
expect(next).toEqual([
{ key: "new-workspace", visible: true },
{ key: notesKey, visible: false },
{ key: "history", visible: false },
{ key: "search", visible: true },
{ key: "schedules", visible: true },
]);
expect(summarize(resolveSidebarNavItems({ pluginGroups: [notes], preferences: next }))).toEqual(
next,
);
});
it("returns the normalized list unchanged for an unknown key", () => {
const items = resolveSidebarNavItems({ pluginGroups: [], preferences: [] });
const next = setSidebarNavItemVisible({ items, key: "bogus", visible: false, previous: [] });
expect(next).toEqual(summarize(items));
});
});
describe("moveSidebarNavItem", () => {
const items = resolveSidebarNavItems({ pluginGroups: [kanban], preferences: [] });
it("moves an item up", () => {
const next = moveSidebarNavItem({ items, key: "search", direction: "up", previous: [] });
expect(next.map((preference) => preference.key)).toEqual([
"new-workspace",
"search",
"history",
"schedules",
kanbanKey,
]);
});
it("moves an item down", () => {
const next = moveSidebarNavItem({ items, key: "schedules", direction: "down", previous: [] });
expect(next.map((preference) => preference.key)).toEqual([
"new-workspace",
"history",
"search",
kanbanKey,
"schedules",
]);
});
it("leaves the order alone at the boundaries", () => {
const first = moveSidebarNavItem({
items,
key: "new-workspace",
direction: "up",
previous: [],
});
const last = moveSidebarNavItem({ items, key: kanbanKey, direction: "down", previous: [] });
expect(first).toEqual(summarize(items));
expect(last).toEqual(summarize(items));
});
it("returns the normalized list unchanged for an unknown key", () => {
const next = moveSidebarNavItem({ items, key: "bogus", direction: "down", previous: [] });
expect(next).toEqual(summarize(items));
});
it("drops duplicate carried-over keys", () => {
const previous: SidebarNavPreference[] = [
{ key: notesKey, visible: false },
{ key: notesKey, visible: true },
];
const next = moveSidebarNavItem({ items, key: "history", direction: "up", previous });
expect(next.filter((preference) => preference.key === notesKey)).toEqual([
{ key: notesKey, visible: false },
]);
});
});
describe("builtinSidebarNavShortcutAction", () => {
it("maps only the builtins that have a keyboard shortcut", () => {
expect(builtinSidebarNavShortcutAction("new-workspace")).toBe("new-workspace");
expect(builtinSidebarNavShortcutAction("search")).toBe("toggle-command-center");
expect(builtinSidebarNavShortcutAction("history")).toBeNull();
expect(builtinSidebarNavShortcutAction("schedules")).toBeNull();
});
});
+161
View File
@@ -0,0 +1,161 @@
import type { PluginSidebarGroup } from "@/plugins/sidebar-groups";
export const BUILTIN_SIDEBAR_NAV_IDS = ["new-workspace", "history", "search", "schedules"] as const;
export type BuiltinSidebarNavId = (typeof BUILTIN_SIDEBAR_NAV_IDS)[number];
/** Persisted shape. Array order is the display order. */
export interface SidebarNavPreference {
key: string;
visible: boolean;
}
export interface BuiltinSidebarNavItem {
kind: "builtin";
key: BuiltinSidebarNavId;
id: BuiltinSidebarNavId;
visible: boolean;
}
export interface PluginSidebarNavItem {
kind: "plugin";
key: string;
group: PluginSidebarGroup;
visible: boolean;
}
export type SidebarNavItem = BuiltinSidebarNavItem | PluginSidebarNavItem;
const BUILTIN_LABEL_KEYS: Record<BuiltinSidebarNavId, string> = {
"new-workspace": "sidebar.actions.newWorkspace",
history: "sidebar.sections.sessions",
search: "sidebar.sections.search",
schedules: "sidebar.sections.schedules",
};
export function builtinSidebarNavLabelKey(id: BuiltinSidebarNavId): string {
return BUILTIN_LABEL_KEYS[id];
}
/**
* Shortcut action ids (`resolveShortcutKeysForAction`) for the builtins that have one.
* Both the sidebar row and the Appearance settings row read the badge from here so the
* two never disagree about which shortcut belongs to which item.
*/
const BUILTIN_SHORTCUT_ACTIONS: Record<BuiltinSidebarNavId, string | null> = {
"new-workspace": "new-workspace",
history: null,
search: "toggle-command-center",
schedules: null,
};
export function builtinSidebarNavShortcutAction(id: BuiltinSidebarNavId): string | null {
return BUILTIN_SHORTCUT_ACTIONS[id];
}
export function pluginSidebarNavKey(
group: Pick<PluginSidebarGroup, "pluginId" | "contributionId">,
): string {
return `plugin:${group.pluginId}:${group.contributionId}`;
}
function isBuiltinSidebarNavId(key: string): key is BuiltinSidebarNavId {
return (BUILTIN_SIDEBAR_NAV_IDS as readonly string[]).includes(key);
}
export function resolveSidebarNavItems(input: {
pluginGroups: readonly PluginSidebarGroup[];
preferences: readonly SidebarNavPreference[];
}): SidebarNavItem[] {
const groupsByKey = new Map(
input.pluginGroups.map((group) => [pluginSidebarNavKey(group), group] as const),
);
const items: SidebarNavItem[] = [];
const placed = new Set<string>();
for (const preference of input.preferences) {
if (placed.has(preference.key)) continue;
const group = groupsByKey.get(preference.key);
if (group) {
placed.add(preference.key);
items.push({ kind: "plugin", key: preference.key, group, visible: preference.visible });
} else if (isBuiltinSidebarNavId(preference.key)) {
placed.add(preference.key);
items.push({
kind: "builtin",
key: preference.key,
id: preference.key,
visible: preference.visible,
});
}
}
for (const id of BUILTIN_SIDEBAR_NAV_IDS) {
if (placed.has(id)) continue;
items.push({ kind: "builtin", key: id, id, visible: true });
}
for (const [key, group] of groupsByKey) {
if (placed.has(key)) continue;
items.push({ kind: "plugin", key, group, visible: true });
}
return items;
}
/**
* Resolved items lead; entries for keys that are not currently available (a plugin that is
* disconnected right now) follow so an unrelated edit does not erase them.
*/
function toPreferences(
items: readonly SidebarNavItem[],
previous: readonly SidebarNavPreference[],
): SidebarNavPreference[] {
const remaining = items.map(({ key, visible }) => ({ key, visible }));
const availableKeys = new Set(remaining.map((preference) => preference.key));
const preferences: SidebarNavPreference[] = [];
const seenPrevious = new Set<string>();
for (const preference of previous) {
if (seenPrevious.has(preference.key)) continue;
seenPrevious.add(preference.key);
if (availableKeys.has(preference.key)) {
const next = remaining.shift();
if (next) preferences.push(next);
continue;
}
preferences.push({ key: preference.key, visible: preference.visible });
}
preferences.push(...remaining);
return preferences;
}
export function setSidebarNavItemVisible(input: {
items: readonly SidebarNavItem[];
key: string;
visible: boolean;
previous: readonly SidebarNavPreference[];
}): SidebarNavPreference[] {
const items = input.items.map((item) =>
item.key === input.key ? { ...item, visible: input.visible } : item,
);
return toPreferences(items, input.previous);
}
export function moveSidebarNavItem(input: {
items: readonly SidebarNavItem[];
key: string;
direction: "up" | "down";
previous: readonly SidebarNavPreference[];
}): SidebarNavPreference[] {
const from = input.items.findIndex((item) => item.key === input.key);
const to = input.direction === "up" ? from - 1 : from + 1;
const canMove = from !== -1 && to >= 0 && to < input.items.length;
if (!canMove) {
return toPreferences(input.items, input.previous);
}
const items = [...input.items];
const [moved] = items.splice(from, 1);
items.splice(to, 0, moved);
return toPreferences(items, input.previous);
}
@@ -0,0 +1,71 @@
import { useCallback, useMemo } from "react";
import { useAppSettings } from "@/hooks/use-settings";
import { useInstalledPlugins } from "@/plugins/registry";
import { groupPluginSidebarContributions } from "@/plugins/sidebar-groups";
import {
moveSidebarNavItem,
resolveSidebarNavItems,
setSidebarNavItemVisible,
type SidebarNavItem,
} from "./model";
export interface UseSidebarNavItemsReturn {
/** Every top-level item in display order, hidden ones included. */
items: SidebarNavItem[];
setVisible: (key: string, visible: boolean) => void;
move: (key: string, direction: "up" | "down") => void;
}
export function useSidebarNavItems(): UseSidebarNavItemsReturn {
const plugins = useInstalledPlugins();
const { settings, updateSettings } = useAppSettings();
const preferences = settings.sidebarNavItems;
const pluginGroups = useMemo(() => groupPluginSidebarContributions(plugins), [plugins]);
const items = useMemo(
() =>
resolveSidebarNavItems({
pluginGroups,
preferences,
}),
[pluginGroups, preferences],
);
const setVisible = useCallback(
(key: string, visible: boolean) => {
void updateSettings((current) => {
const previous = current.sidebarNavItems;
const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous });
return {
sidebarNavItems: setSidebarNavItemVisible({
items: currentItems,
key,
visible,
previous,
}),
};
});
},
[pluginGroups, updateSettings],
);
const move = useCallback(
(key: string, direction: "up" | "down") => {
void updateSettings((current) => {
const previous = current.sidebarNavItems;
const currentItems = resolveSidebarNavItems({ pluginGroups, preferences: previous });
return {
sidebarNavItems: moveSidebarNavItem({
items: currentItems,
key,
direction,
previous,
}),
};
});
},
[pluginGroups, updateSettings],
);
return { items, setVisible, move };
}
+1 -1
View File
@@ -822,7 +822,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
await originalDeck.getByRole("button", { name: "Cancel element selector" }).click();
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
await page.getByRole("button", { name: "Open command center" }).click();
await page.getByTestId("sidebar-search").click();
await page.getByTestId("command-center-input").fill("Split pane right");
await page.getByText("Split pane right", { exact: true }).click();
assert(