Add workspace title display preference

This commit is contained in:
Mohamed Boudra
2026-06-20 19:44:30 +07:00
parent 0718e7c914
commit 68bbc75df1
12 changed files with 155 additions and 17 deletions
+1 -1
View File
@@ -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 });
}
+1 -1
View File
@@ -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();
@@ -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,
@@ -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
+2 -2
View File
@@ -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 }) {
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarGroupingSelector serverId={serverId} />
<SidebarDisplayPreferencesMenu serverId={serverId} />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
@@ -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 extends string> {
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
<DropdownMenuTrigger
style={triggerStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Sidebar grouping"
testID="sidebar-grouping-selector"
accessibilityLabel="Display preferences"
testID="sidebar-display-preferences-menu"
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={180} testID="sidebar-grouping-menu">
<DropdownMenuContent align="end" width={180} testID="sidebar-display-preferences-content">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
{GROUP_MODE_ITEMS.map((item) => (
<GroupModeMenuItem
<DisplayPreferenceMenuItem
key={item.value}
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelect}
/>
))}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
</View>
{WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
key={item.value}
item={item}
isSelected={workspaceTitleSource === item.value}
testIDPrefix="sidebar-workspace-title-source"
onSelect={handleWorkspaceTitleSourceSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function GroupModeMenuItem({
function DisplayPreferenceMenuItem<Value extends string>({
item,
isSelected,
testIDPrefix,
onSelect,
}: {
item: { value: SidebarGroupMode; label: string };
item: DisplayPreferenceOption<Value>;
isSelected: boolean;
onSelect: (mode: SidebarGroupMode) => void;
testIDPrefix: string;
onSelect: (value: Value) => void;
}) {
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
return (
<DropdownMenuItem
testID={`sidebar-grouping-${item.value}`}
testID={`${testIDPrefix}-${item.value}`}
selected={isSelected}
onSelect={handleSelect}
>
{item.label}
<Text style={styles.optionLabel}>{item.label}</Text>
</DropdownMenuItem>
);
}
@@ -110,4 +149,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
optionLabel: {
fontSize: theme.fontSize.sm,
},
}));
@@ -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
<View style={styles.workspaceTitleRow}>
<View style={styles.workspaceTitleLeft}>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
{workspaceLabel}
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
@@ -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");
});
});
@@ -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<SidebarWorkspaceEntry, "name" | "currentBranch">;
workspaceTitleSource: WorkspaceTitleSource;
}): string {
if (input.workspaceTitleSource === "branch") {
return input.workspace.currentBranch ?? input.workspace.name;
}
return input.workspace.name;
}
@@ -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<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -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({
@@ -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<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["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<AppSettings>): Partial<AppSettings> {
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;
}