mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
feat(settings): polish navigation and controls (#5459)
* feat(settings): polish navigation and controls * test(settings): update navigation assertions
This commit is contained in:
+1
-1
@@ -19,7 +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**.
|
||||
- **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 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/sidebar/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).
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function splitCurrentPanelRight(page: Page): Promise<void> {
|
||||
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
|
||||
const timelineUrl = page.url();
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsSection(page, "chat");
|
||||
await page.getByRole("switch", { name: "Chat outline" }).click();
|
||||
await page.goto(timelineUrl);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ async function openPluginScreen(page: Page) {
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
|
||||
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ interface SavedSettingsHostInput {
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Appearance",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
|
||||
@@ -57,7 +57,7 @@ export async function seedSidebarNavPreferences(
|
||||
|
||||
export async function openSidebarNavSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsSection(page, "sidebar");
|
||||
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -117,17 +117,16 @@ export function SettingsSelect<Value extends string>({
|
||||
disabled,
|
||||
...row
|
||||
}: SettingsSelectProps<Value>) {
|
||||
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
|
||||
return (
|
||||
<SettingsRow {...row}>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
disabled={disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={row.label}
|
||||
accessibilityLabel={`${row.label}: ${selectedLabel}`}
|
||||
>
|
||||
<Text style={styles.value}>
|
||||
{options.find((option) => option.value === value)?.label ?? value}
|
||||
</Text>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={220}>
|
||||
{options.map((option) => (
|
||||
@@ -203,6 +202,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
accessoryLabel: { flexShrink: 1, minWidth: 0 },
|
||||
control: { flexShrink: 1, maxWidth: "100%" },
|
||||
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||
input: { minWidth: 180 },
|
||||
}));
|
||||
|
||||
@@ -24,7 +24,7 @@ interface SidebarHeaderRowProps {
|
||||
/**
|
||||
* "header" (default): a sidebar-height row with its own bottom separator —
|
||||
* the lone header at the top of a sidebar (settings "Back to workspace").
|
||||
* "compact": a workspace-row-height row with no separator, for entries that
|
||||
* "compact": a row with no separator, for entries that
|
||||
* sit in a header group whose wrapper owns the single divider.
|
||||
*/
|
||||
variant?: SidebarHeaderRowVariant;
|
||||
@@ -52,10 +52,9 @@ export function SidebarHeaderRow({
|
||||
const buttonStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.button,
|
||||
variant === "compact" && styles.buttonCompact,
|
||||
(Boolean(hovered) || isActive) && styles.buttonHovered,
|
||||
],
|
||||
[isActive, variant],
|
||||
[isActive],
|
||||
);
|
||||
|
||||
const renderChildren = useCallback(
|
||||
@@ -64,7 +63,7 @@ export function SidebarHeaderRow({
|
||||
return (
|
||||
<>
|
||||
<ThemedIcon
|
||||
size={ICON_SIZE.sm}
|
||||
size={variant === "compact" ? ICON_SIZE.sm : ICON_SIZE.md}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
||||
@@ -74,7 +73,7 @@ export function SidebarHeaderRow({
|
||||
</>
|
||||
);
|
||||
},
|
||||
[ThemedIcon, isActive, label, shortcutKeys],
|
||||
[ThemedIcon, isActive, label, shortcutKeys, variant],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -129,21 +128,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
// Match the sidebar workspace-row shape (height, padding, radius) so the
|
||||
// compact header entries sit tight against the workspace list below.
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
// Compact header entries (New workspace / History) sit tighter than the
|
||||
// workspace-row shape the base button mirrors.
|
||||
buttonCompact: {
|
||||
minHeight: 32,
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||
// strip so the hover highlight clears the strip's bottom separator.
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
// Match the project rows' inner padding so the icons align on one vertical
|
||||
// edge with the workspace list below (base button uses a wider spacing[3]).
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
buttonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
|
||||
@@ -228,22 +228,22 @@ export function createControlGeometry(theme: Theme) {
|
||||
buttonXs: {
|
||||
minHeight: buttonControlHeight.xs,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
buttonSm: {
|
||||
minHeight: buttonControlHeight.sm,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
buttonMd: {
|
||||
minHeight: buttonControlHeight.md,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
buttonLg: {
|
||||
minHeight: buttonControlHeight.lg,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -306,17 +306,17 @@ export function createControlGeometry(theme: Theme) {
|
||||
segmentedSegmentXs: {
|
||||
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
},
|
||||
segmentedSegmentMd: {
|
||||
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
segmentedLabelXs: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1,48 +1,104 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
buttonIconSize,
|
||||
createControlGeometry,
|
||||
type ButtonControlSize,
|
||||
} from "@/components/ui/control-geometry";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"> {
|
||||
children?: ReactNode;
|
||||
chevron?: ReactNode | null;
|
||||
}
|
||||
|
||||
const chevronColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children" | "style"> {
|
||||
/** The selected option's label. */
|
||||
children: string;
|
||||
/** Icon or swatch before the label. */
|
||||
leading?: ReactNode;
|
||||
size?: ButtonControlSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* A select-style menu trigger: the selected value plus a chevron, drawn with the
|
||||
* outline Button's chrome and size scale so a dropdown and a button in the same
|
||||
* row share one geometry. Callers pick a size; they don't style it.
|
||||
*/
|
||||
export function DropdownTrigger({
|
||||
children,
|
||||
chevron,
|
||||
leading,
|
||||
size = "sm",
|
||||
disabled,
|
||||
...props
|
||||
}: DropdownTriggerProps): ReactElement {
|
||||
let sizeStyle: ViewStyle;
|
||||
if (size === "xs") {
|
||||
sizeStyle = styles.xs;
|
||||
} else if (size === "md") {
|
||||
sizeStyle = styles.md;
|
||||
} else if (size === "lg") {
|
||||
sizeStyle = styles.lg;
|
||||
} else {
|
||||
sizeStyle = styles.sm;
|
||||
}
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed }: { pressed: boolean }) => [
|
||||
styles.base,
|
||||
sizeStyle,
|
||||
pressed ? styles.pressed : null,
|
||||
disabled ? styles.disabled : null,
|
||||
],
|
||||
[disabled, sizeStyle],
|
||||
);
|
||||
const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]);
|
||||
|
||||
return (
|
||||
<DropdownMenuTrigger {...props}>
|
||||
<View style={styles.row}>
|
||||
<DropdownMenuTrigger {...props} disabled={disabled} style={triggerStyle}>
|
||||
{leading}
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{children}
|
||||
{chevron !== null &&
|
||||
(chevron ?? (
|
||||
<View style={styles.chevronContainer}>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
))}
|
||||
</Text>
|
||||
<View style={styles.chevron}>
|
||||
<ThemedChevronDown size={buttonIconSize[size]} uniProps={chevronColorMapping} />
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
chevronContainer: {
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
}));
|
||||
const styles = StyleSheet.create((theme) => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
return {
|
||||
base: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
xs: geometry.buttonXs,
|
||||
sm: geometry.buttonSm,
|
||||
md: geometry.buttonMd,
|
||||
lg: geometry.buttonLg,
|
||||
pressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
disabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
...geometry.buttonText,
|
||||
},
|
||||
labelXs: geometry.buttonTextXs,
|
||||
chevron: {
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -32,15 +32,32 @@ afterEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function parseColor(value: string): { rgb: string; alpha: number } {
|
||||
const channels = value.match(/[\d.]+/g)?.map(Number) ?? [];
|
||||
return { rgb: channels.slice(0, 3).join(","), alpha: channels[3] ?? 1 };
|
||||
}
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it.each(["success", "warning", "error", "muted"] as const)(
|
||||
"uses the semantic badge shell for the %s variant",
|
||||
it("uses the neutral badge shell for the muted variant", () => {
|
||||
const style = getComputedStyle(mountBadge("muted"));
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
});
|
||||
|
||||
it.each(["success", "warning", "error"] as const)(
|
||||
"fills the %s variant with a translucent tint of its own status color",
|
||||
(variant) => {
|
||||
const badge = mountBadge(variant);
|
||||
const style = getComputedStyle(badge);
|
||||
const text = badge.lastElementChild;
|
||||
if (!(text instanceof HTMLElement)) {
|
||||
throw new Error("StatusBadge did not render its label");
|
||||
}
|
||||
const fill = parseColor(getComputedStyle(badge).backgroundColor);
|
||||
|
||||
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
|
||||
expect(style.borderColor).toBe("rgb(228, 228, 231)");
|
||||
expect(fill.rgb).toBe(parseColor(getComputedStyle(text).color).rgb);
|
||||
expect(fill.alpha).toBeGreaterThan(0);
|
||||
expect(fill.alpha).toBeLessThan(0.5);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ interface StatusBadgeProps {
|
||||
}
|
||||
|
||||
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
|
||||
const pillStyle = useMemo(
|
||||
() => [
|
||||
styles.pill,
|
||||
variant === "success" && styles.pillSuccess,
|
||||
variant === "warning" && styles.pillWarning,
|
||||
variant === "error" && styles.pillError,
|
||||
],
|
||||
[variant],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.pillText,
|
||||
@@ -22,7 +31,7 @@ export function StatusBadge({ label, variant = "muted", leading }: StatusBadgePr
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.pill}>
|
||||
<View style={pillStyle}>
|
||||
{leading}
|
||||
<Text style={textStyle}>{label}</Text>
|
||||
</View>
|
||||
@@ -41,6 +50,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 3,
|
||||
},
|
||||
pillSuccess: {
|
||||
backgroundColor: theme.colors.statusSuccessTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillWarning: {
|
||||
backgroundColor: theme.colors.statusWarningTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: theme.colors.statusDangerTint,
|
||||
borderColor: "transparent",
|
||||
},
|
||||
pillText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
|
||||
@@ -65,7 +65,7 @@ export function BrowserDataSection() {
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="destructive"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={isClearing}
|
||||
disabled={isClearing}
|
||||
|
||||
@@ -1953,8 +1953,11 @@ export const ar: TranslationResources = {
|
||||
groupInfo: "حول{{title}}",
|
||||
sections: {
|
||||
general: "عام",
|
||||
chat: "الدردشة",
|
||||
appearance: "مظهر",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "الشريط الجانبي",
|
||||
terminal: "الطرفية",
|
||||
browser: "المتصفح",
|
||||
editor: "المحرر",
|
||||
shortcuts: "الاختصارات",
|
||||
integrations: "التكامل",
|
||||
@@ -2013,6 +2016,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "عام",
|
||||
sending: "الإرسال",
|
||||
browserData: {
|
||||
title: "بيانات المتصفح",
|
||||
siteData: "ملفات تعريف الارتباط وبيانات المواقع",
|
||||
@@ -2040,8 +2044,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "عناوين URL للخدمة",
|
||||
description: "مكان فتح عناوين URL من تشغيل البرامج النصية",
|
||||
options: {
|
||||
ask: "بسأل",
|
||||
inApp: "في Paseo",
|
||||
@@ -2060,7 +2062,6 @@ export const ar: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
accessibilityLabel: "حدد عرض استدعاءات الأدوات ({{value}})",
|
||||
options: {
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
|
||||
@@ -1978,9 +1978,12 @@ export const en = {
|
||||
groupInfo: "About {{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Appearance",
|
||||
layout: "Layout",
|
||||
sidebar: "Sidebar",
|
||||
editor: "Editor",
|
||||
terminal: "Terminal",
|
||||
browser: "Browser",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
notifications: "Notifications",
|
||||
@@ -1998,29 +2001,32 @@ export const en = {
|
||||
},
|
||||
sources: {
|
||||
explorerFiles: {
|
||||
label: "Selecting a file in Explorer",
|
||||
label: "Clicking a file in the Explorer sidebar",
|
||||
description: "Open files selected in the Explorer sidebar beside your work",
|
||||
},
|
||||
diffs: {
|
||||
label: "Opening a diff",
|
||||
label: "Clicking a change in the Explorer sidebar or a chat",
|
||||
description: "Open diffs from Explorer and agent conversations beside your work",
|
||||
},
|
||||
chatFiles: {
|
||||
label: "Opening a file from an agent chat",
|
||||
label: "Clicking a file in an agent chat",
|
||||
description: "Open file links and tool-call files beside the conversation",
|
||||
},
|
||||
diffFiles: {
|
||||
label: "Opening a file from Changes",
|
||||
label: "Clicking a file in a diff",
|
||||
description: "Open source files selected from a diff beside it",
|
||||
},
|
||||
subagents: {
|
||||
label: "Opening a subagent",
|
||||
label: "Clicking a subagent in an agent chat",
|
||||
description: "Open subagents beside their parent agent",
|
||||
},
|
||||
pullRequests: {
|
||||
label: "Opening a pull request from Changes",
|
||||
label: "Clicking a pull request in the Explorer sidebar",
|
||||
description: "Open pull request details beside Changes",
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "Clicking a script's service URL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2134,6 +2140,7 @@ export const en = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Sending",
|
||||
browserData: {
|
||||
title: "Browser data",
|
||||
siteData: "Cookies and site data",
|
||||
@@ -2160,8 +2167,6 @@ export const en = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "Service URLs",
|
||||
description: "Where to open URLs from running scripts",
|
||||
options: {
|
||||
ask: "Ask",
|
||||
inApp: "In Paseo",
|
||||
@@ -2180,7 +2185,6 @@ export const en = {
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
accessibilityLabel: "Select tool call display ({{value}})",
|
||||
options: {
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
|
||||
@@ -2001,8 +2001,11 @@ export const es: TranslationResources = {
|
||||
groupInfo: "Acerca de{{title}}",
|
||||
sections: {
|
||||
general: "General",
|
||||
chat: "Chat",
|
||||
appearance: "Apariencia",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barra lateral",
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atajos",
|
||||
integrations: "Integraciones",
|
||||
@@ -2061,6 +2064,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "General",
|
||||
sending: "Envío",
|
||||
browserData: {
|
||||
title: "Datos del navegador",
|
||||
siteData: "Cookies y datos de sitios",
|
||||
@@ -2091,8 +2095,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL de servicio",
|
||||
description: "Dónde abrir URL desde scripts en ejecución",
|
||||
options: {
|
||||
ask: "Preguntar",
|
||||
inApp: "EnPaseo",
|
||||
@@ -2112,7 +2114,6 @@ export const es: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
accessibilityLabel: "Seleccionar visualización de llamadas a herramientas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
|
||||
@@ -2006,8 +2006,11 @@ export const fr: TranslationResources = {
|
||||
groupInfo: "À propos de{{title}}",
|
||||
sections: {
|
||||
general: "Général",
|
||||
chat: "Discussion",
|
||||
appearance: "Apparence",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barre latérale",
|
||||
terminal: "Terminal",
|
||||
browser: "Navigateur",
|
||||
editor: "Éditeur",
|
||||
shortcuts: "Raccourcis",
|
||||
integrations: "Intégrations",
|
||||
@@ -2066,6 +2069,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Général",
|
||||
sending: "Envoi",
|
||||
browserData: {
|
||||
title: "Données du navigateur",
|
||||
siteData: "Cookies et données des sites",
|
||||
@@ -2095,8 +2099,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL de services",
|
||||
description: "Où ouvrir les URL à partir de scripts en cours d'exécution",
|
||||
options: {
|
||||
ask: "Demander",
|
||||
inApp: "DansPaseo",
|
||||
@@ -2115,7 +2117,6 @@ export const fr: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
accessibilityLabel: "Sélectionner l’affichage des appels d’outils ({{value}})",
|
||||
options: {
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
|
||||
@@ -1973,8 +1973,11 @@ export const ja: TranslationResources = {
|
||||
groupInfo: "{{title}}について",
|
||||
sections: {
|
||||
general: "一般",
|
||||
chat: "チャット",
|
||||
appearance: "外観",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "サイドバー",
|
||||
terminal: "ターミナル",
|
||||
browser: "ブラウザ",
|
||||
editor: "エディター",
|
||||
shortcuts: "ショートカット",
|
||||
integrations: "連携",
|
||||
@@ -2033,6 +2036,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "一般",
|
||||
sending: "送信",
|
||||
browserData: {
|
||||
title: "ブラウザーデータ",
|
||||
siteData: "Cookie とサイトデータ",
|
||||
@@ -2059,8 +2063,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "サービスURL",
|
||||
description: "実行中のスクリプトからURLを開く場所",
|
||||
options: {
|
||||
ask: "確認する",
|
||||
inApp: "Paseoで",
|
||||
@@ -2079,7 +2081,6 @@ export const ja: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
accessibilityLabel: "ツール呼び出しの表示を選択({{value}})",
|
||||
options: {
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
|
||||
@@ -1963,8 +1963,11 @@ export const ko: TranslationResources = {
|
||||
groupInfo: "{{title}} 정보",
|
||||
sections: {
|
||||
general: "일반",
|
||||
chat: "채팅",
|
||||
appearance: "모양",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "사이드바",
|
||||
terminal: "터미널",
|
||||
browser: "브라우저",
|
||||
editor: "편집기",
|
||||
shortcuts: "단축키",
|
||||
integrations: "통합",
|
||||
@@ -2023,6 +2026,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "일반",
|
||||
sending: "전송",
|
||||
browserData: {
|
||||
title: "브라우저 데이터",
|
||||
siteData: "쿠키 및 사이트 데이터",
|
||||
@@ -2051,8 +2055,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "서비스 URL",
|
||||
description: "실행 중인 스크립트의 URL을 열 위치",
|
||||
options: {
|
||||
ask: "물어보기",
|
||||
inApp: "Paseo에서",
|
||||
@@ -2071,7 +2073,6 @@ export const ko: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "도구 호출 표시",
|
||||
description: "타임라인에 도구 호출이 표시되는 방식",
|
||||
accessibilityLabel: "도구 호출 표시 선택({{value}})",
|
||||
options: {
|
||||
overview: "요약",
|
||||
detailed: "전체 세부정보",
|
||||
|
||||
@@ -1986,8 +1986,11 @@ export const ptBR: TranslationResources = {
|
||||
groupInfo: "Sobre {{title}}",
|
||||
sections: {
|
||||
general: "Geral",
|
||||
chat: "Chat",
|
||||
appearance: "Aparência",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Barra lateral",
|
||||
terminal: "Terminal",
|
||||
browser: "Navegador",
|
||||
editor: "Editor",
|
||||
shortcuts: "Atalhos",
|
||||
integrations: "Integrações",
|
||||
@@ -2046,6 +2049,7 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Geral",
|
||||
sending: "Envio",
|
||||
browserData: {
|
||||
title: "Dados do navegador",
|
||||
siteData: "Cookies e dados de sites",
|
||||
@@ -2074,8 +2078,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URLs de serviço",
|
||||
description: "Onde abrir URLs de scripts em execução",
|
||||
options: {
|
||||
ask: "Perguntar",
|
||||
inApp: "No Paseo",
|
||||
@@ -2095,7 +2097,6 @@ export const ptBR: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
accessibilityLabel: "Selecionar exibição de chamadas de ferramentas ({{value}})",
|
||||
options: {
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
|
||||
@@ -1986,8 +1986,11 @@ export const ru: TranslationResources = {
|
||||
groupInfo: "О разделе «{{title}}»",
|
||||
sections: {
|
||||
general: "Основные",
|
||||
chat: "Чат",
|
||||
appearance: "Оформление",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "Боковая панель",
|
||||
terminal: "Терминал",
|
||||
browser: "Браузер",
|
||||
editor: "Редактор",
|
||||
shortcuts: "Сочетания клавиш",
|
||||
integrations: "Интеграции",
|
||||
@@ -2046,6 +2049,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "Основные",
|
||||
sending: "Отправка",
|
||||
browserData: {
|
||||
title: "Данные браузера",
|
||||
siteData: "Файлы cookie и данные сайтов",
|
||||
@@ -2075,8 +2079,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "URL-адреса сервисов",
|
||||
description: "Где открывать URL-адреса запущенных скриптов",
|
||||
options: {
|
||||
ask: "Спрашивать",
|
||||
inApp: "В Paseo",
|
||||
@@ -2096,7 +2098,6 @@ export const ru: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
accessibilityLabel: "Выбрать отображение вызовов инструментов ({{value}})",
|
||||
options: {
|
||||
overview: "Сводка",
|
||||
detailed: "Все подробности",
|
||||
|
||||
@@ -1932,8 +1932,11 @@ export const zhCN: TranslationResources = {
|
||||
groupInfo: "关于 {{title}}",
|
||||
sections: {
|
||||
general: "通用",
|
||||
chat: "聊天",
|
||||
appearance: "外观",
|
||||
layout: en.settings.sections.layout,
|
||||
sidebar: "侧边栏",
|
||||
terminal: "终端",
|
||||
browser: "浏览器",
|
||||
editor: "编辑器",
|
||||
shortcuts: "快捷键",
|
||||
integrations: "集成",
|
||||
@@ -1991,6 +1994,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
general: {
|
||||
title: "通用",
|
||||
sending: "发送",
|
||||
browserData: {
|
||||
title: "浏览器数据",
|
||||
siteData: "Cookie 和网站数据",
|
||||
@@ -2016,8 +2020,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
serviceUrls: {
|
||||
label: "服务 URL",
|
||||
description: "运行脚本中的 URL 打开位置",
|
||||
options: {
|
||||
ask: "询问",
|
||||
inApp: "在 Paseo 中",
|
||||
@@ -2036,7 +2038,6 @@ export const zhCN: TranslationResources = {
|
||||
toolCallDetail: {
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
accessibilityLabel: "选择工具调用显示方式({{value}})",
|
||||
options: {
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import { useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -38,27 +37,27 @@ import {
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Blocks,
|
||||
PanelsTopLeft,
|
||||
Globe,
|
||||
PanelLeft,
|
||||
MessageSquare,
|
||||
ChevronRight,
|
||||
} from "lucide-react-native";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { AppearanceSection } from "@/screens/settings/appearance/appearance-section";
|
||||
import { LayoutSection } from "@/screens/settings/layout/layout-section";
|
||||
import { OpenLocationSection } from "@/screens/settings/open-location/open-location-section";
|
||||
import { TerminalSection } from "@/screens/settings/terminal/terminal-section";
|
||||
import { ChatSection } from "@/screens/settings/chat/chat-section";
|
||||
import { SidebarNavSection } from "@/screens/settings/sidebar/sidebar-nav-section";
|
||||
import { SendingSection } from "@/screens/settings/general/sending-section";
|
||||
import {
|
||||
useAppSettings,
|
||||
useSettings,
|
||||
parseTerminalScrollbackLines,
|
||||
type AppSettings,
|
||||
type SendBehavior,
|
||||
type ServiceUrlBehavior,
|
||||
type Settings as EffectiveSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -114,7 +113,6 @@ import {
|
||||
HostWorkspacesPage,
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { PluginSettingsContent } from "@/plugins/settings";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
||||
@@ -144,48 +142,88 @@ import { isNative, isWeb } from "@/constants/platform";
|
||||
interface SidebarSectionItem {
|
||||
id: SettingsSectionSlug;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
desktopOnly?: boolean;
|
||||
webOnly?: boolean;
|
||||
/** The page body, for pages that need nothing from the settings screen. */
|
||||
Content?: ComponentType;
|
||||
}
|
||||
|
||||
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
{ id: "general", labelKey: "settings.sections.general", icon: Settings },
|
||||
{ id: "appearance", labelKey: "settings.sections.appearance", icon: Palette },
|
||||
{
|
||||
id: "layout",
|
||||
labelKey: "settings.sections.layout",
|
||||
icon: PanelsTopLeft,
|
||||
id: "appearance",
|
||||
labelKey: "settings.sections.appearance",
|
||||
icon: Palette,
|
||||
Content: AppearanceSection,
|
||||
},
|
||||
{
|
||||
id: "sidebar",
|
||||
labelKey: "settings.sections.sidebar",
|
||||
icon: PanelLeft,
|
||||
Content: SidebarNavSection,
|
||||
},
|
||||
{ id: "chat", labelKey: "settings.sections.chat", icon: MessageSquare, Content: ChatSection },
|
||||
{
|
||||
id: "terminal",
|
||||
labelKey: "settings.sections.terminal",
|
||||
icon: SquareTerminal,
|
||||
Content: TerminalSection,
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
labelKey: "settings.sections.browser",
|
||||
icon: Globe,
|
||||
desktopOnly: true,
|
||||
Content: BrowserDataSection,
|
||||
},
|
||||
{
|
||||
id: "editor",
|
||||
labelKey: "settings.sections.editor",
|
||||
icon: Code2,
|
||||
webOnly: true,
|
||||
Content: EditorSection,
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
labelKey: "settings.sections.shortcuts",
|
||||
icon: Keyboard,
|
||||
desktopOnly: true,
|
||||
Content: KeyboardShortcutsSection,
|
||||
},
|
||||
{ id: "editor", labelKey: "settings.sections.editor", icon: Code2, webOnly: true },
|
||||
{ id: "shortcuts", labelKey: "settings.sections.shortcuts", icon: Keyboard, desktopOnly: true },
|
||||
{
|
||||
id: "integrations",
|
||||
labelKey: "settings.sections.integrations",
|
||||
icon: Puzzle,
|
||||
desktopOnly: true,
|
||||
Content: IntegrationsSection,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
labelKey: "settings.sections.notifications",
|
||||
icon: Bell,
|
||||
desktopOnly: true,
|
||||
Content: DesktopNotificationsSection,
|
||||
},
|
||||
{
|
||||
id: "permissions",
|
||||
labelKey: "settings.sections.permissions",
|
||||
icon: Shield,
|
||||
desktopOnly: true,
|
||||
Content: DesktopPermissionsSection,
|
||||
},
|
||||
{ id: "diagnostics", labelKey: "settings.sections.diagnostics", icon: Stethoscope },
|
||||
{ id: "about", labelKey: "settings.sections.about", icon: Info },
|
||||
];
|
||||
|
||||
function isSectionAvailable(item: SidebarSectionItem, isDesktopApp: boolean): boolean {
|
||||
return (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb);
|
||||
}
|
||||
|
||||
interface HostSectionItem {
|
||||
id: HostSectionSlug;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
}
|
||||
|
||||
const HOST_SECTION_ITEMS: HostSectionItem[] = [
|
||||
@@ -236,10 +274,6 @@ function renderHostSettingsContent(
|
||||
// Trigger + sidebar style helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function themeTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
|
||||
}
|
||||
|
||||
function sidebarItemStyle({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [sidebarStyles.item, Boolean(hovered) && sidebarStyles.itemHovered];
|
||||
}
|
||||
@@ -252,82 +286,18 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho
|
||||
];
|
||||
}
|
||||
|
||||
function getSendBehaviorOptions(t: TFunction) {
|
||||
return [
|
||||
{ value: "interrupt" as const, label: t("settings.general.defaultSend.options.interrupt") },
|
||||
{ value: "steer" as const, label: t("settings.general.defaultSend.options.steer") },
|
||||
{ value: "queue" as const, label: t("settings.general.defaultSend.options.queue") },
|
||||
];
|
||||
}
|
||||
|
||||
function getServiceUrlBehaviorLabel(t: TFunction, value: ServiceUrlBehavior): string {
|
||||
const labels: Record<ServiceUrlBehavior, string> = {
|
||||
ask: t("settings.general.serviceUrls.options.ask"),
|
||||
"in-app": t("settings.general.serviceUrls.options.inApp"),
|
||||
external: t("settings.general.serviceUrls.options.external"),
|
||||
};
|
||||
return labels[value];
|
||||
}
|
||||
|
||||
function getActiveLocale(language: string | undefined): SupportedLocale {
|
||||
const parsed = parseAppLanguage(language);
|
||||
return parsed && parsed !== "system" ? parsed : "en";
|
||||
}
|
||||
|
||||
const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "external"];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GeneralSectionProps {
|
||||
settings: AppSettings;
|
||||
isDesktopApp: boolean;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
|
||||
handleLanguageChange: (language: AppLanguage) => void;
|
||||
handleTerminalScrollbackLinesChange: (lines: number) => void;
|
||||
}
|
||||
|
||||
interface ServiceUrlBehaviorMenuItemProps {
|
||||
value: ServiceUrlBehavior;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onChange: (value: ServiceUrlBehavior) => void;
|
||||
}
|
||||
|
||||
interface SendBehaviorMenuItemProps {
|
||||
value: SendBehavior;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onChange: (value: SendBehavior) => void;
|
||||
}
|
||||
|
||||
function SendBehaviorMenuItem({ value, label, selected, onChange }: SendBehaviorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(value);
|
||||
}, [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceUrlBehaviorMenuItem({
|
||||
value,
|
||||
label,
|
||||
selected,
|
||||
onChange,
|
||||
}: ServiceUrlBehaviorMenuItemProps) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onChange(value);
|
||||
}, [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface LanguageMenuItemProps {
|
||||
@@ -354,21 +324,9 @@ function LanguageMenuItem({ value, activeLocale, selected, onChange }: LanguageM
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralSection({
|
||||
settings,
|
||||
isDesktopApp,
|
||||
handleSendBehaviorChange,
|
||||
handleServiceUrlBehaviorChange,
|
||||
handleLanguageChange,
|
||||
handleTerminalScrollbackLinesChange,
|
||||
}: GeneralSectionProps) {
|
||||
function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const activeLocale = getActiveLocale(i18n.language);
|
||||
const sendBehaviorOptions = useMemo(() => getSendBehaviorOptions(t), [t]);
|
||||
const selectedSendBehaviorLabel =
|
||||
sendBehaviorOptions.find((option) => option.value === settings.sendBehavior)?.label ??
|
||||
settings.sendBehavior;
|
||||
const sendBehaviorDescriptionKey = `settings.general.defaultSend.descriptions.${settings.sendBehavior}`;
|
||||
const selectedLanguageOption = LANGUAGE_OPTIONS.find(
|
||||
(option) => option.value === settings.language,
|
||||
);
|
||||
@@ -379,72 +337,17 @@ function GeneralSection({
|
||||
t(selectedLanguageOption.labelKey),
|
||||
)
|
||||
: settings.language;
|
||||
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
|
||||
String(settings.terminalScrollbackLines),
|
||||
);
|
||||
|
||||
const handleTerminalScrollbackChangeText = useCallback((value: string) => {
|
||||
setTerminalScrollbackValue(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const commitTerminalScrollback = useCallback(() => {
|
||||
const parsed = parseTerminalScrollbackLines(terminalScrollbackValue);
|
||||
const nextValue = parsed ?? settings.terminalScrollbackLines;
|
||||
setTerminalScrollbackValue(String(nextValue));
|
||||
if (nextValue !== settings.terminalScrollbackLines) {
|
||||
handleTerminalScrollbackLinesChange(nextValue);
|
||||
}
|
||||
}, [
|
||||
handleTerminalScrollbackLinesChange,
|
||||
settings.terminalScrollbackLines,
|
||||
terminalScrollbackValue,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setTerminalScrollbackValue(String(settings.terminalScrollbackLines));
|
||||
}, [settings.terminalScrollbackLines]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.general.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.defaultSend.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t(sendBehaviorDescriptionKey)}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${t("settings.general.defaultSend.label")}: ${selectedSendBehaviorLabel}`}
|
||||
style={themeTriggerStyle}
|
||||
>
|
||||
<Text style={styles.themeTriggerText}>{selectedSendBehaviorLabel}</Text>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{sendBehaviorOptions.map((option) => (
|
||||
<SendBehaviorMenuItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
selected={settings.sendBehavior === option.value}
|
||||
onChange={handleSendBehaviorChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.language.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{t("settings.general.language.description")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={selectedLanguageLabel}
|
||||
style={themeTriggerStyle}
|
||||
>
|
||||
<Text style={styles.themeTriggerText}>{selectedLanguageLabel}</Text>
|
||||
<DropdownTrigger accessibilityRole="button" accessibilityLabel={selectedLanguageLabel}>
|
||||
{selectedLanguageLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={300}>
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
@@ -459,55 +362,6 @@ function GeneralSection({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
{isDesktopApp ? (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.serviceUrls.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.serviceUrls.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownTrigger style={themeTriggerStyle}>
|
||||
<Text style={styles.themeTriggerText}>
|
||||
{getServiceUrlBehaviorLabel(t, settings.serviceUrlBehavior)}
|
||||
</Text>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{SERVICE_URL_BEHAVIOR_VALUES.map((value) => (
|
||||
<ServiceUrlBehaviorMenuItem
|
||||
key={value}
|
||||
value={value}
|
||||
label={getServiceUrlBehaviorLabel(t, value)}
|
||||
selected={settings.serviceUrlBehavior === value}
|
||||
onChange={handleServiceUrlBehaviorChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.general.terminalScrollback.label")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.terminalScrollback.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
initialValue={terminalScrollbackValue}
|
||||
onChangeText={handleTerminalScrollbackChangeText}
|
||||
onBlur={commitTerminalScrollback}
|
||||
onSubmitEditing={commitTerminalScrollback}
|
||||
keyboardType="number-pad"
|
||||
inputMode="numeric"
|
||||
selectTextOnFocus
|
||||
style={styles.terminalScrollbackInput}
|
||||
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -889,11 +743,14 @@ function useSortedHosts(hosts: HostProfile[], localServerId: string | null): Hos
|
||||
interface SidebarSectionButtonProps {
|
||||
itemId: SettingsSectionSlug;
|
||||
label: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
isSelected: boolean;
|
||||
onSelect: (section: SettingsSectionSlug) => void;
|
||||
}
|
||||
|
||||
// Lighter than lucide's default 2px stroke so the nav reads quieter than the content.
|
||||
const SIDEBAR_ICON_STROKE_WIDTH = 1.5;
|
||||
|
||||
function SidebarSectionButton({
|
||||
itemId,
|
||||
label,
|
||||
@@ -920,6 +777,7 @@ function SidebarSectionButton({
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
@@ -931,7 +789,7 @@ function SidebarSectionButton({
|
||||
interface SidebarHostSectionButtonProps {
|
||||
itemId: HostSectionSlug;
|
||||
label: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
icon: ComponentType<{ size: number; color: string; strokeWidth?: number }>;
|
||||
isSelected: boolean;
|
||||
onSelect: (section: HostSectionSlug) => void;
|
||||
}
|
||||
@@ -963,6 +821,7 @@ function SidebarHostSectionButton({
|
||||
<IconComponent
|
||||
size={theme.iconSize.md}
|
||||
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<Text style={labelStyle} numberOfLines={1}>
|
||||
{label}
|
||||
@@ -1082,9 +941,7 @@ function SettingsSidebar({
|
||||
const hasHosts = sortedHosts.length > 0;
|
||||
const enableBuiltInDaemonOption = useEnableBuiltInDaemonOption();
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter(
|
||||
(item) => (!item.desktopOnly || isDesktopApp) && (!item.webOnly || isWeb),
|
||||
);
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => isSectionAvailable(item, isDesktopApp));
|
||||
const insets = useSafeAreaInsets();
|
||||
const isDesktop = layout === "desktop";
|
||||
const outerContainerStyle = useMemo(
|
||||
@@ -1116,7 +973,6 @@ function SettingsSidebar({
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
{hasHosts ? (
|
||||
<View style={sidebarStyles.list}>
|
||||
<Text style={sidebarStyles.groupLabel}>{t("settings.groups.host")}</Text>
|
||||
@@ -1147,7 +1003,11 @@ function SettingsSidebar({
|
||||
testID="settings-add-host"
|
||||
style={sidebarItemStyle}
|
||||
>
|
||||
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Plus
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<Text style={sidebarStyles.label} numberOfLines={1}>
|
||||
{t("settings.addHost")}
|
||||
</Text>
|
||||
@@ -1160,7 +1020,11 @@ function SettingsSidebar({
|
||||
testID="settings-enable-built-in-daemon"
|
||||
style={sidebarItemStyle}
|
||||
>
|
||||
<Server size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Server
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
strokeWidth={SIDEBAR_ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<Text style={sidebarStyles.label} numberOfLines={1}>
|
||||
{t("settings.enableBuiltInDaemon")}
|
||||
</Text>
|
||||
@@ -1216,7 +1080,6 @@ export interface SettingsScreenProps {
|
||||
|
||||
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
|
||||
const router = useRouter();
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
|
||||
@@ -1265,20 +1128,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
});
|
||||
}, [view, selectedSettingsHostServerId, localServerId, hosts, sortedHosts]);
|
||||
|
||||
const handleSendBehaviorChange = useCallback(
|
||||
(behavior: SendBehavior) => {
|
||||
void updateSettings({ sendBehavior: behavior });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleServiceUrlBehaviorChange = useCallback(
|
||||
(behavior: ServiceUrlBehavior) => {
|
||||
void updateSettings({ serviceUrlBehavior: behavior });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(language: AppLanguage) => {
|
||||
void updateSettings({ language });
|
||||
@@ -1286,13 +1135,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleTerminalScrollbackLinesChange = useCallback(
|
||||
(terminalScrollbackLines: number) => {
|
||||
void updateSettings({ terminalScrollbackLines });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleUseLegacyTerminalRendererChange = useCallback(
|
||||
(useLegacyTerminalRenderer: boolean) => {
|
||||
void updateSettings({ useLegacyTerminalRenderer });
|
||||
@@ -1465,112 +1307,89 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const detailHeader = ((): {
|
||||
title: string;
|
||||
Icon: ComponentType<{ size: number; color: string }>;
|
||||
titleAccessory?: ReactNode;
|
||||
} | null => {
|
||||
if (view.kind === "plugin") {
|
||||
const screen = installedPlugins
|
||||
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
|
||||
?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
||||
return {
|
||||
title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}`,
|
||||
Icon: screen ? resolvePluginIcon(screen.icon) : Blocks,
|
||||
};
|
||||
return { title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}` };
|
||||
}
|
||||
if (view.kind === "host") {
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: t(item.labelKey), Icon: item.icon };
|
||||
return { title: t(item.labelKey) };
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: t(item.labelKey), Icon: item.icon };
|
||||
return { title: t(item.labelKey) };
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return { title: t("settings.projects"), Icon: FolderGit2 };
|
||||
return { title: t("settings.projects") };
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
let content: ReactNode;
|
||||
if (view.kind === "section" && view.section === "layout") {
|
||||
content = isDesktopApp ? <LayoutSection /> : null;
|
||||
} else {
|
||||
content = (() => {
|
||||
if (view.kind === "plugin")
|
||||
return (
|
||||
<PluginSettingsContent
|
||||
serverId={view.serverId}
|
||||
pluginId={view.pluginId}
|
||||
screenId={view.screenId}
|
||||
/>
|
||||
);
|
||||
if (view.kind === "host") {
|
||||
return renderHostSettingsContent(view, handleHostRemoved);
|
||||
const content: ReactNode = (() => {
|
||||
if (view.kind === "plugin")
|
||||
return (
|
||||
<PluginSettingsContent
|
||||
serverId={view.serverId}
|
||||
pluginId={view.pluginId}
|
||||
screenId={view.screenId}
|
||||
/>
|
||||
);
|
||||
if (view.kind === "host") {
|
||||
return renderHostSettingsContent(view, handleHostRemoved);
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return (
|
||||
<ProjectSettingsScreen
|
||||
serverId={view.serverId}
|
||||
projectId={view.projectId}
|
||||
onBackToProjects={handleBackFromDetail}
|
||||
showBackToProjects={!isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((candidate) => candidate.id === view.section);
|
||||
if (!item || !isSectionAvailable(item, isDesktopApp)) return null;
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
return (
|
||||
<>
|
||||
<GeneralSection settings={settings} handleLanguageChange={handleLanguageChange} />
|
||||
<SendingSection />
|
||||
{isDesktopApp ? <OpenLocationSection /> : null}
|
||||
</>
|
||||
);
|
||||
case "diagnostics":
|
||||
return (
|
||||
<DiagnosticsSection
|
||||
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
|
||||
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
|
||||
voiceAudioEngine={voiceAudioEngine}
|
||||
isPlaybackTestRunning={isPlaybackTestRunning}
|
||||
playbackTestResult={playbackTestResult}
|
||||
handlePlaybackTest={handlePlaybackTest}
|
||||
/>
|
||||
);
|
||||
case "about":
|
||||
return (
|
||||
<AboutSection
|
||||
appVersion={appVersion}
|
||||
appVersionText={appVersionText}
|
||||
isDesktopApp={isDesktopApp}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return item.Content ? <item.Content /> : null;
|
||||
}
|
||||
if (view.kind === "project") {
|
||||
return (
|
||||
<ProjectSettingsScreen
|
||||
serverId={view.serverId}
|
||||
projectId={view.projectId}
|
||||
onBackToProjects={handleBackFromDetail}
|
||||
showBackToProjects={!isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
switch (view.section) {
|
||||
case "general":
|
||||
return (
|
||||
<>
|
||||
<GeneralSection
|
||||
settings={settings}
|
||||
isDesktopApp={isDesktopApp}
|
||||
handleSendBehaviorChange={handleSendBehaviorChange}
|
||||
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
|
||||
handleLanguageChange={handleLanguageChange}
|
||||
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
|
||||
/>
|
||||
{isDesktopApp ? <BrowserDataSection /> : null}
|
||||
</>
|
||||
);
|
||||
case "appearance":
|
||||
return <AppearanceSection />;
|
||||
case "editor":
|
||||
return isWeb ? <EditorSection /> : null;
|
||||
case "shortcuts":
|
||||
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
|
||||
case "integrations":
|
||||
return isDesktopApp ? <IntegrationsSection /> : null;
|
||||
case "notifications":
|
||||
return isDesktopApp ? <DesktopNotificationsSection /> : null;
|
||||
case "permissions":
|
||||
return isDesktopApp ? <DesktopPermissionsSection /> : null;
|
||||
case "diagnostics":
|
||||
return (
|
||||
<DiagnosticsSection
|
||||
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
|
||||
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
|
||||
voiceAudioEngine={voiceAudioEngine}
|
||||
isPlaybackTestRunning={isPlaybackTestRunning}
|
||||
playbackTestResult={playbackTestResult}
|
||||
handlePlaybackTest={handlePlaybackTest}
|
||||
/>
|
||||
);
|
||||
case "about":
|
||||
return (
|
||||
<AboutSection
|
||||
appVersion={appVersion}
|
||||
appVersionText={appVersionText}
|
||||
isDesktopApp={isDesktopApp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (settingsLoading) {
|
||||
return (
|
||||
@@ -1580,14 +1399,13 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
);
|
||||
}
|
||||
|
||||
const desktopDetailHeaderLeft = detailHeader ? (
|
||||
<>
|
||||
<HeaderIconBadge>
|
||||
<detailHeader.Icon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</HeaderIconBadge>
|
||||
<ScreenTitle testID="settings-detail-header-title">{detailHeader.title}</ScreenTitle>
|
||||
const desktopPageTitle = detailHeader ? (
|
||||
<View style={styles.pageTitleRow}>
|
||||
<Text style={styles.pageTitle} testID="settings-detail-header-title">
|
||||
{detailHeader.title}
|
||||
</Text>
|
||||
{detailHeader.titleAccessory}
|
||||
</>
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
const addHostModals = (
|
||||
@@ -1679,13 +1497,14 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
|
||||
</WindowChromeRegion>
|
||||
<WindowChromeRegion corners="top-right">
|
||||
<View style={desktopStyles.contentPane} testID="settings-detail-pane">
|
||||
<ScreenHeader
|
||||
borderless={!detailHeader}
|
||||
left={desktopDetailHeaderLeft}
|
||||
leftStyle={desktopStyles.detailLeft}
|
||||
/>
|
||||
{/* Keeps the titlebar drag region and window controls; the page
|
||||
title lives in the content, like a document heading. */}
|
||||
<ScreenHeader borderless />
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={insetBottomStyle}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
<View style={styles.content}>
|
||||
{desktopPageTitle}
|
||||
{content}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</WindowChromeRegion>
|
||||
@@ -1724,6 +1543,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: 720,
|
||||
alignSelf: "center",
|
||||
},
|
||||
pageTitleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
// Line up with the section titles, which sit inset from their cards.
|
||||
marginLeft: theme.spacing[1],
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: theme.fontSize["4xl"],
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
aboutValue: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -1744,33 +1576,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
themeTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
themeTriggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
terminalScrollbackInput: {
|
||||
width: 112,
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "right",
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
@@ -1783,7 +1588,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const desktopStyles = StyleSheet.create((theme) => ({
|
||||
const desktopStyles = StyleSheet.create({
|
||||
row: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
@@ -1791,10 +1596,7 @@ const desktopStyles = StyleSheet.create((theme) => ({
|
||||
contentPane: {
|
||||
flex: 1,
|
||||
},
|
||||
detailLeft: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
desktopContainer: {
|
||||
@@ -1816,12 +1618,12 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
list: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
groupLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
@@ -1829,8 +1631,8 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
@@ -1850,8 +1652,8 @@ const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
|
||||
import { Monitor, Moon, Sun } from "lucide-react-native";
|
||||
import {
|
||||
SYNTAX_THEME_OPTIONS,
|
||||
type SyntaxThemeId,
|
||||
@@ -14,9 +14,8 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SettingsCard, SettingsSwitch } from "@/components/settings";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { SettingsSection } from "@/components/settings/headings/settings-section";
|
||||
import { useContributedThemes } from "@/appearance/provider";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
@@ -46,7 +45,6 @@ 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
|
||||
@@ -57,7 +55,6 @@ import { SidebarNavSection } from "./sidebar-nav-section";
|
||||
const ThemedSun = withUnistyles(Sun);
|
||||
const ThemedMoon = withUnistyles(Moon);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -83,10 +80,6 @@ function sizeDraftToOverride(value: string): number | undefined {
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.trigger, pressed ? styles.triggerPressed : null];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme picker
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -175,26 +168,29 @@ function ThemeRow({
|
||||
const selectedLabel = selectedPluginTheme
|
||||
? selectedPluginTheme.name
|
||||
: getThemeLabel(t, builtInValue);
|
||||
const leading = useMemo(
|
||||
() =>
|
||||
selectedPluginTheme ? (
|
||||
<ThemeSwatch color={selectedPluginTheme.swatch} />
|
||||
) : (
|
||||
<ThemeLeading themeValue={builtInValue} />
|
||||
),
|
||||
[builtInValue, selectedPluginTheme],
|
||||
);
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.theme.title")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
<DropdownTrigger
|
||||
accessibilityLabel={t("settings.appearance.theme.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
leading={leading}
|
||||
>
|
||||
{selectedPluginTheme ? (
|
||||
<ThemeSwatch color={selectedPluginTheme.swatch} />
|
||||
) : (
|
||||
<ThemeLeading themeValue={builtInValue} />
|
||||
)}
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
|
||||
{THEME_OPTIONS.map((option, index) => {
|
||||
const previousOption = THEME_OPTIONS[index - 1];
|
||||
@@ -226,109 +222,6 @@ function ThemeRow({
|
||||
);
|
||||
}
|
||||
|
||||
interface AutoExpandReasoningRowProps {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsSwitch
|
||||
label={t("settings.general.autoExpandReasoning.label")}
|
||||
hint={t("settings.general.autoExpandReasoning.description")}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatOutlineRowProps {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
function ChatOutlineRow({ value, onChange }: ChatOutlineRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsSwitch
|
||||
label={t("settings.appearance.chatOutline.title")}
|
||||
hint={t("settings.appearance.chatOutline.description")}
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
|
||||
"detailed",
|
||||
"overview",
|
||||
];
|
||||
|
||||
function getToolCallDetailLevelLabel(
|
||||
t: TFunction,
|
||||
value: AppSettings["toolCallDetailLevel"],
|
||||
): string {
|
||||
return t(`settings.general.toolCallDetail.options.${value}`);
|
||||
}
|
||||
|
||||
interface ToolCallDetailMenuItemProps {
|
||||
value: AppSettings["toolCallDetailLevel"];
|
||||
selected: boolean;
|
||||
onChange: (value: AppSettings["toolCallDetailLevel"]) => void;
|
||||
}
|
||||
|
||||
function ToolCallDetailMenuItem({ value, selected, onChange }: ToolCallDetailMenuItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleSelect = useCallback(() => onChange(value), [onChange, value]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{getToolCallDetailLevelLabel(t, value)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCallDetailRowProps {
|
||||
value: AppSettings["toolCallDetailLevel"];
|
||||
onChange: (value: AppSettings["toolCallDetailLevel"]) => void;
|
||||
}
|
||||
|
||||
function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectedLabel = getToolCallDetailLevelLabel(t, value);
|
||||
return (
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.general.toolCallDetail.label")}</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.toolCallDetail.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityLabel={t("settings.general.toolCallDetail.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{TOOL_CALL_DETAIL_LEVELS.map((option) => (
|
||||
<ToolCallDetailMenuItem
|
||||
key={option}
|
||||
value={option}
|
||||
selected={value === option}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fonts: family text fields + numeric size fields (commit on blur/submit)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -478,15 +371,13 @@ function SyntaxRow({ value, onChange }: SyntaxRowProps) {
|
||||
</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
<DropdownTrigger
|
||||
accessibilityLabel={t("settings.appearance.syntax.highlightThemeAccessibility", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{SYNTAX_THEME_OPTIONS.map((option) => (
|
||||
<SyntaxMenuItem
|
||||
@@ -556,27 +447,6 @@ export function AppearanceSection() {
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleAutoExpandReasoningChange = useCallback(
|
||||
(autoExpandReasoning: boolean) => {
|
||||
void updateSettings({ autoExpandReasoning });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleToolCallDetailLevelChange = useCallback(
|
||||
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) => {
|
||||
void updateSettings({ toolCallDetailLevel });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleChatOutlineChange = useCallback(
|
||||
(chatOutlineEnabled: boolean) => {
|
||||
void updateSettings({ chatOutlineEnabled });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const commitUiFontFamily = useCallback(
|
||||
(value: string) => {
|
||||
const sanitized = sanitizeFontFamily(value);
|
||||
@@ -680,25 +550,6 @@ export function AppearanceSection() {
|
||||
/>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
|
||||
<SettingsCard>
|
||||
<AutoExpandReasoningRow
|
||||
value={settings.autoExpandReasoning}
|
||||
onChange={handleAutoExpandReasoningChange}
|
||||
/>
|
||||
<ToolCallDetailRow
|
||||
value={settings.toolCallDetailLevel}
|
||||
onChange={handleToolCallDetailLevelChange}
|
||||
/>
|
||||
{!isNative ? (
|
||||
<ChatOutlineRow
|
||||
value={settings.chatOutlineEnabled}
|
||||
onChange={handleChatOutlineChange}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
<SidebarNavSection />
|
||||
<SettingsSection title={t("settings.appearance.fonts.title")}>
|
||||
<View style={settingsStyles.card}>
|
||||
{showInterfaceFontFamilyRow ? (
|
||||
@@ -777,23 +628,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
triggerPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
triggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
swatch: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
SettingsSelect,
|
||||
SettingsSwitch,
|
||||
} from "@/components/settings";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
|
||||
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
|
||||
"detailed",
|
||||
"overview",
|
||||
];
|
||||
|
||||
export function ChatSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
|
||||
const toolCallDetailOptions = useMemo(
|
||||
() =>
|
||||
TOOL_CALL_DETAIL_LEVELS.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.general.toolCallDetail.options.${value}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const changeToolCallDetailLevel = useCallback(
|
||||
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) =>
|
||||
void updateSettings({ toolCallDetailLevel }),
|
||||
[updateSettings],
|
||||
);
|
||||
const changeAutoExpandReasoning = useCallback(
|
||||
(autoExpandReasoning: boolean) => void updateSettings({ autoExpandReasoning }),
|
||||
[updateSettings],
|
||||
);
|
||||
const changeChatOutline = useCallback(
|
||||
(chatOutlineEnabled: boolean) => void updateSettings({ chatOutlineEnabled }),
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
|
||||
<SettingsCard>
|
||||
<SettingsSwitch
|
||||
label={t("settings.general.autoExpandReasoning.label")}
|
||||
hint={t("settings.general.autoExpandReasoning.description")}
|
||||
value={settings.autoExpandReasoning}
|
||||
onValueChange={changeAutoExpandReasoning}
|
||||
/>
|
||||
<SettingsSelect
|
||||
label={t("settings.general.toolCallDetail.label")}
|
||||
hint={t("settings.general.toolCallDetail.description")}
|
||||
value={settings.toolCallDetailLevel}
|
||||
options={toolCallDetailOptions}
|
||||
onValueChange={changeToolCallDetailLevel}
|
||||
/>
|
||||
{isNative ? null : (
|
||||
<SettingsSwitch
|
||||
label={t("settings.appearance.chatOutline.title")}
|
||||
hint={t("settings.appearance.chatOutline.description")}
|
||||
value={settings.chatOutlineEnabled}
|
||||
onValueChange={changeChatOutline}
|
||||
/>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings";
|
||||
import { useAppSettings, type SendBehavior } from "@/hooks/use-settings";
|
||||
|
||||
const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"];
|
||||
|
||||
export function SendingSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const options = useMemo(
|
||||
() =>
|
||||
SEND_BEHAVIORS.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.general.defaultSend.options.${value}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
const change = useCallback(
|
||||
(sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }),
|
||||
[updateSettings],
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={t("settings.general.sending")}>
|
||||
<SettingsCard>
|
||||
<SettingsSelect
|
||||
label={t("settings.general.defaultSend.label")}
|
||||
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
|
||||
value={settings.sendBehavior}
|
||||
options={options}
|
||||
onValueChange={change}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Pencil } from "lucide-react-native";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Pencil } from "lucide-react-native";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { WorkspaceMetaRow } from "@/components/sidebar/workspace-meta-row";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
@@ -28,15 +24,10 @@ import { settingsStyles } from "@/styles/settings";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return pressed ? [styles.trigger, styles.triggerPressed] : styles.trigger;
|
||||
}
|
||||
|
||||
function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { renameHost } = useHostMutations();
|
||||
@@ -119,23 +110,22 @@ function ColorMenuItem({
|
||||
function ColorRow({ color, onChange }: { color: HostColor; onChange: (color: HostColor) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const selectedLabel = colorLabel(t, color);
|
||||
const leading = useMemo(() => <ColorSwatch color={color} />, [color]);
|
||||
return (
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.color.label")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={dropdownTriggerStyle}
|
||||
<DropdownTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.host.appearance.color.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
leading={leading}
|
||||
>
|
||||
<ColorSwatch color={color} />
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{HOST_COLORS.map((option) => (
|
||||
<ColorMenuItem
|
||||
@@ -166,17 +156,15 @@ function BadgeDisplayRow({
|
||||
<Text style={settingsStyles.rowTitle}>{t("settings.host.appearance.badge.label")}</Text>
|
||||
</View>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
<DropdownTrigger
|
||||
testID="host-appearance-badge-display"
|
||||
style={dropdownTriggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.host.appearance.badge.accessibilityLabel", {
|
||||
value: selectedLabel,
|
||||
})}
|
||||
>
|
||||
<Text style={styles.triggerText}>{selectedLabel}</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
{selectedLabel}
|
||||
</DropdownTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" width={200}>
|
||||
{HOST_BADGE_DISPLAYS.map((option) => (
|
||||
<BadgeDisplayMenuItem
|
||||
@@ -312,23 +300,6 @@ export function HostAppearanceSection({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
triggerPressed: {
|
||||
opacity: 0.85,
|
||||
},
|
||||
triggerText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
swatch: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
|
||||
@@ -375,12 +375,6 @@ export function HostSettingsPage({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={styles.daemonHeader}>
|
||||
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
|
||||
{host.label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<HostStatusBadges serverId={serverId} />
|
||||
<HostConnectionError serverId={serverId} />
|
||||
|
||||
@@ -1714,18 +1708,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginHorizontal: theme.spacing[4],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
daemonHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
daemonHeaderLabel: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
identityBadges: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
+41
-8
@@ -5,6 +5,7 @@ import {
|
||||
useAppSettings,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type ServiceUrlBehavior,
|
||||
} from "@/hooks/use-settings";
|
||||
|
||||
const SOURCES = [
|
||||
@@ -15,18 +16,26 @@ const SOURCES = [
|
||||
"subagents",
|
||||
] as const satisfies readonly (keyof OpenInSidePanePreferences)[];
|
||||
|
||||
type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests";
|
||||
const SERVICE_URL_BEHAVIORS: readonly ServiceUrlBehavior[] = ["ask", "in-app", "external"];
|
||||
|
||||
function LayoutPreferenceRow({
|
||||
const SERVICE_URL_LABEL_KEYS: Record<ServiceUrlBehavior, string> = {
|
||||
ask: "settings.general.serviceUrls.options.ask",
|
||||
"in-app": "settings.general.serviceUrls.options.inApp",
|
||||
external: "settings.general.serviceUrls.options.external",
|
||||
};
|
||||
|
||||
type OpenLocationSource = keyof OpenInSidePanePreferences | "pullRequests";
|
||||
|
||||
function OpenLocationRow({
|
||||
source,
|
||||
destination,
|
||||
allowExplorer,
|
||||
onDestinationChange,
|
||||
}: {
|
||||
source: LayoutPreferenceSource;
|
||||
source: OpenLocationSource;
|
||||
destination: PullRequestOpenLocation;
|
||||
allowExplorer?: boolean;
|
||||
onDestinationChange(source: LayoutPreferenceSource, destination: PullRequestOpenLocation): void;
|
||||
onDestinationChange(source: OpenLocationSource, destination: PullRequestOpenLocation): void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(() => {
|
||||
@@ -52,11 +61,34 @@ function LayoutPreferenceRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutSection() {
|
||||
function ServiceUrlRow() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const options = useMemo(
|
||||
() =>
|
||||
SERVICE_URL_BEHAVIORS.map((value) => ({ value, label: t(SERVICE_URL_LABEL_KEYS[value]) })),
|
||||
[t],
|
||||
);
|
||||
const change = useCallback(
|
||||
(serviceUrlBehavior: ServiceUrlBehavior) => void updateSettings({ serviceUrlBehavior }),
|
||||
[updateSettings],
|
||||
);
|
||||
return (
|
||||
<SettingsSelect
|
||||
label={t("settings.layout.openInSidePane.sources.serviceUrls.label")}
|
||||
value={settings.serviceUrlBehavior}
|
||||
options={options}
|
||||
onValueChange={change}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where things open: files, diffs, subagents, pull requests, and script URLs. Desktop only. */
|
||||
export function OpenLocationSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const handleDestinationChange = useCallback(
|
||||
(source: LayoutPreferenceSource, destination: PullRequestOpenLocation) => {
|
||||
(source: OpenLocationSource, destination: PullRequestOpenLocation) => {
|
||||
if (source === "pullRequests") {
|
||||
void updateSettings({ pullRequestOpenLocation: destination });
|
||||
return;
|
||||
@@ -71,19 +103,20 @@ export function LayoutSection() {
|
||||
<SettingsSection title={t("settings.layout.openInSidePane.title")}>
|
||||
<SettingsCard>
|
||||
{SOURCES.map((source) => (
|
||||
<LayoutPreferenceRow
|
||||
<OpenLocationRow
|
||||
key={source}
|
||||
source={source}
|
||||
destination={settings.openInSidePane[source] ? "side" : "main"}
|
||||
onDestinationChange={handleDestinationChange}
|
||||
/>
|
||||
))}
|
||||
<LayoutPreferenceRow
|
||||
<OpenLocationRow
|
||||
source="pullRequests"
|
||||
destination={settings.pullRequestOpenLocation}
|
||||
allowExplorer
|
||||
onDestinationChange={handleDestinationChange}
|
||||
/>
|
||||
<ServiceUrlRow />
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsSection } from "@/components/settings";
|
||||
import { FormTextInput } from "@/components/ui/form-field";
|
||||
import { parseTerminalScrollbackLines, useAppSettings } from "@/hooks/use-settings";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
export function TerminalSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const [scrollbackValue, setScrollbackValue] = useState(String(settings.terminalScrollbackLines));
|
||||
|
||||
const handleChangeText = useCallback((value: string) => {
|
||||
setScrollbackValue(value.replace(/[^\d]/g, ""));
|
||||
}, []);
|
||||
|
||||
const commitScrollback = useCallback(() => {
|
||||
const nextValue =
|
||||
parseTerminalScrollbackLines(scrollbackValue) ?? settings.terminalScrollbackLines;
|
||||
setScrollbackValue(String(nextValue));
|
||||
if (nextValue !== settings.terminalScrollbackLines) {
|
||||
void updateSettings({ terminalScrollbackLines: nextValue });
|
||||
}
|
||||
}, [scrollbackValue, settings.terminalScrollbackLines, updateSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
setScrollbackValue(String(settings.terminalScrollbackLines));
|
||||
}, [settings.terminalScrollbackLines]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("settings.sections.terminal")}>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>
|
||||
{t("settings.general.terminalScrollback.label")}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{t("settings.general.terminalScrollback.description")}
|
||||
</Text>
|
||||
</View>
|
||||
<FormTextInput
|
||||
size="sm"
|
||||
initialValue={scrollbackValue}
|
||||
onChangeText={handleChangeText}
|
||||
onBlur={commitScrollback}
|
||||
onSubmitEditing={commitScrollback}
|
||||
keyboardType="number-pad"
|
||||
inputMode="numeric"
|
||||
selectTextOnFocus
|
||||
style={styles.scrollbackInput}
|
||||
accessibilityLabel={t("settings.general.terminalScrollback.accessibilityLabel")}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollbackInput: {
|
||||
width: 112,
|
||||
textAlign: "right",
|
||||
},
|
||||
});
|
||||
@@ -159,6 +159,20 @@ const darkStatusColors = {
|
||||
statusMerged: "#a890d5",
|
||||
};
|
||||
|
||||
// Status tints — the fill of a status badge. The status color itself at low opacity, so a
|
||||
// badge takes its state's hue from the same source as its text and never drifts from it.
|
||||
// Dark surfaces swallow more of the tint, so the dark band runs a little stronger.
|
||||
function statusTints(colors: typeof lightStatusColors, alphaHex: string) {
|
||||
return {
|
||||
statusSuccessTint: `${colors.statusSuccess}${alphaHex}`,
|
||||
statusDangerTint: `${colors.statusDanger}${alphaHex}`,
|
||||
statusWarningTint: `${colors.statusWarning}${alphaHex}`,
|
||||
};
|
||||
}
|
||||
|
||||
const lightStatusTints = statusTints(lightStatusColors, "1f"); // 12%
|
||||
const darkStatusTints = statusTints(darkStatusColors, "29"); // 16%
|
||||
|
||||
// Status *dot* colors — the small filled discs on a sidebar row, and the glyphs that stand in
|
||||
// for them. Same four hues and the same generation rule as the status colors above, but its
|
||||
// own band, because a dot is doing a different job than a check icon or a host badge.
|
||||
@@ -289,6 +303,7 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
|
||||
|
||||
...lightDiffColors,
|
||||
...lightStatusColors,
|
||||
...lightStatusTints,
|
||||
...lightStatusDotColors,
|
||||
|
||||
terminal: {
|
||||
@@ -420,6 +435,7 @@ export function buildDarkSemanticColors(tint: DarkThemeConfig) {
|
||||
|
||||
...darkDiffColors,
|
||||
...darkStatusColors,
|
||||
...darkStatusTints,
|
||||
...darkStatusDotColors,
|
||||
|
||||
terminal: {
|
||||
|
||||
@@ -492,7 +492,10 @@ export function resolveKnownHostRoute(input: {
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"general",
|
||||
"appearance",
|
||||
"layout",
|
||||
"sidebar",
|
||||
"chat",
|
||||
"terminal",
|
||||
"browser",
|
||||
"editor",
|
||||
"shortcuts",
|
||||
"integrations",
|
||||
|
||||
@@ -7,6 +7,10 @@ const testTheme = {
|
||||
statusDanger: "#b91c1c",
|
||||
statusWarning: "#d97706",
|
||||
statusMerged: "#7c3aed",
|
||||
// Same rule as the real theme: the status color at 12%.
|
||||
statusSuccessTint: "#15803d1f",
|
||||
statusDangerTint: "#b91c1c1f",
|
||||
statusWarningTint: "#d977061f",
|
||||
// The light band's values, so a test can name the colour it expects.
|
||||
statusDotSuccess: "#299f51",
|
||||
statusDotDanger: "#f12e2f",
|
||||
|
||||
@@ -9,7 +9,10 @@ const MAX_TRANSIENT_DETACHED_PANES = 2;
|
||||
const SETTINGS_DESTINATIONS = [
|
||||
"General",
|
||||
"Appearance",
|
||||
"Layout",
|
||||
"Sidebar",
|
||||
"Chat",
|
||||
"Terminal",
|
||||
"Browser",
|
||||
"Editor",
|
||||
"Shortcuts",
|
||||
"Integrations",
|
||||
|
||||
Reference in New Issue
Block a user