Compare commits

...

1 Commits

Author SHA1 Message Date
sabhya-db 78beed3df2 feat(ui): Add interface density setting
- Persist Compact, Comfortable, and Spacious choices with pre-paint root application
- Centralize chrome spacing tokens with mobile-safe Compact behavior
- Cover settings, persistence, reload behavior, and light/dark visuals
2026-07-15 16:40:36 -07:00
21 changed files with 484 additions and 38 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

@@ -0,0 +1,53 @@
"""E2E coverage for live and reload-persistent interface density."""
from __future__ import annotations
from playwright.sync_api import Page, expect
STORAGE_KEY = "omnigent:interface-density"
def _root_density(page: Page) -> str | None:
return page.locator("html").get_attribute("data-density")
def _sidebar_row_padding(page: Page) -> str:
return page.evaluate(
"() => getComputedStyle(document.documentElement)"
".getPropertyValue('--density-sidebar-row-padding-y').trim()"
)
def test_interface_density_applies_live_and_survives_reload(
page: Page, seeded_session: tuple[str, str]
) -> None:
base_url, _session_id = seeded_session
page.goto(f"{base_url}/settings/appearance")
group = page.get_by_role("radiogroup", name="Interface density")
expect(group).to_be_visible(timeout=30_000)
assert _root_density(page) == "comfortable"
assert _sidebar_row_padding(page) == "0.5rem"
assert page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") is None
page.get_by_test_id("density-compact").click()
expect(page.get_by_test_id("density-compact")).to_have_attribute("aria-checked", "true")
assert _root_density(page) == "compact"
assert _sidebar_row_padding(page) == "0.25rem"
page.get_by_test_id("density-spacious").click()
expect(page.get_by_test_id("density-spacious")).to_have_attribute("aria-checked", "true")
assert _root_density(page) == "spacious"
assert _sidebar_row_padding(page) == "0.75rem"
assert page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") == "spacious"
page.reload()
expect(group).to_be_visible(timeout=30_000)
expect(page.get_by_test_id("density-spacious")).to_have_attribute("aria-checked", "true")
assert _root_density(page) == "spacious"
assert _sidebar_row_padding(page) == "0.75rem"
page.get_by_test_id("density-reset").click()
expect(page.get_by_test_id("density-comfortable")).to_have_attribute("aria-checked", "true")
assert _root_density(page) == "comfortable"
assert page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") is None
+14
View File
@@ -7,6 +7,20 @@
<meta name="theme-color" content="#0d1218" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
// Keep in sync with src/lib/interfaceDensity.ts. This must run in <head>
// so persisted spacing is present before the first stylesheet paint.
(() => {
let density = "comfortable";
try {
const stored = localStorage.getItem("omnigent:interface-density");
if (stored === "compact" || stored === "spacious") density = stored;
} catch {
// Storage can be blocked in sandboxed or private contexts.
}
document.documentElement.dataset.density = density;
})();
</script>
<title>Omnigent</title>
</head>
<body>
+1 -1
View File
@@ -260,7 +260,7 @@ export const ComposerMicButton = ({
aria-label={a11yLabel}
title={tooltip}
className={cn(
"size-9 md:size-8",
"size-[var(--density-button-icon)]",
isListening &&
"bg-muted/60 text-foreground hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive",
error && "text-destructive",
+8 -8
View File
@@ -27,16 +27,16 @@ const buttonVariants = cva(
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-10 md:size-8",
"h-[var(--density-button-height)] gap-[var(--density-control-gap)] px-[var(--density-button-padding-x)] has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-[var(--density-button-height-xs)] gap-[var(--density-control-gap-sm)] rounded-[min(var(--radius-md),10px)] px-[var(--density-button-padding-x-xs)] text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-[var(--density-button-height-sm)] gap-[var(--density-control-gap-sm)] rounded-[min(var(--radius-md),12px)] px-[var(--density-button-padding-x-sm)] text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-[var(--density-button-height-lg)] gap-[var(--density-control-gap)] px-[var(--density-button-padding-x-lg)] has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-[var(--density-button-icon)]",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"size-[var(--density-button-icon-xs)] rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
"size-[var(--density-button-icon-sm)] rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-[var(--density-button-icon-lg)]",
},
},
defaultVariants: {
+2
View File
@@ -35,6 +35,7 @@ import { resolveServerInfo, type ServerInfo } from "./lib/capabilities";
import { EmbeddedProvider } from "./lib/embedded";
import { type OmnigentHostConfig, setEmbedRoot, setOmnigentHostConfig } from "./lib/host";
import { resolveIdentity } from "./lib/identity";
import { applyInterfaceDensity, readInterfaceDensity } from "./lib/interfaceDensity";
import {
type RoutingApi,
RoutingProvider,
@@ -173,6 +174,7 @@ function OmnigentProviders({
// `.dark` token overrides too.
const scopeRef = useCallback((el: HTMLDivElement | null) => {
setEmbedRoot(el);
if (el) applyInterfaceDensity(readInterfaceDensity());
}, []);
return (
+135
View File
@@ -109,6 +109,37 @@
* the whole rem-based UI scales. Overridden at runtime on documentElement. */
--ui-font-scale: 1;
/* Interface density changes chrome geometry only. Comfortable mirrors the
* pre-setting metrics; semantic consumers below keep spacing coordinated. */
--density-button-height: 2rem;
--density-button-height-xs: 1.5rem;
--density-button-height-sm: 1.75rem;
--density-button-height-lg: 2.25rem;
--density-button-icon: 2rem;
--density-button-icon-xs: 1.5rem;
--density-button-icon-sm: 1.75rem;
--density-button-icon-lg: 2.25rem;
--density-button-padding-x: 0.625rem;
--density-button-padding-x-xs: 0.5rem;
--density-button-padding-x-sm: 0.625rem;
--density-button-padding-x-lg: 0.625rem;
--density-control-gap: 0.375rem;
--density-control-gap-sm: 0.25rem;
--density-sidebar-row-padding-y: 0.5rem;
--density-toolbar-padding-x: 0.5rem;
--density-toolbar-padding-y: 0.75rem;
--density-toolbar-gap: 0.25rem;
--density-composer-padding-x: 1rem;
--density-composer-outer-x: 1.5rem;
--density-composer-padding-top: 0.75rem;
--density-composer-padding-bottom: 0.5rem;
--density-composer-actions-padding: 0.5rem;
--density-settings-gap: 2rem;
--density-settings-row-gap: 0.75rem;
--density-workspace-padding-x: 0.5rem;
--density-workspace-padding-y: 0.375rem;
--density-workspace-row-padding-y: 0.375rem;
/* User-controlled UI font family (see Appearance settings /
* lib/uiFontPreferences.ts). Deliberately left unset here so the `html` rule's
* `var(--ui-font-family, var(--font-sans))` falls back to the system stack;
@@ -245,6 +276,71 @@
--sidebar-ring: #2272b4;
}
:root[data-density="spacious"] {
--density-button-height: 2.25rem;
--density-button-height-xs: 1.75rem;
--density-button-height-sm: 2rem;
--density-button-height-lg: 2.5rem;
--density-button-icon: 2.25rem;
--density-button-icon-xs: 1.75rem;
--density-button-icon-sm: 2rem;
--density-button-icon-lg: 2.5rem;
--density-button-padding-x: 0.875rem;
--density-button-padding-x-xs: 0.625rem;
--density-button-padding-x-sm: 0.75rem;
--density-button-padding-x-lg: 0.875rem;
--density-control-gap: 0.5rem;
--density-control-gap-sm: 0.375rem;
--density-sidebar-row-padding-y: 0.75rem;
--density-toolbar-padding-x: 0.75rem;
--density-toolbar-padding-y: 0.625rem;
--density-toolbar-gap: 0.375rem;
--density-composer-padding-x: 1.25rem;
--density-composer-outer-x: 2rem;
--density-composer-padding-top: 1rem;
--density-composer-padding-bottom: 0.75rem;
--density-composer-actions-padding: 0.75rem;
--density-settings-gap: 2.5rem;
--density-settings-row-gap: 1rem;
--density-workspace-padding-x: 0.75rem;
--density-workspace-padding-y: 0.5rem;
--density-workspace-row-padding-y: 0.5rem;
}
/* Compact is desktop-only so phone-sized controls keep their current targets. */
@media (width >= 48rem) {
:root[data-density="compact"] {
--density-button-height: 1.75rem;
--density-button-height-xs: 1.375rem;
--density-button-height-sm: 1.5rem;
--density-button-height-lg: 2rem;
--density-button-icon: 1.75rem;
--density-button-icon-xs: 1.375rem;
--density-button-icon-sm: 1.5rem;
--density-button-icon-lg: 2rem;
--density-button-padding-x: 0.5rem;
--density-button-padding-x-xs: 0.375rem;
--density-button-padding-x-sm: 0.5rem;
--density-button-padding-x-lg: 0.5rem;
--density-control-gap: 0.25rem;
--density-control-gap-sm: 0.1875rem;
--density-sidebar-row-padding-y: 0.25rem;
--density-toolbar-padding-x: 0.375rem;
--density-toolbar-padding-y: 0.5rem;
--density-toolbar-gap: 0.1875rem;
--density-composer-padding-x: 0.75rem;
--density-composer-outer-x: 1rem;
--density-composer-padding-top: 0.5rem;
--density-composer-padding-bottom: 0.375rem;
--density-composer-actions-padding: 0.375rem;
--density-settings-gap: 1.5rem;
--density-settings-row-gap: 0.5rem;
--density-workspace-padding-x: 0.375rem;
--density-workspace-padding-y: 0.25rem;
--density-workspace-row-padding-y: 0.25rem;
}
}
.dark {
/* Dark mode tokens — semi-transparent for glassmorphism backdrop-filter */
--background: #0d1218;
@@ -795,6 +891,45 @@
html {
font-size: calc(1.125em * var(--ui-font-scale));
}
:root {
--density-button-icon: 2.5rem;
--density-composer-outer-x: 1rem;
}
}
/* Semantic density consumers. These are intentionally limited to chrome;
* message prose, Monaco, xterm, and rendered file content never opt in. */
.density-sidebar-row {
padding-block: var(--density-sidebar-row-padding-y);
}
.density-toolbar {
padding-inline: var(--density-toolbar-padding-x);
padding-block: var(--density-toolbar-padding-y);
gap: var(--density-toolbar-gap);
}
.density-composer-input {
padding-inline: var(--density-composer-padding-x);
padding-top: var(--density-composer-padding-top);
padding-bottom: var(--density-composer-padding-bottom);
}
.density-composer-shell {
padding-inline: var(--density-composer-outer-x);
}
.density-composer-actions {
padding: var(--density-composer-actions-padding);
}
.density-settings-list {
gap: var(--density-settings-gap);
}
.density-settings-row {
gap: var(--density-settings-row-gap);
}
.density-workspace-toolbar {
padding-inline: var(--density-workspace-padding-x);
padding-block: var(--density-workspace-padding-y);
}
.density-workspace-row {
padding-block: var(--density-workspace-row-padding-y);
}
/* Geist Mono renders wider than Geist Sans at the same point size, making
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyInterfaceDensity,
DEFAULT_INTERFACE_DENSITY,
normalizeInterfaceDensity,
readInterfaceDensity,
writeInterfaceDensity,
} from "./interfaceDensity";
const STORAGE_KEY = "omnigent:interface-density";
afterEach(() => {
localStorage.clear();
document.documentElement.removeAttribute("data-density");
vi.restoreAllMocks();
});
describe("interface density persistence", () => {
it("defaults to Comfortable without writing storage", () => {
expect(readInterfaceDensity()).toBe(DEFAULT_INTERFACE_DENSITY);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it("round-trips Compact and Spacious and clears storage for Comfortable", () => {
writeInterfaceDensity("compact");
expect(readInterfaceDensity()).toBe("compact");
expect(localStorage.getItem(STORAGE_KEY)).toBe("compact");
writeInterfaceDensity("spacious");
expect(readInterfaceDensity()).toBe("spacious");
expect(localStorage.getItem(STORAGE_KEY)).toBe("spacious");
writeInterfaceDensity("comfortable");
expect(readInterfaceDensity()).toBe("comfortable");
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
it("normalizes unknown or corrupt values to Comfortable", () => {
localStorage.setItem(STORAGE_KEY, "dense");
expect(readInterfaceDensity()).toBe("comfortable");
expect(normalizeInterfaceDensity(null)).toBe("comfortable");
});
});
describe("interface density root attribute", () => {
it("applies every supported value to the document root", () => {
for (const density of ["compact", "comfortable", "spacious"] as const) {
applyInterfaceDensity(density);
expect(document.documentElement).toHaveAttribute("data-density", density);
}
});
it("also applies to the scoped root used by the embedded app", () => {
const embedRoot = document.createElement("div");
embedRoot.className = "omnigent-app";
document.body.append(embedRoot);
applyInterfaceDensity("spacious");
expect(embedRoot).toHaveAttribute("data-density", "spacious");
embedRoot.remove();
});
it("still applies live when persistence is unavailable", () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
expect(() => writeInterfaceDensity("compact")).not.toThrow();
expect(document.documentElement).toHaveAttribute("data-density", "compact");
});
});
+52
View File
@@ -0,0 +1,52 @@
const STORAGE_KEY = "omnigent:interface-density";
export const interfaceDensities = ["compact", "comfortable", "spacious"] as const;
export type InterfaceDensity = (typeof interfaceDensities)[number];
export const DEFAULT_INTERFACE_DENSITY: InterfaceDensity = "comfortable";
export function isInterfaceDensity(value: string | null | undefined): value is InterfaceDensity {
return value === "compact" || value === "comfortable" || value === "spacious";
}
export function normalizeInterfaceDensity(value: string | null | undefined): InterfaceDensity {
return isInterfaceDensity(value) ? value : DEFAULT_INTERFACE_DENSITY;
}
/** Read the device-local density preference without letting storage block app boot. */
export function readInterfaceDensity(): InterfaceDensity {
if (typeof window === "undefined") return DEFAULT_INTERFACE_DENSITY;
try {
return normalizeInterfaceDensity(window.localStorage.getItem(STORAGE_KEY));
} catch {
return DEFAULT_INTERFACE_DENSITY;
}
}
/** Apply density to the root so every chrome surface updates in the same frame. */
export function applyInterfaceDensity(value: InterfaceDensity): void {
if (typeof document === "undefined") return;
const normalized = normalizeInterfaceDensity(value);
document.documentElement.dataset.density = normalized;
// The embed build remaps :root selectors onto its scoped root.
document.querySelectorAll<HTMLElement>(".omnigent-app").forEach((root) => {
root.dataset.density = normalized;
});
}
/** Persist non-default choices; Comfortable stays represented by an absent key. */
export function writeInterfaceDensity(value: InterfaceDensity): void {
const normalized = normalizeInterfaceDensity(value);
if (typeof window !== "undefined") {
try {
if (normalized === DEFAULT_INTERFACE_DENSITY) {
window.localStorage.removeItem(STORAGE_KEY);
} else {
window.localStorage.setItem(STORAGE_KEY, normalized);
}
} catch {
// A blocked localStorage must not prevent the live density change.
}
}
applyInterfaceDensity(normalized);
}
+5
View File
@@ -15,6 +15,7 @@ import { CapabilitiesProvider } from "./lib/CapabilitiesContext";
import { resolveIdentity } from "./lib/identity";
import { initNativeInsets } from "./lib/nativeInsets";
import { initBrowserTelemetry } from "./lib/telemetry";
import { applyInterfaceDensity, readInterfaceDensity } from "./lib/interfaceDensity";
import {
applyUiFontFamily,
applyUiFontScale,
@@ -61,6 +62,10 @@ initNativeInsets();
applyUiFontScale(readUiFontSizePx());
applyUiFontFamily(readUiFontFamily());
// The inline boot script applies this before the stylesheet can paint. Repeat
// through the typed preference API so non-HTML entry points get the same state.
applyInterfaceDensity(readInterfaceDensity());
// Apply the saved color palette (data-theme on <html>) before first paint too,
// so the app renders in the chosen theme rather than flashing the brand default.
applyCustomTheme(readCustomTheme());
+6 -7
View File
@@ -4570,7 +4570,7 @@ export function Composer({
<form
onSubmit={handleSubmit}
className={cn(
"chat-composer-form px-4 md:px-6",
"chat-composer-form density-composer-shell",
isTerminalFirst ? "terminal-first-composer-form pb-1.5" : "pb-3",
)}
>
@@ -4697,7 +4697,7 @@ export function Composer({
ref={backdropRef}
aria-hidden
data-testid="composer-highlight-overlay"
className="pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words px-4 pt-3 pb-2 text-sm text-foreground"
className="density-composer-input pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words text-sm text-foreground"
>
{(() => {
const split = splitSlashCommand(value);
@@ -4779,7 +4779,7 @@ export function Composer({
disabled={disabled || isReadOnly || unreachable || hasPendingElicitation}
data-slash-command={composerIsCommand ? "true" : undefined}
className={cn(
"relative w-full resize-none bg-transparent px-4 pt-3 pb-2 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-60",
"density-composer-input relative w-full resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:opacity-60",
// Hand glyph painting to the overlay while a command is drafted;
// the caret stays visible via caret-foreground.
composerIsCommand && "text-transparent caret-foreground",
@@ -4859,14 +4859,13 @@ export function Composer({
{commandError}
</div>
)}
<div className="flex items-center justify-between gap-2 px-2 pb-2">
<div className="density-composer-actions flex items-center justify-between gap-2">
{/* Attach + mic — left side of the action row */}
<div className="flex shrink-0 items-center gap-0.5">
<Button
type="button"
size="icon"
variant="ghost"
className="size-9 md:size-8"
disabled={disabled || isReadOnly || hasPendingElicitation}
onClick={() => fileInputRef.current?.click()}
title="Attach files"
@@ -4909,7 +4908,7 @@ export function Composer({
size="sm"
variant={codexPlanMode ? "secondary" : "ghost"}
className={cn(
"h-9 gap-1.5 px-2 text-xs md:h-8",
"h-[var(--density-button-height)] gap-[var(--density-control-gap)] px-[var(--density-button-padding-x)] text-xs",
codexPlanMode && "border border-ring/30 text-foreground",
)}
disabled={isReadOnly || planModeBusy}
@@ -4960,7 +4959,7 @@ export function Composer({
// overrides the base 50% disabled-opacity so the affordance
// reads as "waiting for input", not "almost active".
className={cn(
"size-9 shrink-0 rounded-full md:size-8",
"size-[var(--density-button-icon)] shrink-0 rounded-full",
!showInterruptButton && "hover:bg-primary/90 disabled:opacity-30",
)}
// Interrupt stays live during a pending elicitation —
+27 -1
View File
@@ -196,6 +196,7 @@ afterEach(() => {
// The palette picker sets data-theme on <html>; clear it so a palette
// selected in one test doesn't leak into the next.
document.documentElement.removeAttribute("data-theme");
document.documentElement.removeAttribute("data-density");
document.documentElement.removeAttribute("data-custom-translucent-sidebar");
for (const property of Array.from(document.documentElement.style)) {
if (property.startsWith("--custom-")) document.documentElement.style.removeProperty(property);
@@ -212,6 +213,29 @@ describe("SettingsPage", () => {
expect(mocks.setTheme).toHaveBeenCalledWith("dark");
});
it("applies and persists all interface density choices and resets to Comfortable", () => {
renderPage("/settings/appearance");
expect(screen.getByRole("radiogroup", { name: "Interface density" })).toBeInTheDocument();
expect(screen.getByTestId("density-comfortable")).toHaveAttribute("aria-checked", "true");
expect(screen.getByTestId("density-reset")).toBeDisabled();
fireEvent.click(screen.getByTestId("density-compact"));
expect(document.documentElement).toHaveAttribute("data-density", "compact");
expect(localStorage.getItem("omnigent:interface-density")).toBe("compact");
expect(screen.getByTestId("density-compact")).toHaveAttribute("aria-checked", "true");
fireEvent.click(screen.getByTestId("density-spacious"));
expect(document.documentElement).toHaveAttribute("data-density", "spacious");
expect(localStorage.getItem("omnigent:interface-density")).toBe("spacious");
expect(screen.getByTestId("density-spacious")).toHaveAttribute("aria-checked", "true");
fireEvent.click(screen.getByTestId("density-reset"));
expect(document.documentElement).toHaveAttribute("data-density", "comfortable");
expect(localStorage.getItem("omnigent:interface-density")).toBeNull();
expect(screen.getByTestId("density-comfortable")).toHaveAttribute("aria-checked", "true");
});
it("renders the Terminal theme radiogroup with auto selected by default", () => {
renderPage("/settings/appearance");
expect(screen.getByRole("radiogroup", { name: "Terminal theme" })).toBeInTheDocument();
@@ -221,11 +245,13 @@ describe("SettingsPage", () => {
expect(localStorage.getItem("omnigent:terminal-theme")).toBeNull();
});
it("renders Terminal theme before Color theme", () => {
it("renders Interface density below the theme controls", () => {
renderPage("/settings/appearance");
const terminal = screen.getByText("Terminal theme");
const color = screen.getByText("Color theme");
const density = screen.getByText("Interface density");
expect(terminal.compareDocumentPosition(color) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(color.compareDocumentPosition(density) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("persists dark and light terminal theme choices on card click", () => {
+95 -7
View File
@@ -149,6 +149,12 @@ import {
type CustomTheme,
writeCustomTheme,
} from "@/lib/customTheme";
import {
DEFAULT_INTERFACE_DENSITY,
type InterfaceDensity,
readInterfaceDensity,
writeInterfaceDensity,
} from "@/lib/interfaceDensity";
import { useIsEmbedded } from "@/lib/embedded";
import { type CliStatus, getCliStatus, isElectronShell, resetCliPath } from "@/lib/nativeBridge";
import { cn } from "@/lib/utils";
@@ -244,6 +250,12 @@ const terminalThemeCards: { mode: TerminalThemeMode; label: string; icon: typeof
{ mode: "dark", label: "Dark", icon: MoonIcon },
];
const densityCards: { value: InterfaceDensity; label: string }[] = [
{ value: "compact", label: "Compact" },
{ value: "comfortable", label: "Comfortable" },
{ value: "spacious", label: "Spacious" },
];
const workspacePanelCards: {
value: WorkspacePanelDefault;
label: string;
@@ -424,26 +436,100 @@ function ThemeSubsection({
labelId,
title,
helper,
action,
children,
}: {
labelId: string;
title: string;
helper: string;
action?: ReactNode;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col">
<span id={labelId} className="text-sm font-medium">
{title}
</span>
<span className="text-sm text-muted-foreground">{helper}</span>
<div className="density-settings-row flex flex-col">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col">
<span id={labelId} className="text-sm font-medium">
{title}
</span>
<span className="text-sm text-muted-foreground">{helper}</span>
</div>
{action}
</div>
{children}
</div>
);
}
/** Abstract row stack that previews spacing without changing text size. */
function DensityPreview({ density }: { density: InterfaceDensity }) {
const gap = density === "compact" ? 3 : density === "spacious" ? 7 : 5;
const rowHeight = density === "compact" ? 7 : density === "spacious" ? 13 : 10;
return (
<div
aria-hidden
className="flex h-16 w-full flex-col justify-center rounded-md border border-border bg-background px-3"
style={{ gap }}
>
{[0.76, 0.92, 0.64].map((width) => (
<span
key={width}
className="block rounded-sm bg-muted-foreground/25"
style={{ height: rowHeight, width: `${width * 100}%` }}
/>
))}
</div>
);
}
/** UI chrome spacing: Compact / Comfortable / Spacious. */
function InterfaceDensityControl() {
const [density, setDensity] = useState<InterfaceDensity>(() => readInterfaceDensity());
const labelId = useId();
const choose = useCallback((next: InterfaceDensity) => {
setDensity(next);
writeInterfaceDensity(next);
}, []);
return (
<ThemeSubsection
labelId={labelId}
title="Interface density"
helper="Adjust spacing in navigation, controls, and workspace chrome. Text size is unchanged."
action={
<Button
type="button"
variant="ghost"
size="sm"
disabled={density === DEFAULT_INTERFACE_DENSITY}
onClick={() => choose(DEFAULT_INTERFACE_DENSITY)}
data-testid="density-reset"
>
Reset
</Button>
}
>
<CardRadioGroup<InterfaceDensity>
labelledBy={labelId}
value={density}
onSelect={choose}
className="grid grid-cols-1 gap-3 sm:grid-cols-3"
cardClassName="gap-2 p-2"
items={densityCards.map((card) => ({
value: card.value,
testId: `density-${card.value}`,
body: (
<>
<DensityPreview density={card.value} />
<span className="text-center text-sm font-medium">{card.label}</span>
</>
),
}))}
/>
</ThemeSubsection>
);
}
/** Appearance mode: System / Light / Dark. */
function ModeControl() {
const { theme, setTheme } = useTheme();
@@ -800,7 +886,7 @@ function AppearanceSection() {
return (
<Section title="Appearance" description="Choose how Omnigent looks on this device.">
<div className="flex flex-col gap-8">
<div className="density-settings-list flex flex-col">
{isEmbedded ? (
<div className="flex flex-col gap-3">
<span className="text-sm font-medium">Theme</span>
@@ -816,6 +902,8 @@ function AppearanceSection() {
{!isEmbedded && <ColorThemeControl />}
<InterfaceDensityControl />
<WorkspacePanelDefaultControl />
<HideUnconfiguredHarnessesControl />
+2 -2
View File
@@ -181,7 +181,7 @@ export function ChatHeader({
// Scrolled chat text can't render through the controls because the
// conversation viewport fades its top edge instead (chat-scroll-fade
// in index.css, applied in ChatPage).
"chat-header absolute inset-x-0 top-0 z-30 flex h-14 items-center justify-between px-2 py-3",
"chat-header density-toolbar absolute inset-x-0 top-0 z-30 flex h-14 items-center justify-between",
)}
>
{/* Left slot: sidebar toggle (when sidebar is closed) and a
@@ -258,7 +258,7 @@ export function ChatHeader({
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-[var(--density-toolbar-gap)]">
{/* Other users currently viewing this session (presence).
Self-contained — reads the chat store directly, renders
nothing when the user is alone. */}
+3 -3
View File
@@ -356,7 +356,7 @@ export function FilesPanel({
)}
>
{/* Header — single row: [title · workingDir] [eye] [close?] */}
<div className="flex shrink-0 items-center gap-2 px-3 py-2">
<div className="density-workspace-toolbar flex shrink-0 items-center gap-2">
<span className="shrink-0 font-medium text-sm">Working folder</span>
{workingDir && <WorkingDirLabel dir={workingDir} />}
<div className="ml-auto flex items-center gap-1">
@@ -386,7 +386,7 @@ export function FilesPanel({
scroll container so negative margins aren't clipped. */}
{flatView && (
<div
className="shrink-0 flex items-center gap-2 px-2 py-1.5 @max-[400px]/filespanel:flex-col @max-[400px]/filespanel:items-stretch"
className="density-workspace-toolbar shrink-0 flex items-center gap-2 @max-[400px]/filespanel:flex-col @max-[400px]/filespanel:items-stretch"
onClick={(e) => e.stopPropagation()}
>
<FileScopeSwitch flatView={flatView} onChange={onFlatViewChange} count={changedCount} />
@@ -408,7 +408,7 @@ export function FilesPanel({
)}
{!flatView && (
<div className="shrink-0" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-2 py-1.5 @max-[400px]/filespanel:flex-col @max-[400px]/filespanel:items-stretch">
<div className="density-workspace-toolbar flex items-center gap-2 @max-[400px]/filespanel:flex-col @max-[400px]/filespanel:items-stretch">
<FileScopeSwitch flatView={flatView} onChange={onFlatViewChange} count={changedCount} />
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-[6px] rounded-full border border-border px-[10px] py-[4px] transition-colors focus-within:border-border-strong">
+1 -1
View File
@@ -46,7 +46,7 @@ export function InlineTerminalsSection({ conversationId, onExpand }: InlineTermi
<button
key={terminalTabKey(t)}
type="button"
className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-accent/60"
className="density-workspace-row flex w-full items-center gap-2 px-2 text-left hover:bg-accent/60"
onClick={() => onExpand(terminalTabKey(t))}
>
<TerminalIcon className="size-3.5 shrink-0 text-muted-foreground" />
+5 -5
View File
@@ -1751,7 +1751,7 @@ function SectionHeader({
type="button"
aria-expanded={!collapsed}
onClick={onToggleCollapsed}
className="group flex w-full items-center gap-1 rounded-md px-2 py-1 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
className="density-sidebar-row group flex w-full items-center gap-1 rounded-md px-2 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{icon ? (
// Headers with a leading icon (project folders) swap the folder for a
@@ -2678,7 +2678,7 @@ function ConversationRow({
<Link
to={selectionMode ? "#" : `/c/${conversation.id}`}
className={cn(
"relative flex w-full flex-col gap-0.5 rounded-md px-2 py-2 text-left text-sm hover:bg-muted",
"density-sidebar-row relative flex w-full flex-col gap-0.5 rounded-md px-2 text-left text-sm hover:bg-muted",
!selectionMode && (sessionState?.kind === "awaiting" ? "pr-48 md:pr-29" : "pr-28 md:pr-16"),
selectionMode && "pr-10",
isActive && "bg-muted",
@@ -3101,7 +3101,7 @@ function DeletingRow({
if (isError) {
return (
<div
className="flex w-full items-center gap-1.5 rounded-md px-2 py-2 text-sm"
className="density-sidebar-row flex w-full items-center gap-1.5 rounded-md px-2 text-sm"
data-testid="conversation-delete-failed"
role="alert"
>
@@ -3131,7 +3131,7 @@ function DeletingRow({
}
return (
<div
className="flex w-full items-center gap-1.5 rounded-md px-2 py-2 text-sm text-muted-foreground opacity-70"
className="density-sidebar-row flex w-full items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground opacity-70"
data-testid="conversation-deleting"
aria-live="polite"
>
@@ -3154,7 +3154,7 @@ function DeletingRow({
function ArchivingRow({ label }: { label: string }) {
return (
<div
className="flex w-full items-center gap-1.5 rounded-md px-2 py-2 text-sm text-muted-foreground opacity-70"
className="density-sidebar-row flex w-full items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground opacity-70"
data-testid="conversation-archiving"
aria-live="polite"
>
+1 -1
View File
@@ -172,7 +172,7 @@ function ViewModeToggle({
onViewModeChange: (mode: ViewMode) => void;
}) {
return (
<div className="flex shrink-0 items-center justify-end gap-0.5 border-b px-2 py-1">
<div className="density-workspace-toolbar flex shrink-0 items-center justify-end gap-0.5 border-b">
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="icon-xs"
+1 -1
View File
@@ -52,7 +52,7 @@ export function TodoPanel({ frameless = false }: TodoPanelProps) {
// eslint-disable-next-line react/no-array-index-key
key={i}
className={cn(
"flex items-center gap-2 rounded px-1.5 py-1 text-xs",
"density-workspace-row flex items-center gap-2 rounded px-1.5 text-xs",
todo.status === "completed" && "opacity-50",
)}
>
+1 -1
View File
@@ -302,7 +302,7 @@ export function WorkspacePanel({
overflow-x-auto) and the file region just overflows into it.
overflow-y stays hidden so overflow-x:auto can't spawn a vertical
scrollbar that eats horizontal space. */}
<div className="shrink-0 flex items-center overflow-x-auto overflow-y-hidden border-b border-border px-2 py-1.5 [scrollbar-width:thin] @min-[500px]/rail:overflow-x-hidden [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-track]:bg-transparent">
<div className="density-workspace-toolbar shrink-0 flex items-center overflow-x-auto overflow-y-hidden border-b border-border [scrollbar-width:thin] @min-[500px]/rail:overflow-x-hidden [&::-webkit-scrollbar]:h-1 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-track]:bg-transparent">
<Tabs
// Static group — never compresses (shrink-0) so it stays anchored on
// the left in the ≥500px case and contributes its full width to the