diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index dd8a2f9aa..b189bf6e8 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -52,7 +52,7 @@ interface WorkspaceStatusGroupEvent { } async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) { - await page.getByTestId("sidebar-grouping-selector").click(); + await page.getByTestId("sidebar-display-preferences-menu").click(); await page.getByTestId("sidebar-grouping-status").click(); await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 }); } diff --git a/packages/app/e2e/sidebar-model-b.spec.ts b/packages/app/e2e/sidebar-model-b.spec.ts index 0b6bd0c1e..4363dd62e 100644 --- a/packages/app/e2e/sidebar-model-b.spec.ts +++ b/packages/app/e2e/sidebar-model-b.spec.ts @@ -127,7 +127,7 @@ test.describe("Model B sidebar shape", () => { await expect(workspaceRow(page, idleProject.workspaceId)).toBeVisible({ timeout: 30_000 }); // Switch to status grouping. - await page.getByTestId("sidebar-grouping-selector").click(); + await page.getByTestId("sidebar-display-preferences-menu").click(); await page.getByTestId("sidebar-grouping-status").click(); const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first(); diff --git a/packages/app/e2e/workspace-model-regressions.spec.ts b/packages/app/e2e/workspace-model-regressions.spec.ts index aaac489e6..20501ed15 100644 --- a/packages/app/e2e/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/workspace-model-regressions.spec.ts @@ -72,7 +72,7 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi } async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) { - await page.getByTestId("sidebar-grouping-selector").click(); + await page.getByTestId("sidebar-display-preferences-menu").click(); await page.getByTestId("sidebar-grouping-status").click(); await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({ timeout: 30_000, diff --git a/packages/app/e2e/workspace-model-restart.spec.ts b/packages/app/e2e/workspace-model-restart.spec.ts index a46c9569d..82da902e7 100644 --- a/packages/app/e2e/workspace-model-restart.spec.ts +++ b/packages/app/e2e/workspace-model-restart.spec.ts @@ -390,7 +390,7 @@ async function expectWorkspaceRowInStatusBucket( page: Page, input: { serverId: string; workspaceId: string; bucket: string }, ) { - await page.getByTestId("sidebar-grouping-selector").click(); + await page.getByTestId("sidebar-display-preferences-menu").click(); await page.getByTestId("sidebar-grouping-status").click(); await expect( page diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 033639b06..759f0f991 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -35,7 +35,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row"; -import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector"; +import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -1096,7 +1096,7 @@ function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) { - + diff --git a/packages/app/src/components/sidebar/sidebar-grouping-selector.tsx b/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx similarity index 60% rename from packages/app/src/components/sidebar/sidebar-grouping-selector.tsx rename to packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx index 216a01109..7e822e51e 100644 --- a/packages/app/src/components/sidebar/sidebar-grouping-selector.tsx +++ b/packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx @@ -8,9 +8,11 @@ import { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store"; import { isWeb as platformIsWeb } from "@/constants/platform"; +import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings"; const ThemedSettings2 = withUnistyles(Settings2); const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); @@ -20,11 +22,25 @@ const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [ { value: "status", label: "Status" }, ]; -export function SidebarGroupingSelector({ serverId }: { serverId: string | null }) { +const WORKSPACE_TITLE_SOURCE_ITEMS: Array<{ value: WorkspaceTitleSource; label: string }> = [ + { value: "title", label: "Title" }, + { value: "branch", label: "Branch name" }, +]; + +interface DisplayPreferenceOption { + value: Value; + label: string; +} + +export function SidebarDisplayPreferencesMenu({ serverId }: { serverId: string | null }) { const groupMode = useSidebarViewStore((state) => serverId ? state.getGroupMode(serverId) : "project", ); const setGroupMode = useSidebarViewStore((state) => state.setGroupMode); + const { + settings: { workspaceTitleSource }, + updateSettings, + } = useAppSettings(); const handleSelect = useCallback( (mode: SidebarGroupMode) => { @@ -34,6 +50,13 @@ export function SidebarGroupingSelector({ serverId }: { serverId: string | null [serverId, setGroupMode], ); + const handleWorkspaceTitleSourceSelect = useCallback( + (source: WorkspaceTitleSource) => { + void updateSettings({ workspaceTitleSource: source }); + }, + [updateSettings], + ); + const triggerStyle = useCallback( ({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.trigger, @@ -47,45 +70,61 @@ export function SidebarGroupingSelector({ serverId }: { serverId: string | null - + Group by {GROUP_MODE_ITEMS.map((item) => ( - ))} + + + Workspace title + + {WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => ( + + ))} ); } -function GroupModeMenuItem({ +function DisplayPreferenceMenuItem({ item, isSelected, + testIDPrefix, onSelect, }: { - item: { value: SidebarGroupMode; label: string }; + item: DisplayPreferenceOption; isSelected: boolean; - onSelect: (mode: SidebarGroupMode) => void; + testIDPrefix: string; + onSelect: (value: Value) => void; }) { const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]); return ( - {item.label} + {item.label} ); } @@ -110,4 +149,7 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, }, + optionLabel: { + fontSize: theme.fontSize.sm, + }, })); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index ae8db5def..8c172d104 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -22,12 +22,14 @@ import { GitHubIcon } from "@/components/icons/github-icon"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { SyncedLoader } from "@/components/synced-loader"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { useAppSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { PrHint } from "@/git/use-pr-status-query"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { openExternalUrl } from "@/utils/open-external-url"; +import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; @@ -110,6 +112,10 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte showShortcutBadge?: boolean; children?: ReactNode; }) { + const { + settings: { workspaceTitleSource }, + } = useAppSettings(); + const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource }); const workspaceBranchTextStyle = useMemo( () => [ styles.workspaceBranchText, @@ -132,7 +138,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte - {workspace.name} + {workspaceLabel} {scriptIconKind ? : null} diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts new file mode 100644 index 000000000..9b91dba9d --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; + +describe("resolveSidebarWorkspacePrimaryLabel", () => { + it("uses the workspace name in title mode", () => { + const label = resolveSidebarWorkspacePrimaryLabel({ + workspace: { name: "Investigate search", currentBranch: "fix/search" }, + workspaceTitleSource: "title", + }); + + expect(label).toBe("Investigate search"); + }); + + it("uses the branch name in branch mode", () => { + const label = resolveSidebarWorkspacePrimaryLabel({ + workspace: { name: "Investigate search", currentBranch: "fix/search" }, + workspaceTitleSource: "branch", + }); + + expect(label).toBe("fix/search"); + }); + + it("falls back to the workspace name in branch mode without a branch", () => { + const label = resolveSidebarWorkspacePrimaryLabel({ + workspace: { name: "Local folder", currentBranch: null }, + workspaceTitleSource: "branch", + }); + + expect(label).toBe("Local folder"); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-title.ts b/packages/app/src/components/sidebar/sidebar-workspace-title.ts new file mode 100644 index 000000000..1ad4e6616 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-workspace-title.ts @@ -0,0 +1,12 @@ +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import type { WorkspaceTitleSource } from "@/hooks/use-settings"; + +export function resolveSidebarWorkspacePrimaryLabel(input: { + workspace: Pick; + workspaceTitleSource: WorkspaceTitleSource; +}): string { + if (input.workspaceTitleSource === "branch") { + return input.workspace.currentBranch ?? input.workspace.name; + } + return input.workspace.name; +} diff --git a/packages/app/src/hooks/use-settings/index.ts b/packages/app/src/hooks/use-settings/index.ts index fb8be8f85..19c9cb9fe 100644 --- a/packages/app/src/hooks/use-settings/index.ts +++ b/packages/app/src/hooks/use-settings/index.ts @@ -38,6 +38,7 @@ import { type ServiceUrlBehavior, type Settings, type SettingsDeps, + type WorkspaceTitleSource, } from "./storage"; export { @@ -67,6 +68,7 @@ export type { ServiceUrlBehavior, Settings, SettingsDeps, + WorkspaceTitleSource, }; const productionDeps: SettingsDeps = { @@ -172,6 +174,9 @@ export function useSettings(): UseSettingsReturn { if (updates.syntaxTheme !== undefined) { appUpdates.syntaxTheme = updates.syntaxTheme; } + if (updates.workspaceTitleSource !== undefined) { + appUpdates.workspaceTitleSource = updates.workspaceTitleSource; + } const promises: Promise[] = []; if (Object.keys(appUpdates).length > 0) { promises.push(appSettings.updateSettings(appUpdates)); diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index 796a362dd..87c5e4321 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -61,6 +61,14 @@ describe("loadAppSettingsFromStorage", () => { expect(result.language).toBe("system"); }); + it("defaults workspace title source to title when storage is empty", async () => { + const deps = makeDeps(); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.workspaceTitleSource).toBe("title"); + }); + it("loads configured terminal scrollback lines from app settings", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ @@ -73,6 +81,30 @@ describe("loadAppSettingsFromStorage", () => { expect(result.terminalScrollbackLines).toBe(42_000); }); + it("loads configured workspace title source from app settings", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "branch" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.workspaceTitleSource).toBe("branch"); + }); + + it("drops an unknown workspace title source back to title", async () => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ workspaceTitleSource: "directory" }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.workspaceTitleSource).toBe("title"); + }); + it("normalizes terminal scrollback lines from storage", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index ff2c180d1..9bf897cef 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -11,9 +11,11 @@ const LEGACY_SETTINGS_KEY = "@paseo:settings"; export type SendBehavior = "interrupt" | "queue"; export type ReleaseChannel = "stable" | "beta"; export type ServiceUrlBehavior = "ask" | "in-app" | "external"; +export type WorkspaceTitleSource = "title" | "branch"; const VALID_THEMES = new Set([...Object.keys(THEME_TO_UNISTYLES), "auto"]); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); +const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000; export const MIN_TERMINAL_SCROLLBACK_LINES = 0; export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000; @@ -36,6 +38,7 @@ export interface AppSettings { uiFontSize: number; // clamped px, default 16 codeFontSize: number; // clamped px, default 12 syntaxTheme: SyntaxThemeId; // default "one" + workspaceTitleSource: WorkspaceTitleSource; } export interface Settings extends AppSettings { @@ -54,6 +57,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = { uiFontSize: DEFAULT_UI_FONT_SIZE, codeFontSize: DEFAULT_CODE_FONT_SIZE, syntaxTheme: "one", + workspaceTitleSource: "title", }; export const DEFAULT_APP_SETTINGS: Settings = { @@ -194,6 +198,12 @@ function pickAppSettings(stored: Partial): Partial { if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) { result.syntaxTheme = stored.syntaxTheme; } + if ( + typeof stored.workspaceTitleSource === "string" && + VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource) + ) { + result.workspaceTitleSource = stored.workspaceTitleSource; + } return result; }