Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 78beed3df2 |
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
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user