diff --git a/src/features/chat/ui/shell.tsx b/src/features/chat/ui/shell.tsx index 1ba3c9a7..7af1aed7 100644 --- a/src/features/chat/ui/shell.tsx +++ b/src/features/chat/ui/shell.tsx @@ -15,6 +15,7 @@ interface ChatPanelShellMount { props: ChatPanelShellProps; stateStore: ChatStateStore; unsubscribe: () => void; + stopStatusBarClearanceSync: () => void; } const shellMounts = new WeakMap(); @@ -62,8 +63,10 @@ export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShe const existing = shellMounts.get(container); if (existing?.stateStore === props.stateStore) { existing.props = props; + syncStatusBarClearance(container); } else { existing?.unsubscribe(); + existing?.stopStatusBarClearanceSync(); shellMounts.set(container, { props, stateStore: props.stateStore, @@ -72,6 +75,7 @@ export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShe if (!mount) return; renderMountedSlots(container, mount.props); }), + stopStatusBarClearanceSync: startStatusBarClearanceSync(container), }); } renderMountedSlots(container, props); @@ -79,7 +83,9 @@ export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShe export function unmountChatPanelShell(container: HTMLElement | null): void { if (!container) return; - shellMounts.get(container)?.unsubscribe(); + const mount = shellMounts.get(container); + mount?.unsubscribe(); + mount?.stopStatusBarClearanceSync(); shellMounts.delete(container); unmountSlotRoots(container); container.replaceChildren(); @@ -142,3 +148,80 @@ function renderKey(renderVersion: number, snapshot: ChatPanelSlotSnapshot): stri function ensureBody(container: HTMLElement): HTMLElement { return container.querySelector(":scope > .codex-panel__body") ?? container.createDiv({ cls: "codex-panel__body" }); } + +function startStatusBarClearanceSync(container: HTMLElement): () => void { + const win = container.ownerDocument.defaultView; + if (!win) return noop; + + const cleanupCallbacks: (() => void)[] = []; + let observedStatusBar: HTMLElement | null = null; + let statusBarMutationObserver: MutationObserver | null = null; + let statusBarResizeObserver: ResizeObserver | null = null; + + const observeStatusBar = (): void => { + const statusBar = container.ownerDocument.querySelector(".status-bar"); + if (statusBar === observedStatusBar) return; + statusBarMutationObserver?.disconnect(); + statusBarResizeObserver?.disconnect(); + statusBarMutationObserver = null; + statusBarResizeObserver = null; + observedStatusBar = statusBar; + if (!statusBar) return; + + statusBarMutationObserver = new win.MutationObserver(() => { + syncStatusBarClearance(container); + }); + statusBarMutationObserver.observe(statusBar, { attributes: true, attributeFilter: ["class", "style"] }); + + const ResizeObserverCtor = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver; + if (ResizeObserverCtor) { + statusBarResizeObserver = new ResizeObserverCtor(() => { + syncStatusBarClearance(container); + }); + statusBarResizeObserver.observe(statusBar); + } + }; + + const sync = (): void => { + observeStatusBar(); + syncStatusBarClearance(container); + }; + + const bodyObserver = new win.MutationObserver(sync); + bodyObserver.observe(container.ownerDocument.body, { attributes: true, attributeFilter: ["class", "style"], childList: true }); + cleanupCallbacks.push(() => { + bodyObserver.disconnect(); + }); + + win.addEventListener("resize", sync); + cleanupCallbacks.push(() => { + win.removeEventListener("resize", sync); + }); + sync(); + + return () => { + for (const cleanup of cleanupCallbacks) cleanup(); + statusBarMutationObserver?.disconnect(); + statusBarResizeObserver?.disconnect(); + }; +} + +function noop(): void { + return undefined; +} + +function syncStatusBarClearance(container: HTMLElement): void { + container.style.setProperty("--codex-panel-status-bar-clearance", `${String(statusBarClearance(container))}px`); +} + +function statusBarClearance(container: HTMLElement): number { + const win = container.ownerDocument.defaultView; + const statusBar = container.ownerDocument.querySelector(".status-bar"); + if (!win || !statusBar) return 0; + const style = win.getComputedStyle(statusBar); + if (style.display === "none" || style.visibility === "hidden" || style.position !== "fixed") return 0; + const rectHeight = statusBar.getBoundingClientRect().height; + if (Number.isFinite(rectHeight) && rectHeight > 0) return Math.ceil(rectHeight); + const computedHeight = Number.parseFloat(style.height); + return Number.isFinite(computedHeight) && computedHeight > 0 ? Math.ceil(computedHeight) : 0; +} diff --git a/src/styles/00-tokens.css b/src/styles/00-tokens.css index b582a429..3f4ef9d2 100644 --- a/src/styles/00-tokens.css +++ b/src/styles/00-tokens.css @@ -32,7 +32,7 @@ --codex-panel-icon-button-block-size: calc(var(--codex-panel-control-icon-size) + var(--codex-panel-control-gap) * 2); --codex-panel-composer-control-size: var(--codex-panel-icon-button-block-size); --codex-panel-composer-min-height: calc((var(--codex-panel-composer-control-size) * 2) + var(--codex-panel-control-gap)); - --codex-panel-status-bar-clearance: var(--status-bar-height, calc(var(--codex-panel-section-gap) * 3)); + --codex-panel-status-bar-clearance: 0px; --codex-panel-bottom-safe-area: max(env(safe-area-inset-bottom, 0px), var(--codex-panel-status-bar-clearance)); --codex-panel-toolbar-text-color: var(--text-muted); --codex-panel-toolbar-text-size: var(--font-ui-small); diff --git a/tests/features/chat/ui/shell.test.tsx b/tests/features/chat/ui/shell.test.tsx index fe313b60..2a4c40fc 100644 --- a/tests/features/chat/ui/shell.test.tsx +++ b/tests/features/chat/ui/shell.test.tsx @@ -114,6 +114,46 @@ describe("ChatPanelShell", () => { }); }); + it("sets composer bottom clearance only for fixed visible Obsidian status bars", async () => { + const store = createChatStateStore(); + const container = document.createElement("div"); + const statusBar = document.createElement("div"); + statusBar.className = "status-bar"; + document.body.appendChild(statusBar); + document.body.appendChild(container); + Object.defineProperty(statusBar, "getBoundingClientRect", { + configurable: true, + value: () => ({ height: 26, width: 100, top: 0, right: 100, bottom: 26, left: 0, x: 0, y: 0, toJSON: () => ({}) }), + }); + + await act(async () => { + statusBar.style.display = "flex"; + statusBar.style.position = "fixed"; + renderChatPanelShell(container, shellRenderers(store)); + await settleShellEffects(); + }); + expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("26px"); + + await act(async () => { + statusBar.style.position = "static"; + renderChatPanelShell(container, shellRenderers(store)); + await settleShellEffects(); + }); + expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("0px"); + + await act(async () => { + statusBar.style.display = "none"; + renderChatPanelShell(container, shellRenderers(store)); + await settleShellEffects(); + }); + expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("0px"); + + await act(async () => { + unmountChatPanelShell(container); + }); + statusBar.remove(); + }); + it("unmounts existing slot roots before rebuilding a damaged shell scaffold", async () => { const store = createChatStateStore(); const container = document.createElement("div"); diff --git a/tests/styles.test.ts b/tests/styles.test.ts index 1ab3bd67..8d237cda 100644 --- a/tests/styles.test.ts +++ b/tests/styles.test.ts @@ -133,6 +133,16 @@ describe("chat toolbar CSS", () => { expect(composerMetaFatal).toContain("padding-inline-start: calc(var(--size-4-1) / 2)"); }); + it("lets the shell provide Obsidian status bar composer clearance", () => { + const tokenScope = /^(?(?:\.[^{]+,\n)*\.[^{]+) \{(?[^}]+)\}/m.exec(styles)?.groups?.["body"] ?? ""; + const clearanceLine = tokenScope + .split("\n") + .find((line) => line.trim().startsWith("--codex-panel-status-bar-clearance:")) + ?.trim(); + + expect(clearanceLine).toBe("--codex-panel-status-bar-clearance: 0px;"); + }); + it("keeps composer status accessible summary visually hidden", () => { const summary = /\.codex-panel__composer-meta-summary \{(?[^}]+)\}/.exec(styles)?.groups?.["body"] ?? ""; const visual = /\.codex-panel__composer-meta-status-visual \{(?[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";