From 3a9df0c633482cca5b646e49b88e12459606a670 Mon Sep 17 00:00:00 2001 From: David McKay Date: Wed, 16 Sep 2026 17:49:04 -0700 Subject: [PATCH 1/2] Fix desktop installation ordering and fresh-install usability --- app/src/components/layout/detail-panel.tsx | 140 +++-- app/src/routes/_authed/admin/boundaries.tsx | 3 + app/tests/composing-enter.test.tsx | 3 + app/tests/detail-panel.test.tsx | 112 ++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/main.rs | 382 ++++++++----- desktop/src-tauri/src/plan.rs | 39 ++ desktop/src-tauri/src/preparation.rs | 507 ++++++++++++++++++ desktop/src-tauri/src/pull_metrics.rs | 51 +- desktop/src-tauri/src/stack.rs | 56 +- desktop/src-tauri/src/stop_ipc_tests.rs | 30 ++ desktop/src-tauri/src/telemetry.rs | 9 +- desktop/src-tauri/tests/fixtures/engine.rs | 26 +- desktop/src/App.test.tsx | 566 ++++++++++++++++++-- desktop/src/App.tsx | 372 ++++++++++--- desktop/src/HarnessPicker.tsx | 4 +- desktop/src/ProviderPicker.tsx | 9 +- desktop/src/Welcome.tsx | 4 +- desktop/src/telemetry.ts | 8 +- 19 files changed, 1965 insertions(+), 357 deletions(-) create mode 100644 app/tests/detail-panel.test.tsx create mode 100644 desktop/src-tauri/src/preparation.rs diff --git a/app/src/components/layout/detail-panel.tsx b/app/src/components/layout/detail-panel.tsx index 38d7ba3a2..2d326c82b 100644 --- a/app/src/components/layout/detail-panel.tsx +++ b/app/src/components/layout/detail-panel.tsx @@ -1,11 +1,12 @@ import { IconX } from "@tabler/icons-react"; import { motion, useReducedMotion } from "motion/react"; -import type { ReactNode } from "react"; +import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; import { EASE_OUT } from "@/lib/motion"; /** - * A main pane with a detail pane that slides in beside it. + * A main pane with details beside it when there is room, or in a sheet on narrow windows. * * The open/closed state belongs to the caller, in practice a search parameter, so opening a detail * is a real navigation: it survives a reload, it can be linked to, and Back closes it. @@ -16,6 +17,7 @@ import { EASE_OUT } from "@/lib/motion"; const ANIMATION_DURATION_SECONDS = 0.3; const DEFAULT_DETAIL_WIDTH = 400; +const MIN_MAIN_WIDTH = 400; /** * The content overlaps the tail of the pane rather than following it. @@ -45,59 +47,101 @@ export function DetailPanel({ }) { // Reduced motion keeps the fade, which explains the change, and drops the movement. const shouldReduceMotion = useReducedMotion(); + const container = useRef(null); + const [overlay, setOverlay] = useState(true); + useLayoutEffect(() => { + const element = container.current; + if (!element) return; + const measure = (width: number) => + setOverlay(width < detailWidth + MIN_MAIN_WIDTH); + measure(element.getBoundingClientRect().width); + const observer = new ResizeObserver(([entry]) => { + if (entry) measure(entry.contentRect.width); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [detailWidth]); return ( -
+
{children}
- -
{ + if (!next) onClose(); + }} > - {/* Rendered for the whole animation, so the way out is available immediately. */} -
-
- {title} + + + {title ?? "Details"} + +
+ {open ? detail : null}
-
- + + + ) : ( + +
+ {/* Rendered for the whole animation, so the way out is available immediately. */} +
+
+ {title} +
+
+ +
+ {/* + * Unmount while closed so dismissed form state and detail queries do not remain active. + */} + {open ? ( + + {detail} + + ) : null}
- {/* - * Unmount while closed so dismissed form state and detail queries do not remain active. - */} - {open ? ( - - {detail} - - ) : null} -
- + + )}
); } diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx index 1511713a0..b792dc0e7 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -225,6 +225,9 @@ function BoundariesPage() {
{ setDraft(event.target.value); diff --git a/app/tests/composing-enter.test.tsx b/app/tests/composing-enter.test.tsx index b0ea8fba4..75a4f3594 100644 --- a/app/tests/composing-enter.test.tsx +++ b/app/tests/composing-enter.test.tsx @@ -192,6 +192,9 @@ test("a boundary rule is not saved by the Enter that confirms a composed charact ); const field = await view.findByLabelText("A rule, written in CEL"); + expect(field.getAttribute("spellcheck")).toBe("false"); + expect(field.getAttribute("autocorrect")).toBe("off"); + expect(field.getAttribute("autocapitalize")).toBe("off"); const rule = 'contains(element.name, "送信")'; await type(field, rule); diff --git a/app/tests/detail-panel.test.tsx b/app/tests/detail-panel.test.tsx new file mode 100644 index 000000000..cf887a65e --- /dev/null +++ b/app/tests/detail-panel.test.tsx @@ -0,0 +1,112 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; +import { useEffect } from "react"; +import { DetailPanel } from "@/components/layout/detail-panel"; + +class Observer implements ResizeObserver { + static targets = new Map(); + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + Observer.targets.set(target, this); + } + unobserve(target: Element) { + Observer.targets.delete(target); + } + disconnect() { + for (const [target, observer] of Observer.targets) { + if (observer === this) Observer.targets.delete(target); + } + } + resize(target: Element, width: number) { + this.callback( + [ + { + target, + contentRect: new DOMRect(0, 0, width, 640), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], + }, + ], + this, + ); + } +} +let originalObserver: typeof ResizeObserver; +beforeAll(() => { + GlobalRegistrator.register(); + originalObserver = globalThis.ResizeObserver; + globalThis.ResizeObserver = Observer; +}); +afterEach(() => { + cleanup(); + Observer.targets.clear(); +}); +afterAll(() => { + globalThis.ResizeObserver = originalObserver; + GlobalRegistrator.unregister(); +}); + +async function resize(container: HTMLElement, width: number) { + const target = container.firstElementChild; + if (!target) throw new Error("Panel root missing"); + await act(async () => { + Observer.targets.get(target)?.resize(target, width); + }); +} + +test("narrow container overlays details without remounting the chat draft", async () => { + let mounts = 0; + function Chat() { + useEffect(() => { + mounts += 1; + }, []); + return