diff --git a/graphlink_app/tests/fixtures/composer_styles_pre_retrofit.css b/graphlink_app/tests/fixtures/composer_styles_pre_retrofit.css deleted file mode 100644 index d36df6f4..00000000 --- a/graphlink_app/tests/fixtures/composer_styles_pre_retrofit.css +++ /dev/null @@ -1,291 +0,0 @@ -/* Theme variables + utility generation only - deliberately NOT - "@import "tailwindcss";" (which also pulls in Tailwind's preflight - reset: * { margin:0; padding:0; border:0; box-sizing:border-box }, - list-style/img/button resets, etc. - a real, visible rendering change, - not something this install-only increment should introduce). This - island's existing CSS already establishes its own baseline; preflight - adoption is a decision for whichever increment actually retrofits THIS - island onto Tailwind utilities - it does not generalize to future - islands with richer markup (headings/lists/paragraphs from markdown, - e.g. help/chat-library/document-viewer), which will need their own - reset decision, not an inherited assumption that composer's answer - already covers them. - - Tailwind's automatic content scanner has no @source restriction by - default - it scans every non-ignored file's raw TEXT for candidate - utility names, not just real className="..." usage. Confirmed by - adversarial review: plain identifier words that are also valid - unprefixed Tailwind utilities (e.g. "border", a TS object key in - bridgeTypes.ts; "resize", a method name in bridge.ts) were getting - matched and compiled into live (if currently unused, since nothing - applies those classNames) CSS. Harmless today, but a real latent - collision: the first time a real className="border" is ever added - here meaning something else, it would silently pick up Tailwind's - utility instead. Scoped scanning to only the file types that can - legitimately author a Tailwind className in this workspace. */ -@source not "**/*.ts"; -@source not "**/*.css"; - -@import "tailwindcss/theme" layer(theme); -@import "tailwindcss/utilities" layer(utilities); -@import "../../lib/tokens/gl-theme.css"; - -:root { - color-scheme: dark; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-size: 13px; - color: #e7e7e7; - background: transparent; - font-synthesis: none; - text-rendering: optimizeLegibility; -} - -* { box-sizing: border-box; } -html, body, #root { width: 100%; margin: 0; background: transparent; } -body { min-width: 320px; overflow: hidden; } -button, textarea { font: inherit; } -button { color: inherit; } - -.composer-shell { - position: relative; - width: 100%; - min-height: 0; - padding: 9px 10px 8px; - overflow: visible; - border: 1px solid rgba(150, 150, 150, 0.34); - border-radius: 14px; - background: #1f1f1f; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.20), inset 0 1px rgba(255, 255, 255, 0.035); - transition: border-color 150ms ease, box-shadow 150ms ease; -} - -.composer-shell:focus-within { - border-color: rgba(160, 160, 160, 0.82); - box-shadow: 0 8px 26px rgba(0, 0, 0, 0.22), 0 0 0 2px rgba(160, 160, 160, 0.12); -} - -.composer-footer, .footer-left, .footer-right { - display: flex; - align-items: center; -} - -.attach-button, .attachment-count, .send-button { - border: 0; - cursor: pointer; - transition: color 140ms ease, background-color 140ms ease, border-color 140ms ease, - opacity 140ms ease, transform 140ms ease; -} - -.icon { - width: 15px; - height: 15px; - fill: none; - stroke: currentColor; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.8; -} - -.input-wrap { - min-height: 42px; - border-radius: 8px; - background: transparent; -} - -textarea { - display: block; - width: 100%; - min-height: 42px; - max-height: 160px; - resize: none; - overflow-y: auto; - padding: 8px 4px 5px; - border: 0; - outline: 0; - color: #eeeeee; - background: transparent; - line-height: 1.45; - scrollbar-color: #555555 transparent; -} - -textarea::placeholder { color: #888888; } -textarea:disabled { cursor: wait; opacity: 0.7; } - -.composer-footer { - min-height: 30px; - justify-content: space-between; - gap: 12px; - margin-top: 3px; -} - -.footer-left, .footer-right { min-width: 0; gap: 8px; } -.footer-left { flex: 1 1 auto; } -.footer-right { flex: 0 0 auto; justify-content: flex-end; } - -.attachment-control { - position: relative; - display: inline-flex; - width: 30px; - height: 30px; - flex: 0 0 30px; - align-items: center; - justify-content: center; -} - -.attach-button { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border: 1px solid rgba(150, 150, 150, 0.28); - border-radius: 8px; - color: #c0c0c0; - background: rgba(255, 255, 255, 0.018); -} - -.attach-button:hover:not(:disabled) { - border-color: rgba(160, 160, 160, 0.72); - color: #f4f4f4; - background: rgba(160, 160, 160, 0.10); -} - -.attachment-count { - position: absolute; - top: -5px; - right: -7px; - z-index: 2; - display: grid; - min-width: 18px; - height: 18px; - place-items: center; - padding: 0 4px; - border: 1px solid #1f1f1f; - border-radius: 999px; - color: #ededed; - background: #555555; - font-size: 9px; - font-weight: 750; - line-height: 1; -} - -.attachment-count:hover:not(:disabled) { - color: #ffffff; - background: #666666; -} - -.attachment-count:disabled { cursor: default; opacity: 0.45; } - -button:focus-visible { - outline: 2px solid rgba(156, 156, 156, 0.82); - outline-offset: 2px; -} - -button:disabled { cursor: default; opacity: 0.38; } - -.restored-pill { - padding: 3px 7px; - border: 1px solid rgba(150, 150, 150, 0.28); - border-radius: 999px; - color: #c7c7c7; - background: rgba(160, 160, 160, 0.08); - font-size: 9px; -} - -.composer-control { - display: inline-flex; - min-width: 0; - max-width: 190px; - overflow: hidden; - align-items: center; - gap: 6px; - padding: 3px 6px; - border: 1px solid transparent; - border-radius: 7px; - color: #c0c0c0; - background: transparent; - cursor: pointer; - text-align: left; -} - -.composer-control:hover:not(:disabled), -.composer-control[aria-expanded="true"] { - border-color: rgba(160, 160, 160, 0.34); - color: #f0f0f0; - background: rgba(255, 255, 255, 0.045); -} - -.composer-control .icon { - width: 11px; - height: 11px; - flex: 0 0 auto; - color: #909090; -} - -.control-copy { - flex: 1 1 auto; - min-width: 0; - display: grid; - gap: 1px; -} - -.control-kicker { - overflow: hidden; - color: #8a8a8a; - font-size: 9px; - font-weight: 700; - letter-spacing: 0.04em; - text-overflow: ellipsis; - white-space: nowrap; -} - -.control-value { - overflow: hidden; - color: #dddddd; - font-size: 10px; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -.reasoning-control { - width: 118px; - flex: 0 0 118px; -} - -.model-control { - width: 190px; - flex: 0 0 190px; -} - -.send-button { - display: grid; - width: 34px; - height: 30px; - place-items: center; - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 9px; - color: #ffffff; - background: #414141; - box-shadow: 0 3px 10px rgba(0, 0, 0, 0.22); -} - -.send-button:hover:not(:disabled) { transform: translateY(-1px); background: #555555; } -.send-button:disabled { box-shadow: none; } -.send-button.cancel { background: #4a4a4a; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.20); } - -.request-status { - display: flex; - justify-content: space-between; - gap: 12px; - margin: 6px 3px 1px; - color: #b0b0b0; - font-size: 10px; -} - -.request-status.failed { color: #b0b0b0; } -.request-status button { padding: 0; border: 0; color: #bdbdbd; background: transparent; cursor: pointer; font-weight: 650; } - -@media (max-width: 540px) { - .reasoning-control { display: none; } - .model-control { width: 150px; flex-basis: 150px; } -} diff --git a/graphlink_app/tests/test_composer_model_controls.py b/graphlink_app/tests/test_composer_model_controls.py index 04425374..bcfb279d 100644 --- a/graphlink_app/tests/test_composer_model_controls.py +++ b/graphlink_app/tests/test_composer_model_controls.py @@ -141,18 +141,6 @@ def test_cloud_route_uses_persisted_catalog_and_selection_updates_chat_task(): assert settings.api_models[config.TASK_CHAT] == "cloud-fast" -def test_composer_footer_has_stable_model_width_and_no_status_dot(): - source_root = Path(__file__).resolve().parents[2] / "web_ui" / "src" / "islands" / "composer" - composer_source = (source_root / "ComposerApp.tsx").read_text(encoding="utf-8") - styles_source = (source_root / "styles.css").read_text(encoding="utf-8") - - assert "status-dot" not in composer_source - assert ".status-dot" not in styles_source - assert "width: 190px;" in styles_source - assert "flex: 0 0 190px;" in styles_source - assert "text-overflow: ellipsis;" in styles_source - - def test_quick_reasoning_disables_ollama_thinking_for_reasoning_models(monkeypatch): monkeypatch.setattr(api_provider, "USE_API_MODE", False) monkeypatch.setattr(api_provider, "LOCAL_PROVIDER_TYPE", config.LOCAL_PROVIDER_OLLAMA) diff --git a/graphlink_app/tests/test_composer_token_retrofit.py b/graphlink_app/tests/test_composer_token_retrofit.py deleted file mode 100644 index 40847a86..00000000 --- a/graphlink_app/tests/test_composer_token_retrofit.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Coverage for the composer island's CSS token retrofit. - -Two independent guarantees live here, and the distinction matters: - -1. `styles.css` contains no hardcoded color literal (the "zero hardcoded hex" - requirement itself). -2. Every `var(--gl-composer-*)` in `styles.css` resolves, for every theme, to - exactly the literal that used to sit at that same site. - -(1) alone would pass if every literal were replaced by the WRONG token - the -file would be clean and the app would be visibly broken. (2) is what actually -proves the retrofit was value-preserving, by textually re-substituting every -token back and comparing against a checked-in copy of the pre-retrofit file. -It is the direct analog of the QSS golden test in test_qss_generation.py. - -Line endings: both the fixture and the live file are read in ordinary -universal-newline text mode, never with newline="". `.gitattributes` sets -`* text=auto`, so a fresh checkout on Windows can materialize either file with -CRLF; a byte-exact comparison would then fail for a reason that has nothing to -do with this retrofit. That exact bug already shipped once here and had to be -fixed in the QSS golden test - not repeating it. -""" - -import re -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -import pytest - -import graphlink_styles as gs -from graphlink_styles import THEMES - -_REPO_ROOT = Path(__file__).resolve().parents[2] -_STYLES = _REPO_ROOT / "web_ui" / "src" / "islands" / "composer" / "styles.css" -_FIXTURE = Path(__file__).parent / "fixtures" / "composer_styles_pre_retrofit.css" -_DEV_VARS = _REPO_ROOT / "web_ui" / "src" / "lib" / "tokens" / "gl-vars-dev.css" -_MAIN_TSX = _REPO_ROOT / "web_ui" / "src" / "islands" / "composer" / "main.tsx" - -# Matches the color literals this retrofit removed. Deliberately the same two -# patterns used to capture them in the first place, so the "did we get them -# all" question is answered by the same definition that answered "what is -# there". -_COLOR_LITERAL_RE = re.compile(r"#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)") -_VAR_RE = re.compile(r"var\((--gl-composer-[a-z0-9-]+)\)") - - -def _read(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -class TestNoHardcodedColors: - def test_styles_css_contains_no_color_literal(self): - found = _COLOR_LITERAL_RE.findall(_read(_STYLES)) - - assert found == [], ( - f"{_STYLES.name} still contains hardcoded color literal(s): {found}. " - "Every color in this island must come from a --gl-composer-* token." - ) - - def test_the_fixture_still_records_the_pre_retrofit_literals(self): - # Guards the guard: if someone regenerated the fixture from the - # retrofitted file, the golden test below would pass vacuously. - found = _COLOR_LITERAL_RE.findall(_read(_FIXTURE)) - - assert len(found) == 43, ( - f"The pre-retrofit fixture should hold exactly 43 color literals, found " - f"{len(found)}. If this fixture was regenerated from the current " - "styles.css, the resolution-golden test below is no longer proving " - "anything - restore it from history instead." - ) - - -class TestResolutionGolden: - """Substitute every token back and compare against the pre-retrofit file.""" - - @pytest.mark.parametrize("theme_name", sorted(THEMES)) - def test_resolving_every_token_reproduces_the_original_file(self, theme_name): - properties = gs.css_custom_properties(theme_name) - styles = _read(_STYLES) - - def resolve(match: re.Match[str]) -> str: - name = match.group(1) - # A typo'd token name raises here rather than silently rendering as - # an invalid computed value in the browser. - return properties[name] - - resolved = _VAR_RE.sub(resolve, styles) - - assert resolved == _read(_FIXTURE), ( - f"Resolving styles.css against the {theme_name!r} theme does not " - "reproduce the pre-retrofit file byte-for-byte." - ) - - def test_every_theme_resolves_identically_today(self): - """The machine-checkable statement "composer is theme-invariant". - - This is expected to FAIL LOUDLY the day a real per-theme composer - palette is authored - that is the point, not collateral damage. When it - does, this assertion should be deliberately inverted, not deleted, and - the per-theme resolution test above becomes the one that matters. - """ - styles = _read(_STYLES) - resolved = {} - for theme_name in THEMES: - properties = gs.css_custom_properties(theme_name) - resolved[theme_name] = _VAR_RE.sub( - lambda m: properties[m.group(1)], styles - ) - - assert len(set(resolved.values())) == 1, ( - "Composer no longer resolves identically across themes. If that is " - "intentional (a real per-theme palette was authored), invert this " - "assertion deliberately and record the design decision." - ) - - -class TestTokenSurface: - def test_every_token_referenced_by_css_exists_in_every_theme(self): - referenced = set(_VAR_RE.findall(_read(_STYLES))) - - assert referenced, "expected styles.css to reference --gl-composer-* tokens" - for theme_name in THEMES: - exported = set(gs.css_custom_properties(theme_name)) - missing = referenced - exported - assert not missing, f"{theme_name}: styles.css references undefined {missing}" - - def test_every_island_token_is_actually_used_by_the_css(self): - # The reverse direction: a token nobody references is dead weight that - # a future reader would reasonably assume is load-bearing. - referenced = set(_VAR_RE.findall(_read(_STYLES))) - island = gs.island_property_names("dark") - - unused = island - referenced - assert not unused, ( - f"These island tokens are exported but referenced nowhere in " - f"styles.css: {sorted(unused)}" - ) - - def test_island_tokens_are_excluded_from_the_tailwind_theme_block(self): - block = gs.tailwind_theme_css() - - assert "composer" not in block, ( - "Island-scoped tokens must not be registered as Tailwind design " - "tokens - that would publish one island's private palette as a " - "workspace-wide utility surface." - ) - - def test_island_token_names_are_computed_from_key_sets_not_prefix_matching(self): - # A same-prefixed app-wide token must NOT be swallowed by the carve-out. - names = gs.island_property_names("dark") - - assert "--gl-composer-shell-background" in names - assert "--gl-composer-not-a-real-token" not in names - - @pytest.mark.parametrize("theme_name", sorted(THEMES)) - def test_the_two_composer_groups_have_disjoint_keys(self, theme_name): - # They flatten under one shared prefix, so a collision would mean one - # silently overwrites the other. - flat = set(gs.THEME_TOKENS[theme_name]["composer"]) - alpha = set(gs.THEME_TOKENS[theme_name]["composer_alpha"]) - - assert not (flat & alpha) - - def test_composer_groups_have_identical_keys_across_themes(self): - # mono's qss group is genuinely 9 keys short of dark's, so this failure - # mode is live in this table - guard the new groups against it. - for group in ("composer", "composer_alpha"): - key_sets = {t: set(gs.THEME_TOKENS[t][group]) for t in THEMES} - assert len({frozenset(k) for k in key_sets.values()}) == 1, ( - f"{group} has differing keys across themes: " - f"{ {t: sorted(k) for t, k in key_sets.items()} }" - ) - - -class TestDevServerVariablesCoverComposer: - """Only the composer-specific half of the dev-variables contract lives here. - - The file's own staleness/dev-only coverage is in test_gl_vars_dev_css.py, - deliberately: gl-vars-dev.css mirrors EVERY dark value, so editing an - unrelated palette color makes it stale - and that failure should not be - reported by a test named for the composer retrofit. - """ - - def test_it_defines_every_token_the_composer_css_references(self): - referenced = set(_VAR_RE.findall(_read(_STYLES))) - defined = set(re.findall(r"^\s*(--gl-[a-z0-9-]+):", _read(_DEV_VARS), re.MULTILINE)) - - missing = referenced - defined - assert not missing, f"gl-vars-dev.css does not define {missing}" diff --git a/graphlink_app/tests/test_gl_vars_dev_css.py b/graphlink_app/tests/test_gl_vars_dev_css.py index 878fd082..4d86530c 100644 --- a/graphlink_app/tests/test_gl_vars_dev_css.py +++ b/graphlink_app/tests/test_gl_vars_dev_css.py @@ -26,7 +26,6 @@ _REPO_ROOT = Path(__file__).resolve().parents[2] GENERATED_FILE = _REPO_ROOT / "web_ui" / "src" / "lib" / "tokens" / "gl-vars-dev.css" -_MAIN_TSX = _REPO_ROOT / "web_ui" / "src" / "islands" / "composer" / "main.tsx" _REGENERATE_HINT = ( "Regenerate it: write graphlink_styles.css_root_block('dark') to " @@ -92,25 +91,6 @@ class TestGlVarsDevCssIsDevOnly: injection instead of letting it show. """ - def test_it_is_imported_behind_the_dev_flag(self): - main_tsx = _read(_MAIN_TSX) - - assert "gl-vars-dev.css" in main_tsx, "main.tsx must import the dev variables" - # Match the real import expression rather than a bare substring, so a - # comment mentioning the filename can't satisfy (or spuriously break) - # this, and an unconditional import below an unrelated - # `const isDev = import.meta.env.DEV` can't sneak past a naive - # index comparison. - guarded_import = re.search( - r"if\s*\(\s*import\.meta\.env\.DEV\s*\)\s*\{[^}]*gl-vars-dev\.css[^}]*\}", - main_tsx, - re.DOTALL, - ) - assert guarded_import, ( - "gl-vars-dev.css must be imported inside an `if (import.meta.env.DEV)` " - "block so Vite statically eliminates it from the production bundle" - ) - def test_no_island_imports_it_unconditionally(self): # main.tsx is not the only possible importer once a second island # exists; scan the whole workspace source tree. See _strip_js_comments() diff --git a/web_ui/src/islands/about/App.test.tsx b/web_ui/src/islands/about/App.test.tsx deleted file mode 100644 index 53fe3f8a..00000000 --- a/web_ui/src/islands/about/App.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import App from "./App"; -import { initialAboutState } from "./bridgeTypes"; -import type { QtWindow } from "../../lib/bridge-core/transport"; - -// jsdom has no window.QWebChannel, so createAboutBridge() falls through to -// the mock bridge automatically for the smoke test below - same pattern as -// every other island's App.test.tsx. - -describe("App against the mock bridge", () => { - it("renders the app name, version, and developer credits from the initial state", () => { - render(); - - expect(screen.getByText(initialAboutState.appName)).toBeInTheDocument(); - expect(screen.getByText(`Version ${initialAboutState.appVersion}`)).toBeInTheDocument(); - expect(screen.getByText(initialAboutState.developerName)).toBeInTheDocument(); - expect(screen.getByText(initialAboutState.copyrightText)).toBeInTheDocument(); - }); - - it("renders the 3 link buttons and the Close button", () => { - render(); - - expect(screen.getByRole("button", { name: "Graphlink Repository" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Personal Webpage" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Personal GitHub" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument(); - }); - - it("clicking a link button and Close do not throw against the mock bridge", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "Graphlink Repository" })); - await user.click(screen.getByRole("button", { name: "Close" })); - }); -}); - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - close: vi.fn(), - openExternal: vi.fn(), - }; - class FakeQWebChannel { - constructor(_t: unknown, cb: (channel: { objects: Record }) => void) { - cb({ objects: { aboutBridge: remote } }); - } - } - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - return remote; -} - -function uninstall() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -describe("App against a real (faked) QWebChannel connection", () => { - afterEach(() => { - cleanup(); - uninstall(); - vi.restoreAllMocks(); - }); - - it("clicking the Graphlink Repository button calls through to openExternal with its URL", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push( - JSON.stringify({ - ...initialAboutState, - repositoryUrl: "https://github.com/dovvnloading/Graphlink", - }), - ); - - await user.click(await screen.findByRole("button", { name: "Graphlink Repository" })); - - expect(remote.openExternal).toHaveBeenCalledWith("https://github.com/dovvnloading/Graphlink"); - }); - - it("clicking Personal Webpage and Personal GitHub call through with their own URLs", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push( - JSON.stringify({ - ...initialAboutState, - developerWebsiteUrl: "https://mattwesney.com", - developerGithubUrl: "https://github.com/dovvnloading", - }), - ); - - await user.click(await screen.findByRole("button", { name: "Personal Webpage" })); - await user.click(screen.getByRole("button", { name: "Personal GitHub" })); - - expect(remote.openExternal).toHaveBeenCalledWith("https://mattwesney.com"); - expect(remote.openExternal).toHaveBeenCalledWith("https://github.com/dovvnloading"); - }); - - it("clicking Close calls through to the remote's close()", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "Close" })); - - expect(remote.close).toHaveBeenCalledTimes(1); - }); - - it("pressing Escape calls through to the remote's close()", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - - await user.keyboard("{Escape}"); - - expect(remote.close).toHaveBeenCalledTimes(1); - }); - - it("renders the shared error state on a rejected payload", async () => { - const remote = installFakeQWebChannel(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push(JSON.stringify({ ...initialAboutState, minCompatibleSchemaVersion: 999 })); - - expect(await screen.findByRole("alert")).toBeInTheDocument(); - expect(screen.getByText("About is unavailable")).toBeInTheDocument(); - }); -}); diff --git a/web_ui/src/islands/about/App.tsx b/web_ui/src/islands/about/App.tsx deleted file mode 100644 index 9d9f885a..00000000 --- a/web_ui/src/islands/about/App.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { AboutState, initialAboutState } from "./bridgeTypes"; -import { AboutBridge, BridgeRejection, createAboutBridge } from "./bridge"; -import { BridgeErrorState } from "../../lib/ui/BridgeErrorState"; - -function App() { - const [state, setState] = useState(initialAboutState); - const [rejection, setRejection] = useState(null); - const bridgeRef = useRef(null); - - useEffect(() => { - const bridge = createAboutBridge(setState, setRejection); - bridgeRef.current = bridge; - bridge.ready(); - return () => { - bridge.dispose(); - bridgeRef.current = null; - }; - }, []); - - // The legacy dialog was frameless title-bar-only (no context-help button, - // native close button retained); this frameless WebIslandHost has no - // native title bar at all, so Escape has to substitute for it explicitly. - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - bridgeRef.current?.close(); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - if (rejection) { - return ( - - ); - } - - return ( -
-

{state.appName}

-

Version {state.appVersion}

- -
- -
-

Project

- - -

Developed By

-

{state.developerName}

- - -
- -
- {state.copyrightText} - -
-
- ); -} - -export default App; diff --git a/web_ui/src/islands/about/bridge.test.ts b/web_ui/src/islands/about/bridge.test.ts deleted file mode 100644 index 1b8e6715..00000000 --- a/web_ui/src/islands/about/bridge.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createAboutBridge } from "./bridge"; -import { initialAboutState, AboutState } from "./bridgeTypes"; -import { QtWindow } from "../../lib/bridge-core/transport"; - -function stateJson(overrides: Partial = {}): string { - return JSON.stringify({ ...initialAboutState, ...overrides }); -} - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - close: vi.fn(), - openExternal: vi.fn(), - }; - - class FakeQWebChannel { - constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) { - callback({ objects: { aboutBridge: remote } }); - } - } - - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - - return remote; -} - -function uninstallFakeQWebChannel() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -describe("createAboutBridge with no QWebChannel available", () => { - it("falls back to the mock bridge and publishes the initial state on ready()", () => { - const listener = vi.fn(); - const bridge = createAboutBridge(listener); - - bridge.ready(); - - expect(listener).toHaveBeenCalledExactlyOnceWith(initialAboutState); - }); - - it("close() on the mock bridge does not throw", () => { - const bridge = createAboutBridge(() => {}); - expect(() => bridge.close()).not.toThrow(); - }); - - it("openExternal() on the mock bridge does not throw", () => { - const bridge = createAboutBridge(() => {}); - expect(() => bridge.openExternal("https://example.com")).not.toThrow(); - }); - - it("dispose() on the mock bridge does not throw", () => { - const bridge = createAboutBridge(() => {}); - expect(() => bridge.dispose()).not.toThrow(); - }); -}); - -describe("createAboutBridge against a real QWebChannel connection", () => { - it("connects synchronously and calls remote.ready()", () => { - const remote = installFakeQWebChannel(); - try { - createAboutBridge(() => {}); - expect(remote.ready).toHaveBeenCalledTimes(1); - expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("forwards state pushed through stateChanged to the listener", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - createAboutBridge(listener); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push(stateJson({ appVersion: "9.9.9" })); - - expect(listener).toHaveBeenCalledWith(expect.objectContaining({ appVersion: "9.9.9" })); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("close and openExternal call through to their own remote methods", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createAboutBridge(() => {}); - - bridge.close(); - bridge.openExternal("https://example.com"); - - expect(remote.close).toHaveBeenCalledTimes(1); - expect(remote.openExternal).toHaveBeenCalledWith("https://example.com"); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("rejects a payload with an incompatible schema version instead of forwarding it", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - const onRejection = vi.fn(); - createAboutBridge(listener, onRejection); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - listener.mockClear(); - - push(stateJson({ minCompatibleSchemaVersion: 999 })); - - expect(listener).not.toHaveBeenCalled(); - expect(onRejection).toHaveBeenCalledWith(expect.objectContaining({ kind: "version" })); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("dispose() disconnects the stateChanged listener", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createAboutBridge(() => {}); - const handler = remote.stateChanged.connect.mock.calls[0][0]; - - bridge.dispose(); - - expect(remote.stateChanged.disconnect).toHaveBeenCalledWith(handler); - } finally { - uninstallFakeQWebChannel(); - } - }); -}); diff --git a/web_ui/src/islands/about/bridge.ts b/web_ui/src/islands/about/bridge.ts deleted file mode 100644 index d4487693..00000000 --- a/web_ui/src/islands/about/bridge.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { AboutState, initialAboutState } from "./bridgeTypes"; -import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport"; -import { validateAboutState } from "../../lib/bridge-core/generated/about-state"; -import { - BridgeRejection, - RejectionListener, - parseIslandState, -} from "../../lib/bridge-core/islandState"; - -export type { BridgeRejection, RejectionListener }; - -type StateListener = (state: AboutState) => void; - -interface QtSignal { - connect(listener: (value: T) => void): void; - disconnect?: (listener: (value: T) => void) => void; -} - -interface QtAboutObject { - stateChanged: QtSignal; - ready: () => void; - close: () => void; - openExternal: (url: string) => void; -} - -export interface AboutBridge { - ready(): void; - close(): void; - openExternal(url: string): void; - dispose(): void; -} - -function parseState(payload: string) { - return parseIslandState(payload, validateAboutState); -} - -class MockAboutBridge implements AboutBridge { - private readonly state: AboutState = structuredClone(initialAboutState); - private readonly listener: StateListener; - - constructor(listener: StateListener) { - this.listener = listener; - } - - ready(): void { - this.listener(this.state); - } - - close(): void { - // No real top-level window to close in the mock/test environment. - } - - openExternal(): void { - // No real browser to open in the mock/test environment - a no-op, - // same treatment every other island gives a native-only action with - // no dev-mode stand-in (e.g. settings' pickLlamaCppChatModelFile). - // Fewer params than the interface declares is valid TS here (a class - // method implementation may ignore trailing arguments). - } - - dispose(): void {} -} - -export function createAboutBridge( - listener: StateListener, - onRejection?: RejectionListener, -): AboutBridge { - const fallback = new MockAboutBridge(listener); - - if (!isQWebChannelAvailable()) { - return fallback; - } - - let remote: QtAboutObject | null = null; - let connected = false; - const stateListener = (payload: string) => { - const outcome = parseState(payload); - if (outcome.ok) { - listener(outcome.state); - onRejection?.(null); - return; - } - console.error( - `[about bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`, - outcome.rejection.details, - ); - onRejection?.(outcome.rejection); - }; - - connectQWebChannel((objects) => { - remote = objects.aboutBridge as QtAboutObject; - remote.stateChanged.connect(stateListener); - connected = true; - remote.ready(); - }); - - const call = ( - method: K, - ...args: QtAboutObject[K] extends (...values: infer A) => void ? A : never - ) => { - if (connected && remote && typeof remote[method] === "function") { - (remote[method] as (...values: unknown[]) => void)(...args); - } - }; - - return { - ready: () => call("ready"), - close: () => call("close"), - openExternal: (url) => call("openExternal", url), - dispose: () => { - remote?.stateChanged.disconnect?.(stateListener); - remote = null; - }, - }; -} diff --git a/web_ui/src/islands/about/bridgeTypes.ts b/web_ui/src/islands/about/bridgeTypes.ts deleted file mode 100644 index e1f96c6a..00000000 --- a/web_ui/src/islands/about/bridgeTypes.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * The about-dialog island's state contract. - * - * See composer/bridgeTypes.ts for the fuller rationale (re-export from the - * generated file, not a hand mirror). Every field here is a build-time - * constant on the Python side - there is no live app state on this - * surface at all. - */ -export type { AboutState } from "../../lib/bridge-core/generated/about-state"; - -import type { AboutState } from "../../lib/bridge-core/generated/about-state"; - -export const initialAboutState: AboutState = { - schemaVersion: 1, - minCompatibleSchemaVersion: 1, - revision: 0, - appName: "Graphlink", - appVersion: "0.0.0-dev", - repositoryUrl: "https://github.com/dovvnloading/Graphlink", - developerName: "Matthew Robert Wesney", - developerWebsiteUrl: "https://mattwesney.com", - developerGithubUrl: "https://github.com/dovvnloading", - copyrightText: "© 2026", -}; diff --git a/web_ui/src/islands/about/index.html b/web_ui/src/islands/about/index.html deleted file mode 100644 index ed1bf000..00000000 --- a/web_ui/src/islands/about/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - About Graphlink - - -
- - - diff --git a/web_ui/src/islands/about/main.tsx b/web_ui/src/islands/about/main.tsx deleted file mode 100644 index bb58ae83..00000000 --- a/web_ui/src/islands/about/main.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; -import "../../lib/ui/base.css"; -import "./styles.css"; - -// See composer/main.tsx for the full rationale: styles.css resolves colors -// through var(--gl-*), which only exist in the real app via the Python -// host's build-time :root injection - npm run dev has no Python in the -// loop, so this supplies the same variables for browser preview. -if (import.meta.env.DEV) { - await import("../../lib/tokens/gl-vars-dev.css"); -} - -createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/web_ui/src/islands/about/styles.css b/web_ui/src/islands/about/styles.css deleted file mode 100644 index c122233e..00000000 --- a/web_ui/src/islands/about/styles.css +++ /dev/null @@ -1,152 +0,0 @@ -/* Colors reuse EXISTING app-wide --gl-* tokens, same convention every - island since token-counter follows. The legacy AboutDialog was - dark-mode-only (hardcoded #1E1E1E background, near-all-literal colors) - - deliberately NOT preserved here; this island reacts to the real app - theme via the shared build-time embedding instead, per the Phase 4 - scope note. Sized to match the legacy dialog's resize(420, 420). */ - -html, -body, -#root { - margin: 0; - padding: 0; - background: transparent; -} - -.about-shell { - box-sizing: border-box; - width: 420px; - height: 420px; - display: flex; - flex-direction: column; - padding: 30px; - font-family: var(--gl-font-family, system-ui, sans-serif); - color: var(--gl-neutral-button-icon); - background-color: var(--gl-graph-node-panel-fill); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 8px; -} - -.about-title { - margin: 0; - font-size: 26px; - font-weight: 900; - letter-spacing: 1px; - text-align: center; - color: var(--gl-palette-selection); -} - -.about-version { - margin: 4px 0 0 0; - font-size: 12px; - text-align: center; - color: var(--gl-neutral-button-muted-icon); -} - -.about-divider { - height: 1px; - margin: 16px 0; - background-color: var(--gl-neutral-button-border); -} - -.about-links { - display: flex; - flex-direction: column; - gap: 8px; - padding: 0 16px; -} - -.about-section-label { - margin: 0; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--gl-neutral-button-muted-icon); -} - -.about-section-label-spaced { - margin-top: 16px; -} - -.about-dev-name { - margin: 0 0 4px 0; - font-size: 13px; - font-weight: 600; - color: var(--gl-neutral-button-icon); -} - -.about-link-btn { - box-sizing: border-box; - padding: 10px 20px; - text-align: left; - font-size: 12px; - font-weight: 600; - font-family: inherit; - color: var(--gl-neutral-button-icon); - background-color: var(--gl-neutral-button-background); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 6px; - cursor: pointer; -} - -.about-link-btn:hover { - background-color: var(--gl-neutral-button-hover); -} - -.about-footer { - margin-top: auto; - display: flex; - align-items: center; - justify-content: space-between; -} - -.about-copyright { - font-size: 11px; - color: var(--gl-neutral-button-muted-icon); -} - -/* No --gl-* token exists yet for "text on an accent-filled background", so - this follows the established settings-button-primary convention instead - of introducing a raw literal: accent-colored border+text on a neutral - background, not a solid accent fill. */ -.about-close-btn { - box-sizing: border-box; - width: 100px; - height: 32px; - font-size: 12px; - font-weight: 700; - font-family: inherit; - color: var(--gl-palette-selection); - background-color: var(--gl-neutral-button-background); - border: 1px solid var(--gl-palette-selection); - border-radius: 6px; - cursor: pointer; -} - -.about-close-btn:hover { - background-color: var(--gl-neutral-button-hover); -} - -.bridge-error-title { - font-weight: 600; - margin-bottom: 4px; -} - -.bridge-error-reason { - margin: 0 0 4px 0; - font-size: 13px; -} - -.bridge-error-details { - margin: 0; - padding-left: 16px; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} - -.bridge-error-hint { - margin: 8px 0 0 0; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} diff --git a/web_ui/src/islands/chat-library/App.test.tsx b/web_ui/src/islands/chat-library/App.test.tsx deleted file mode 100644 index a1f7d236..00000000 --- a/web_ui/src/islands/chat-library/App.test.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import App from "./App"; -import { initialChatLibraryState } from "./bridgeTypes"; -import type { QtWindow } from "../../lib/bridge-core/transport"; - -const ROWS = [ - { id: 1, title: "First chat", createdLabel: "Jul 01, 2026 09:30 AM", updatedLabel: "Jul 05, 2026 02:00 PM" }, - { id: 2, title: "Second chat", createdLabel: "Jul 02, 2026 10:00 AM", updatedLabel: "Jul 06, 2026 11:15 AM" }, -]; - -describe("App against the mock bridge", () => { - afterEach(cleanup); - - it("renders the empty placeholder and the default toolbar", () => { - render(); - - expect(screen.getByText("No saved chats yet.")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "New Chat" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Rename" })).toBeDisabled(); - expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); - }); -}); - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - refresh: vi.fn(), - loadChat: vi.fn(), - deleteChat: vi.fn(), - renameChat: vi.fn(), - newChat: vi.fn(), - close: vi.fn(), - }; - class FakeQWebChannel { - constructor(_t: unknown, cb: (channel: { objects: Record }) => void) { - cb({ objects: { chatLibraryBridge: remote } }); - } - } - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - return remote; -} - -function uninstall() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -type Remote = ReturnType; - -function push(remote: Remote, rows = ROWS, extra: Record = {}) { - const handler = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - handler(JSON.stringify({ ...initialChatLibraryState, revision: 1, rows, ...extra })); -} - -describe("App against a real (faked) QWebChannel connection", () => { - afterEach(() => { - cleanup(); - uninstall(); - vi.restoreAllMocks(); - }); - - it("renders each saved chat row with its title and formatted timestamps", async () => { - const remote = installFakeQWebChannel(); - render(); - - push(remote); - - expect(await screen.findByText("First chat")).toBeInTheDocument(); - expect(screen.getByText("Second chat")).toBeInTheDocument(); - expect(screen.getByText(/Updated Jul 05, 2026 02:00 PM/)).toBeInTheDocument(); - expect(screen.getByText("2 saved chats.")).toBeInTheDocument(); - }); - - it("filters the list client-side as the user types, with no bridge round-trip", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await screen.findByText("First chat"); - - await user.type(screen.getByRole("textbox", { name: "Search chats" }), "second"); - - expect(screen.queryByText("First chat")).not.toBeInTheDocument(); - expect(screen.getByText("Second chat")).toBeInTheDocument(); - expect(screen.getByText("Showing 1 of 2 saved chats.")).toBeInTheDocument(); - expect(remote.refresh).not.toHaveBeenCalled(); - }); - - it("double-clicking a row loads it through the bridge", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - - await user.dblClick(await screen.findByText("Second chat")); - - expect(remote.loadChat).toHaveBeenCalledWith(2); - }); - - it("Enter in the search box loads the selected row", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await screen.findByText("First chat"); - - const search = screen.getByRole("textbox", { name: "Search chats" }); - await user.click(search); - await user.keyboard("{ArrowDown}{Enter}"); // move to Second chat, then load - - expect(remote.loadChat).toHaveBeenCalledWith(2); - }); - - it("delete is a two-step confirm: first click arms, Confirm Delete commits", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await user.click(await screen.findByText("Second chat")); - - await user.click(screen.getByRole("button", { name: "Delete" })); - expect(screen.getByText(/Delete this chat\? This cannot be undone\./)).toBeInTheDocument(); - expect(remote.deleteChat).not.toHaveBeenCalled(); - - await user.click(screen.getByRole("button", { name: "Confirm Delete" })); - expect(remote.deleteChat).toHaveBeenCalledWith(2); - }); - - it("delete confirm can be cancelled without deleting", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await user.click(await screen.findByText("First chat")); - - await user.click(screen.getByRole("button", { name: "Delete" })); - await user.click(screen.getByRole("button", { name: "Cancel" })); - - expect(remote.deleteChat).not.toHaveBeenCalled(); - expect(screen.getByRole("button", { name: "New Chat" })).toBeInTheDocument(); - }); - - it("rename opens an inline input prefilled with the title and commits the trimmed value", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await user.click(await screen.findByText("First chat")); - - await user.click(screen.getByRole("button", { name: "Rename" })); - const input = screen.getByRole("textbox", { name: "New chat title" }); - expect(input).toHaveValue("First chat"); - - await user.clear(input); - await user.type(input, " Renamed "); - await user.click(screen.getByRole("button", { name: "Save" })); - - expect(remote.renameChat).toHaveBeenCalledWith(1, "Renamed"); - }); - - it("rename Save is disabled for an empty draft", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await user.click(await screen.findByText("First chat")); - - await user.click(screen.getByRole("button", { name: "Rename" })); - await user.clear(screen.getByRole("textbox", { name: "New chat title" })); - - expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); - expect(remote.renameChat).not.toHaveBeenCalled(); - }); - - it("New Chat calls the bridge (its native confirm lives Python-side)", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await screen.findByText("First chat"); - - await user.click(screen.getByRole("button", { name: "New Chat" })); - - expect(remote.newChat).toHaveBeenCalledTimes(1); - }); - - it("Escape in the search box closes the dialog through the bridge", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await screen.findByText("First chat"); - - await user.click(screen.getByRole("textbox", { name: "Search chats" })); - await user.keyboard("{Escape}"); - - expect(remote.close).toHaveBeenCalledTimes(1); - }); - - it("Escape cancels an armed delete confirm before it would close the dialog", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - await user.click(await screen.findByText("First chat")); - await user.click(screen.getByRole("button", { name: "Delete" })); - - await user.click(screen.getByRole("textbox", { name: "Search chats" })); - await user.keyboard("{Escape}"); - - expect(remote.close).not.toHaveBeenCalled(); - expect(screen.queryByText(/Delete this chat\?/)).not.toBeInTheDocument(); - }); - - it("a recoverable notice from Python renders inline above the list", async () => { - const remote = installFakeQWebChannel(); - render(); - - push(remote, ROWS, { notice: "Failed to load chat: corrupt row" }); - - expect(await screen.findByText("Failed to load chat: corrupt row")).toBeInTheDocument(); - // The list is still usable - not replaced by a full-surface error. - expect(screen.getByText("First chat")).toBeInTheDocument(); - }); - - it("renders the shared full-surface error state only on a rejected payload", async () => { - const remote = installFakeQWebChannel(); - render(); - const handler = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - handler(JSON.stringify({ ...initialChatLibraryState, minCompatibleSchemaVersion: 999 })); - - expect(await screen.findByRole("alert")).toBeInTheDocument(); - expect(screen.getByText("Chat library unavailable")).toBeInTheDocument(); - }); - - it("keeps the selected row highlighted via aria-selected", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote); - - const secondRow = (await screen.findByText("Second chat")).closest("li")!; - await user.click(within(secondRow).getByText("Second chat")); - - expect(secondRow).toHaveAttribute("aria-selected", "true"); - }); -}); diff --git a/web_ui/src/islands/chat-library/App.tsx b/web_ui/src/islands/chat-library/App.tsx deleted file mode 100644 index 9bfb1e74..00000000 --- a/web_ui/src/islands/chat-library/App.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { ChatLibraryState, initialChatLibraryState } from "./bridgeTypes"; -import { BridgeRejection, ChatLibraryBridge, createChatLibraryBridge } from "./bridge"; -import { BridgeErrorState } from "../../lib/ui/BridgeErrorState"; - -function App() { - const [state, setState] = useState(initialChatLibraryState); - const [rejection, setRejection] = useState(null); - const [query, setQuery] = useState(""); - const [selectedId, setSelectedId] = useState(null); - const [renamingId, setRenamingId] = useState(null); - const [renameDraft, setRenameDraft] = useState(""); - const [confirmingDeleteId, setConfirmingDeleteId] = useState(null); - const bridgeRef = useRef(null); - const searchRef = useRef(null); - const renameRef = useRef(null); - - useEffect(() => { - const bridge = createChatLibraryBridge(setState, setRejection); - bridgeRef.current = bridge; - bridge.ready(); - return () => { - bridge.dispose(); - bridgeRef.current = null; - }; - }, []); - - useEffect(() => { - searchRef.current?.focus(); - }, []); - - useEffect(() => { - if (renamingId !== null) { - renameRef.current?.focus(); - renameRef.current?.select(); - } - }, [renamingId]); - - // The notice is shown once per Python revision, then cleared locally the - // moment the user acts again - same pattern as command-palette (Python has - // no way to know the user moved on, so JS owns dismissing its own copy). - const [visibleNotice, setVisibleNotice] = useState(null); - const [noticeRevision, setNoticeRevision] = useState(state.revision); - if (noticeRevision !== state.revision) { - setNoticeRevision(state.revision); - setVisibleNotice(state.notice ?? null); - // A republish after delete/rename can drop the row a pending confirm/ - // rename targeted; clear both so they can't point at a gone row. - setConfirmingDeleteId(null); - setRenamingId(null); - } - - const filtered = useMemo(() => { - const term = query.trim().toLowerCase(); - if (!term) return state.rows; - return state.rows.filter((row) => - `${row.title} ${row.updatedLabel} ${row.createdLabel}`.toLowerCase().includes(term), - ); - }, [query, state.rows]); - - const effectiveSelected = filtered.find((row) => row.id === selectedId) ?? filtered[0] ?? null; - - if (rejection) { - return ( - - ); - } - - function selectRow(id: number) { - setSelectedId(id); - setConfirmingDeleteId(null); - setRenamingId(null); - setVisibleNotice(null); - } - - function load(id: number) { - bridgeRef.current?.loadChat(id); - } - - function startRename() { - if (!effectiveSelected) return; - setRenamingId(effectiveSelected.id); - setRenameDraft(effectiveSelected.title); - setConfirmingDeleteId(null); - } - - function commitRename() { - const title = renameDraft.trim(); - if (renamingId === null || !title) return; - bridgeRef.current?.renameChat(renamingId, title); - setRenamingId(null); - } - - function startDelete() { - if (!effectiveSelected) return; - setConfirmingDeleteId(effectiveSelected.id); - setRenamingId(null); - } - - function confirmDelete() { - if (confirmingDeleteId === null) return; - bridgeRef.current?.deleteChat(confirmingDeleteId); - setConfirmingDeleteId(null); - } - - function onSearchChange(event: React.ChangeEvent) { - setQuery(event.target.value); - setVisibleNotice(null); - setConfirmingDeleteId(null); - } - - function onSearchKeyDown(event: React.KeyboardEvent) { - if (event.key === "ArrowDown") { - event.preventDefault(); - if (filtered.length === 0) return; - const index = filtered.findIndex((row) => row.id === effectiveSelected?.id); - setSelectedId(filtered[Math.min(index + 1, filtered.length - 1)].id); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - if (filtered.length === 0) return; - const index = filtered.findIndex((row) => row.id === effectiveSelected?.id); - setSelectedId(filtered[Math.max(index - 1, 0)].id); - } else if (event.key === "Enter") { - event.preventDefault(); - if (effectiveSelected) load(effectiveSelected.id); - } else if (event.key === "Escape") { - event.preventDefault(); - if (confirmingDeleteId !== null) { - setConfirmingDeleteId(null); - } else { - bridgeRef.current?.close(); - } - } - } - - function onRenameKeyDown(event: React.KeyboardEvent) { - if (event.key === "Enter") { - event.preventDefault(); - commitRename(); - } else if (event.key === "Escape") { - event.preventDefault(); - setRenamingId(null); - } - } - - const total = state.rows.length; - const statusText = - total === 0 - ? "No saved chats yet." - : filtered.length !== total - ? `Showing ${filtered.length} of ${total} saved ${total === 1 ? "chat" : "chats"}.` - : `${total} saved ${total === 1 ? "chat" : "chats"}.`; - - return ( -
- - -
- {renamingId !== null ? ( - <> - setRenameDraft(event.target.value)} - onKeyDown={onRenameKeyDown} - aria-label="New chat title" - autoComplete="off" - spellCheck={false} - /> - - - - ) : confirmingDeleteId !== null ? ( - <> - - Delete this chat? This cannot be undone. - - - - - ) : ( - <> - - - - - )} -
- - {visibleNotice && ( -

- {visibleNotice} -

- )} - -
    - {total > 0 && filtered.length === 0 && ( -
  • No chats match your search.
  • - )} - {filtered.map((row) => ( -
  • selectRow(row.id)} - onDoubleClick={() => load(row.id)} - > -

    {row.title}

    -

    - Updated {row.updatedLabel} · Created {row.createdLabel} -

    -
  • - ))} -
- -

- {statusText} -

-
- ); -} - -export default App; diff --git a/web_ui/src/islands/chat-library/bridge.test.ts b/web_ui/src/islands/chat-library/bridge.test.ts deleted file mode 100644 index 9d05d450..00000000 --- a/web_ui/src/islands/chat-library/bridge.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createChatLibraryBridge } from "./bridge"; -import { initialChatLibraryState, ChatLibraryState } from "./bridgeTypes"; -import { QtWindow } from "../../lib/bridge-core/transport"; - -function stateJson(overrides: Partial = {}): string { - return JSON.stringify({ ...initialChatLibraryState, ...overrides }); -} - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - refresh: vi.fn(), - loadChat: vi.fn(), - deleteChat: vi.fn(), - renameChat: vi.fn(), - newChat: vi.fn(), - close: vi.fn(), - }; - - class FakeQWebChannel { - constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) { - callback({ objects: { chatLibraryBridge: remote } }); - } - } - - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - - return remote; -} - -function uninstallFakeQWebChannel() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -describe("createChatLibraryBridge with no QWebChannel available", () => { - it("falls back to the mock bridge and publishes the initial state on ready()", () => { - const listener = vi.fn(); - const bridge = createChatLibraryBridge(listener); - - bridge.ready(); - - expect(listener).toHaveBeenCalledExactlyOnceWith(initialChatLibraryState); - }); - - it("mutating intents on the mock bridge do not throw", () => { - const bridge = createChatLibraryBridge(() => {}); - expect(() => { - bridge.loadChat(1); - bridge.deleteChat(1); - bridge.renameChat(1, "x"); - bridge.newChat(); - bridge.close(); - bridge.refresh(); - bridge.dispose(); - }).not.toThrow(); - }); -}); - -describe("createChatLibraryBridge against a real QWebChannel connection", () => { - it("connects synchronously and calls remote.ready()", () => { - const remote = installFakeQWebChannel(); - try { - createChatLibraryBridge(() => {}); - expect(remote.ready).toHaveBeenCalledTimes(1); - expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("forwards rows pushed through stateChanged to the listener", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - createChatLibraryBridge(listener); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push( - stateJson({ - revision: 3, - rows: [{ id: 7, title: "A chat", createdLabel: "c", updatedLabel: "u" }], - }), - ); - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - revision: 3, - rows: [{ id: 7, title: "A chat", createdLabel: "c", updatedLabel: "u" }], - }), - ); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("each intent calls through to the matching remote method with its args", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createChatLibraryBridge(() => {}); - - bridge.loadChat(5); - bridge.deleteChat(6); - bridge.renameChat(7, "New Title"); - bridge.newChat(); - bridge.close(); - - expect(remote.loadChat).toHaveBeenCalledWith(5); - expect(remote.deleteChat).toHaveBeenCalledWith(6); - expect(remote.renameChat).toHaveBeenCalledWith(7, "New Title"); - expect(remote.newChat).toHaveBeenCalledTimes(1); - expect(remote.close).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("rejects a payload with an incompatible schema version instead of forwarding it", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - const onRejection = vi.fn(); - createChatLibraryBridge(listener, onRejection); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - listener.mockClear(); - - push(stateJson({ minCompatibleSchemaVersion: 999 })); - - expect(listener).not.toHaveBeenCalled(); - expect(onRejection).toHaveBeenCalledWith(expect.objectContaining({ kind: "version" })); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("dispose() disconnects the stateChanged listener", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createChatLibraryBridge(() => {}); - const handler = remote.stateChanged.connect.mock.calls[0][0]; - - bridge.dispose(); - - expect(remote.stateChanged.disconnect).toHaveBeenCalledWith(handler); - } finally { - uninstallFakeQWebChannel(); - } - }); -}); diff --git a/web_ui/src/islands/chat-library/bridge.ts b/web_ui/src/islands/chat-library/bridge.ts deleted file mode 100644 index 673352d1..00000000 --- a/web_ui/src/islands/chat-library/bridge.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { ChatLibraryState, initialChatLibraryState } from "./bridgeTypes"; -import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport"; -import { installTextFocusReporting } from "../../lib/bridge-core/textFocus"; -import { validateChatLibraryState } from "../../lib/bridge-core/generated/chat-library-state"; -import { - BridgeRejection, - RejectionListener, - parseIslandState, -} from "../../lib/bridge-core/islandState"; - -export type { BridgeRejection, RejectionListener }; - -type StateListener = (state: ChatLibraryState) => void; - -interface QtSignal { - connect(listener: (value: T) => void): void; - disconnect?: (listener: (value: T) => void) => void; -} - -interface QtChatLibraryObject { - stateChanged: QtSignal; - ready: () => void; - refresh: () => void; - loadChat: (id: number) => void; - deleteChat: (id: number) => void; - renameChat: (id: number, title: string) => void; - newChat: () => void; - close: () => void; -} - -export interface ChatLibraryBridge { - ready(): void; - refresh(): void; - loadChat(id: number): void; - deleteChat(id: number): void; - renameChat(id: number, title: string): void; - newChat(): void; - close(): void; - dispose(): void; -} - -function parseState(payload: string) { - return parseIslandState(payload, validateChatLibraryState); -} - -class MockChatLibraryBridge implements ChatLibraryBridge { - private readonly state: ChatLibraryState = structuredClone(initialChatLibraryState); - private readonly listener: StateListener; - - constructor(listener: StateListener) { - this.listener = listener; - } - - ready(): void { - this.listener(this.state); - } - - refresh(): void {} - loadChat(): void {} - deleteChat(): void {} - renameChat(): void {} - newChat(): void {} - close(): void {} - dispose(): void {} -} - -export function createChatLibraryBridge( - listener: StateListener, - onRejection?: RejectionListener, -): ChatLibraryBridge { - const fallback = new MockChatLibraryBridge(listener); - - if (!isQWebChannelAvailable()) { - return fallback; - } - - let remote: QtChatLibraryObject | null = null; - let connected = false; - const stateListener = (payload: string) => { - const outcome = parseState(payload); - if (outcome.ok) { - listener(outcome.state); - onRejection?.(null); - return; - } - console.error( - `[chat-library bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`, - outcome.rejection.details, - ); - onRejection?.(outcome.rejection); - }; - - connectQWebChannel((objects) => { - remote = objects.chatLibraryBridge as QtChatLibraryObject; - remote.stateChanged.connect(stateListener); - connected = true; - remote.ready(); - // Real text inputs (search + inline rename) mean this island must - // participate in the keyboard-arbitration protocol, like command-palette. - installTextFocusReporting(objects); - }); - - const call = ( - method: K, - ...args: QtChatLibraryObject[K] extends (...values: infer A) => void ? A : never - ) => { - if (connected && remote && typeof remote[method] === "function") { - (remote[method] as (...values: unknown[]) => void)(...args); - } - }; - - return { - ready: () => call("ready"), - refresh: () => call("refresh"), - loadChat: (id) => call("loadChat", id), - deleteChat: (id) => call("deleteChat", id), - renameChat: (id, title) => call("renameChat", id, title), - newChat: () => call("newChat"), - close: () => call("close"), - dispose: () => { - remote?.stateChanged.disconnect?.(stateListener); - remote = null; - }, - }; -} diff --git a/web_ui/src/islands/chat-library/bridgeTypes.ts b/web_ui/src/islands/chat-library/bridgeTypes.ts deleted file mode 100644 index e9c8898f..00000000 --- a/web_ui/src/islands/chat-library/bridgeTypes.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * The chat-library island's state contract. - * - * See composer/bridgeTypes.ts for the fuller rationale (re-export from the - * generated file, not a hand mirror). What legitimately still lives here: - * initialChatLibraryState, the mock snapshot used for browser-preview/dev - * and by jsdom tests. - */ -export type { ChatLibraryState, ChatLibraryRow } from "../../lib/bridge-core/generated/chat-library-state"; - -import type { ChatLibraryState } from "../../lib/bridge-core/generated/chat-library-state"; - -export const initialChatLibraryState: ChatLibraryState = { - schemaVersion: 1, - minCompatibleSchemaVersion: 1, - revision: 0, - rows: [], - notice: null, -}; diff --git a/web_ui/src/islands/chat-library/index.html b/web_ui/src/islands/chat-library/index.html deleted file mode 100644 index 569cb613..00000000 --- a/web_ui/src/islands/chat-library/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Graphlink Chat Library - - -
- - - diff --git a/web_ui/src/islands/chat-library/main.tsx b/web_ui/src/islands/chat-library/main.tsx deleted file mode 100644 index bb58ae83..00000000 --- a/web_ui/src/islands/chat-library/main.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; -import "../../lib/ui/base.css"; -import "./styles.css"; - -// See composer/main.tsx for the full rationale: styles.css resolves colors -// through var(--gl-*), which only exist in the real app via the Python -// host's build-time :root injection - npm run dev has no Python in the -// loop, so this supplies the same variables for browser preview. -if (import.meta.env.DEV) { - await import("../../lib/tokens/gl-vars-dev.css"); -} - -createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/web_ui/src/islands/chat-library/styles.css b/web_ui/src/islands/chat-library/styles.css deleted file mode 100644 index 0340aa37..00000000 --- a/web_ui/src/islands/chat-library/styles.css +++ /dev/null @@ -1,198 +0,0 @@ -/* Colors reuse EXISTING app-wide --gl-* tokens, same convention every - island since token-counter follows. This island is the CONTENT region - embedded inside the native ChatLibraryDialog shell (which keeps its own - frameless window chrome, title bar, and Close button), so the shell here - is transparent and fills its host - the native QFrame#libraryShell owns - the panel background and rounded corners. */ - -html, -body, -#root { - margin: 0; - padding: 0; - height: 100%; - background: transparent; -} - -.library-shell { - box-sizing: border-box; - width: 100%; - height: 100vh; - display: flex; - flex-direction: column; - gap: 12px; - font-family: var(--gl-font-family, system-ui, sans-serif); - color: var(--gl-neutral-button-icon); - background: transparent; -} - -.library-search-input { - box-sizing: border-box; - flex-shrink: 0; - width: 100%; - padding: 10px 12px; - font-size: 13px; - font-family: inherit; - color: var(--gl-neutral-button-icon); - background-color: var(--gl-graph-node-body-start); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 9px; - outline: none; -} - -.library-search-input:focus { - border-color: var(--gl-palette-selection); -} - -.library-toolbar { - flex-shrink: 0; - display: flex; - align-items: center; - gap: 8px; -} - -.library-button { - padding: 8px 12px; - font-size: 11px; - font-weight: 600; - font-family: inherit; - color: var(--gl-neutral-button-icon); - background-color: var(--gl-neutral-button-background); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 8px; - cursor: pointer; -} - -.library-button:hover:not(:disabled) { - background-color: var(--gl-neutral-button-hover); -} - -.library-button:disabled { - opacity: 0.45; - cursor: default; -} - -.library-button-primary { - color: var(--gl-palette-selection); - border-color: var(--gl-palette-selection); -} - -.library-button-danger { - color: var(--gl-semantic-status-error); - border-color: var(--gl-semantic-status-error); -} - -.library-rename-input { - box-sizing: border-box; - flex: 1; - min-width: 0; - padding: 8px 10px; - font-size: 12px; - font-family: inherit; - color: var(--gl-neutral-button-icon); - background-color: var(--gl-graph-node-body-start); - border: 1px solid var(--gl-palette-selection); - border-radius: 8px; - outline: none; -} - -.library-confirm-text { - flex: 1; - min-width: 0; - font-size: 12px; - color: var(--gl-semantic-status-warning); -} - -.library-notice { - flex-shrink: 0; - margin: 0; - padding: 8px 10px; - font-size: 12px; - color: var(--gl-semantic-status-warning); - background-color: var(--gl-neutral-button-background); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 8px; -} - -.library-list { - flex: 1; - min-height: 0; - margin: 0; - padding: 0; - list-style: none; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 6px; -} - -.library-empty { - padding: 12px 14px; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} - -.library-row { - box-sizing: border-box; - padding: 12px 14px; - border: 1px solid var(--gl-neutral-button-border); - border-radius: 10px; - background-color: var(--gl-neutral-button-background); - cursor: pointer; -} - -.library-row:hover { - background-color: var(--gl-neutral-button-hover); -} - -.library-row.selected { - background-color: var(--gl-palette-selection); - border-color: var(--gl-palette-selection); -} - -.library-row-title { - margin: 0 0 4px 0; - font-size: 13px; - font-weight: 600; -} - -.library-row-meta { - margin: 0; - font-size: 11px; - color: var(--gl-neutral-button-muted-icon); -} - -.library-row.selected .library-row-meta { - color: var(--gl-neutral-button-icon); -} - -.library-status { - flex-shrink: 0; - margin: 0; - padding-left: 2px; - font-size: 11px; - color: var(--gl-neutral-button-muted-icon); -} - -.bridge-error-title { - font-weight: 600; - margin-bottom: 4px; -} - -.bridge-error-reason { - margin: 0 0 4px 0; - font-size: 13px; -} - -.bridge-error-details { - margin: 0; - padding-left: 16px; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} - -.bridge-error-hint { - margin: 8px 0 0 0; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} diff --git a/web_ui/src/islands/command-palette/App.test.tsx b/web_ui/src/islands/command-palette/App.test.tsx deleted file mode 100644 index 64ac806e..00000000 --- a/web_ui/src/islands/command-palette/App.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import App from "./App"; -import { initialCommandPaletteState } from "./bridgeTypes"; -import type { QtWindow } from "../../lib/bridge-core/transport"; - -// jsdom has no window.QWebChannel, so createCommandPaletteBridge() falls -// through to the mock bridge automatically for the smoke test below - same -// pattern as notification/App.test.tsx. - -describe("App against the mock bridge", () => { - it("starts hidden, matching the initial visible:false state", () => { - render(); - expect(screen.getByRole("dialog", { hidden: true })).not.toBeVisible(); - }); -}); - -// The mock bridge is deliberately inert (there is no JS-callable "open" - -// only Python's show_command_palette() ever opens the palette), so -// interactive behavior needs a real (faked) QWebChannel connection, same -// approach as notification/App.test.tsx. - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - executeCommand: vi.fn(), - dismiss: vi.fn(), - }; - class FakeQWebChannel { - constructor(_t: unknown, cb: (channel: { objects: Record }) => void) { - cb({ objects: { commandPaletteBridge: remote } }); - } - } - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - return remote; -} - -function uninstall() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -const openPayload = (overrides: Partial = {}) => - JSON.stringify({ - ...initialCommandPaletteState, - visible: true, - revision: 1, - commands: [ - { id: "0", name: "New Chat", aliases: ["new chat", "start new"] }, - { id: "1", name: "Reset View", aliases: ["reset view", "reset zoom"] }, - ], - ...overrides, - }); - -describe("App against a real (faked) QWebChannel connection", () => { - afterEach(() => { - cleanup(); - uninstall(); - vi.restoreAllMocks(); - }); - - it("renders every command once Python publishes a visible state", async () => { - const remote = installFakeQWebChannel(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push(openPayload()); - - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - expect(screen.getByText("New Chat")).toBeInTheDocument(); - expect(screen.getByText("Reset View")).toBeInTheDocument(); - }); - - it("filters client-side against aliases as the user types, with no bridge calls", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push(openPayload()); - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - - await user.type(screen.getByLabelText("Search commands"), "zoom"); - - expect(screen.getByText("Reset View")).toBeInTheDocument(); - expect(screen.queryByText("New Chat")).not.toBeInTheDocument(); - expect(remote.executeCommand).not.toHaveBeenCalled(); - }); - - it("Enter executes the currently-selected (first) filtered command", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push(openPayload()); - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - - await user.type(screen.getByLabelText("Search commands"), "{Enter}"); - - expect(remote.executeCommand).toHaveBeenCalledWith("0"); - }); - - it("ArrowDown then Enter executes the second command", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push(openPayload()); - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - - await user.type(screen.getByLabelText("Search commands"), "{ArrowDown}{Enter}"); - - expect(remote.executeCommand).toHaveBeenCalledWith("1"); - }); - - it("clicking a result executes it directly", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push(openPayload()); - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - - await user.click(screen.getByText("Reset View")); - - expect(remote.executeCommand).toHaveBeenCalledWith("1"); - }); - - it("Escape calls dismiss", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push(openPayload()); - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - - await user.type(screen.getByLabelText("Search commands"), "{Escape}"); - - expect(remote.dismiss).toHaveBeenCalledTimes(1); - }); - - it("shows a stale-command notice and clears it on the next keystroke", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push(openPayload()); - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - - push(openPayload({ revision: 2, notice: "That command is no longer available." })); - await waitFor(() => - expect(screen.getByText("That command is no longer available.")).toBeInTheDocument(), - ); - - await user.type(screen.getByLabelText("Search commands"), "x"); - - expect(screen.queryByText("That command is no longer available.")).not.toBeInTheDocument(); - }); - - it("a fresh open (visible false -> true) resets a leftover query", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - push(openPayload()); - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - await user.type(screen.getByLabelText("Search commands"), "zoom"); - expect((screen.getByLabelText("Search commands") as HTMLInputElement).value).toBe("zoom"); - - push(JSON.stringify({ ...initialCommandPaletteState, visible: false, revision: 2 })); - await waitFor(() => expect(screen.getByRole("dialog", { hidden: true })).not.toBeVisible()); - push(openPayload({ revision: 3 })); - await waitFor(() => expect(screen.getByRole("dialog")).toBeVisible()); - - expect((screen.getByLabelText("Search commands") as HTMLInputElement).value).toBe(""); - }); - - // Confirms App.tsx actually reaches the shared lib/ui/BridgeErrorState on a - // rejected payload, with this island's own title/className - the shared - // component's own rendering logic is covered by - // lib/ui/BridgeErrorState.test.tsx, this only proves the wiring at this - // specific call site is correct. - it("renders the shared error state on a rejected payload", async () => { - const remote = installFakeQWebChannel(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push(JSON.stringify({ ...initialCommandPaletteState, minCompatibleSchemaVersion: 999 })); - - expect(await screen.findByRole("alert")).toBeInTheDocument(); - expect(screen.getByText("Command palette unavailable")).toBeInTheDocument(); - }); -}); diff --git a/web_ui/src/islands/command-palette/App.tsx b/web_ui/src/islands/command-palette/App.tsx deleted file mode 100644 index f16a2b87..00000000 --- a/web_ui/src/islands/command-palette/App.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { CommandPaletteState, initialCommandPaletteState } from "./bridgeTypes"; -import { BridgeRejection, CommandPaletteBridge, createCommandPaletteBridge } from "./bridge"; -import { BridgeErrorState } from "../../lib/ui/BridgeErrorState"; - -function App() { - const [state, setState] = useState(initialCommandPaletteState); - const [rejection, setRejection] = useState(null); - const [query, setQuery] = useState(""); - const [selectedIndex, setSelectedIndex] = useState(0); - const bridgeRef = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - const bridge = createCommandPaletteBridge(setState, setRejection); - bridgeRef.current = bridge; - bridge.ready(); - return () => { - bridge.dispose(); - bridgeRef.current = null; - }; - }, []); - - // Every genuine (re)open (Python's visible flips false -> true) resets the - // search box and selection - done during render (React's documented - // "adjusting state when a prop changes" pattern), not in an effect, per - // this island family's established set-state-in-effect lint fix. Keyed - // specifically on the false->true transition, not on every publish: a - // stale-command republish (see bridge module docs) keeps visible=true - // throughout and must NOT wipe whatever the user is still typing. - const [wasVisible, setWasVisible] = useState(state.visible); - if (state.visible !== wasVisible) { - setWasVisible(state.visible); - if (state.visible) { - setQuery(""); - setSelectedIndex(0); - } - } - - // The "no longer available" notice is shown once per Python revision, then - // cleared locally the moment the user types again - Python has no way to - // know the user started a new search, so JS owns dismissing its own copy. - const [visibleNotice, setVisibleNotice] = useState(null); - const [noticeRevision, setNoticeRevision] = useState(state.revision); - if (noticeRevision !== state.revision) { - setNoticeRevision(state.revision); - setVisibleNotice(state.notice ?? null); - } - - useEffect(() => { - if (state.visible) inputRef.current?.focus(); - }, [state.visible]); - - const filtered = useMemo(() => { - const term = query.toLowerCase().trim(); - if (!term) return state.commands; - return state.commands.filter((command) => - command.aliases.some((alias) => alias.includes(term)), - ); - }, [query, state.commands]); - - const clampedIndex = Math.min(selectedIndex, Math.max(filtered.length - 1, 0)); - - if (rejection) { - return ( - - ); - } - - function onQueryChange(event: React.ChangeEvent) { - setQuery(event.target.value); - setSelectedIndex(0); - setVisibleNotice(null); - } - - function execute(id: string) { - bridgeRef.current?.executeCommand(id); - } - - function onKeyDown(event: React.KeyboardEvent) { - if (event.key === "ArrowDown") { - event.preventDefault(); - setSelectedIndex((index) => Math.min(index + 1, Math.max(filtered.length - 1, 0))); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - setSelectedIndex((index) => Math.max(index - 1, 0)); - } else if (event.key === "Enter") { - event.preventDefault(); - const command = filtered[clampedIndex]; - if (command) execute(command.id); - } else if (event.key === "Escape") { - event.preventDefault(); - bridgeRef.current?.dismiss(); - } - } - - return ( -
- - - {visibleNotice && ( -

- {visibleNotice} -

- )} - -
    - {filtered.length === 0 &&
  • No matching commands
  • } - {filtered.map((command, index) => ( -
  • setSelectedIndex(index)} - onClick={() => execute(command.id)} - > - {command.name} -
  • - ))} -
-
- ); -} - -export default App; diff --git a/web_ui/src/islands/command-palette/bridge.test.ts b/web_ui/src/islands/command-palette/bridge.test.ts deleted file mode 100644 index fbce8802..00000000 --- a/web_ui/src/islands/command-palette/bridge.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createCommandPaletteBridge } from "./bridge"; -import { initialCommandPaletteState, CommandPaletteState } from "./bridgeTypes"; -import { QtWindow } from "../../lib/bridge-core/transport"; - -function stateJson(overrides: Partial = {}): string { - return JSON.stringify({ ...initialCommandPaletteState, ...overrides }); -} - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - executeCommand: vi.fn(), - dismiss: vi.fn(), - }; - - class FakeQWebChannel { - constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) { - callback({ objects: { commandPaletteBridge: remote } }); - } - } - - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - - return remote; -} - -function uninstallFakeQWebChannel() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -describe("createCommandPaletteBridge with no QWebChannel available", () => { - it("falls back to the mock bridge and publishes the initial state on ready()", () => { - const listener = vi.fn(); - const bridge = createCommandPaletteBridge(listener); - - bridge.ready(); - - expect(listener).toHaveBeenCalledExactlyOnceWith(initialCommandPaletteState); - }); - - it("dispose() on the mock bridge does not throw", () => { - const bridge = createCommandPaletteBridge(() => {}); - expect(() => bridge.dispose()).not.toThrow(); - }); -}); - -describe("createCommandPaletteBridge against a real QWebChannel connection", () => { - it("connects synchronously and calls remote.ready()", () => { - const remote = installFakeQWebChannel(); - try { - createCommandPaletteBridge(() => {}); - expect(remote.ready).toHaveBeenCalledTimes(1); - expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("forwards state pushed through stateChanged to the listener", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - createCommandPaletteBridge(listener); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push( - stateJson({ - visible: true, - commands: [{ id: "0", name: "New Chat", aliases: ["new chat"] }], - }), - ); - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ - visible: true, - commands: [{ id: "0", name: "New Chat", aliases: ["new chat"] }], - }), - ); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("rejects a payload with an incompatible schema version instead of forwarding it", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - const onRejection = vi.fn(); - createCommandPaletteBridge(listener, onRejection); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - listener.mockClear(); - - push(stateJson({ minCompatibleSchemaVersion: 999 })); - - expect(listener).not.toHaveBeenCalled(); - expect(onRejection).toHaveBeenCalledWith(expect.objectContaining({ kind: "version" })); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("gates outbound calls on the connected flag, not just remote's presence", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createCommandPaletteBridge(() => {}); - bridge.executeCommand("0"); - bridge.dismiss(); - - expect(remote.executeCommand).toHaveBeenCalledWith("0"); - expect(remote.dismiss).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("dispose() disconnects the state listener", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createCommandPaletteBridge(() => {}); - const handler = remote.stateChanged.connect.mock.calls[0][0]; - - bridge.dispose(); - - expect(remote.stateChanged.disconnect).toHaveBeenCalledWith(handler); - } finally { - uninstallFakeQWebChannel(); - } - }); -}); diff --git a/web_ui/src/islands/command-palette/bridge.ts b/web_ui/src/islands/command-palette/bridge.ts deleted file mode 100644 index 0a752b08..00000000 --- a/web_ui/src/islands/command-palette/bridge.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { CommandPaletteState, initialCommandPaletteState } from "./bridgeTypes"; -import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport"; -import { installTextFocusReporting } from "../../lib/bridge-core/textFocus"; -import { validateCommandPaletteState } from "../../lib/bridge-core/generated/command-palette-state"; -import { - BridgeRejection, - RejectionListener, - parseIslandState, -} from "../../lib/bridge-core/islandState"; - -export type { BridgeRejection, RejectionListener }; - -type StateListener = (state: CommandPaletteState) => void; - -interface QtSignal { - connect(listener: (value: T) => void): void; - disconnect?: (listener: (value: T) => void) => void; -} - -interface QtCommandPaletteObject { - stateChanged: QtSignal; - ready: () => void; - executeCommand: (id: string) => void; - dismiss: () => void; -} - -export interface CommandPaletteBridge { - ready(): void; - executeCommand(id: string): void; - dismiss(): void; - dispose(): void; -} - -/** - * Snapshot-and-execute shape: Python snapshots availability once on open() - * (Python-only - there is no JS-callable "open"; the palette only ever opens - * because show_command_palette() made the host visible) and pushes the full - * command list; JS filters/navigates that snapshot entirely client-side and - * sends back exactly one real intent, executeCommand(id), which Python - * re-validates against LIVE state before ever invoking a callback. See - * graphlink_command_palette_bridge.py's module docstring for the fuller - * rationale. - */ -function parseState(payload: string) { - return parseIslandState(payload, validateCommandPaletteState); -} - -class MockCommandPaletteBridge implements CommandPaletteBridge { - private state: CommandPaletteState = structuredClone(initialCommandPaletteState); - private readonly listener: StateListener; - - constructor(listener: StateListener) { - this.listener = listener; - } - - ready(): void { - this.listener(this.state); - } - - executeCommand(): void {} - dismiss(): void {} - dispose(): void {} -} - -export function createCommandPaletteBridge( - listener: StateListener, - onRejection?: RejectionListener, -): CommandPaletteBridge { - const fallback = new MockCommandPaletteBridge(listener); - - if (!isQWebChannelAvailable()) { - return fallback; - } - - let remote: QtCommandPaletteObject | null = null; - let connected = false; - const stateListener = (payload: string) => { - const outcome = parseState(payload); - if (outcome.ok) { - listener(outcome.state); - onRejection?.(null); - return; - } - console.error( - `[command-palette bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`, - outcome.rejection.details, - ); - onRejection?.(outcome.rejection); - }; - - connectQWebChannel((objects) => { - remote = objects.commandPaletteBridge as QtCommandPaletteObject; - remote.stateChanged.connect(stateListener); - connected = true; - remote.ready(); - // The palette has a real text search input (unlike notification), so it - // must participate in the keyboard-arbitration protocol the same way - // composer does - see AcceleratorForwardingFilter. - installTextFocusReporting(objects); - }); - - const call = ( - method: K, - ...args: QtCommandPaletteObject[K] extends (...values: infer A) => void ? A : never - ) => { - if (connected && remote && typeof remote[method] === "function") { - (remote[method] as (...values: unknown[]) => void)(...args); - } - }; - - return { - ready: () => call("ready"), - executeCommand: (id) => call("executeCommand", id), - dismiss: () => call("dismiss"), - dispose: () => { - remote?.stateChanged.disconnect?.(stateListener); - remote = null; - }, - }; -} diff --git a/web_ui/src/islands/command-palette/bridgeTypes.ts b/web_ui/src/islands/command-palette/bridgeTypes.ts deleted file mode 100644 index 63b61cbb..00000000 --- a/web_ui/src/islands/command-palette/bridgeTypes.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * The command-palette island's state contract. - * - * See composer/bridgeTypes.ts for the fuller rationale (re-export from the - * generated file, not a hand mirror). What legitimately still lives here: - * initialCommandPaletteState, the mock snapshot used for browser-preview/dev - * and by jsdom tests. - */ -export type { CommandPaletteState, CommandEntry } from "../../lib/bridge-core/generated/command-palette-state"; - -import type { CommandPaletteState } from "../../lib/bridge-core/generated/command-palette-state"; - -export const initialCommandPaletteState: CommandPaletteState = { - schemaVersion: 1, - minCompatibleSchemaVersion: 1, - revision: 0, - visible: false, - commands: [], - notice: null, -}; diff --git a/web_ui/src/islands/command-palette/index.html b/web_ui/src/islands/command-palette/index.html deleted file mode 100644 index d1907b68..00000000 --- a/web_ui/src/islands/command-palette/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Graphlink Command Palette - - -
- - - diff --git a/web_ui/src/islands/command-palette/main.tsx b/web_ui/src/islands/command-palette/main.tsx deleted file mode 100644 index bb58ae83..00000000 --- a/web_ui/src/islands/command-palette/main.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; -import "../../lib/ui/base.css"; -import "./styles.css"; - -// See composer/main.tsx for the full rationale: styles.css resolves colors -// through var(--gl-*), which only exist in the real app via the Python -// host's build-time :root injection - npm run dev has no Python in the -// loop, so this supplies the same variables for browser preview. -if (import.meta.env.DEV) { - await import("../../lib/tokens/gl-vars-dev.css"); -} - -createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/web_ui/src/islands/command-palette/styles.css b/web_ui/src/islands/command-palette/styles.css deleted file mode 100644 index eb17f6fb..00000000 --- a/web_ui/src/islands/command-palette/styles.css +++ /dev/null @@ -1,130 +0,0 @@ -/* Colors reuse EXISTING app-wide --gl-* tokens, same as token-counter and - notification before it - this island's selected-item highlight needs - exactly one accent color, and --gl-semantic-status-success (the same - token family notification already uses for its 4 message types) is a - direct match for the old widget's get_semantic_color("status_success"). - No island-scoped THEME_TOKENS group needed; this is the THIRD island in a - row that doesn't turn out to be the "island #2" moment - css_custom_properties()'s docstring is watching for. - - Simplification from the old QWidget, deliberate and recorded (same - convention as token-counter's dropped box-shadow and notification's - dropped animation): no QGraphicsDropShadowEffect-equivalent box-shadow - - would need a raw rgba() the no-raw-colors lint rejects, for a cosmetic - difference not worth a new token. A flat border reads clearly enough at - this size. */ - -html, -body, -#root { - margin: 0; - padding: 0; - background: transparent; -} - -.palette-shell { - box-sizing: border-box; - width: 600px; - height: 400px; - display: flex; - flex-direction: column; - padding: 12px; - gap: 10px; - border: 1px solid var(--gl-neutral-button-border); - border-radius: 8px; - background-color: var(--gl-graph-node-panel-fill); - font-family: var(--gl-font-family, system-ui, sans-serif); -} - -.palette-search-input { - box-sizing: border-box; - width: 100%; - padding: 10px; - font-size: 14px; - font-family: inherit; - color: var(--gl-neutral-button-icon); - background-color: var(--gl-neutral-button-background); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 4px; -} - -.palette-search-input:focus { - outline: none; - border-color: var(--gl-semantic-status-success); -} - -.palette-search-input::placeholder { - color: var(--gl-neutral-button-muted-icon); -} - -.palette-notice { - margin: 0; - padding: 6px 10px; - font-size: 12px; - color: var(--gl-semantic-status-warning); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 4px; - background-color: var(--gl-neutral-button-background); -} - -.palette-results { - flex: 1; - margin: 0; - padding: 4px; - list-style: none; - overflow-y: auto; - border: 1px solid var(--gl-neutral-button-border); - border-radius: 4px; - background-color: var(--gl-neutral-button-background); -} - -.palette-result { - padding: 10px; - border-radius: 4px; - font-size: 13px; - color: var(--gl-neutral-button-icon); - cursor: pointer; -} - -.palette-result.selected { - background-color: var(--gl-semantic-status-success); - color: var(--gl-neutral-button-icon); -} - -.palette-empty { - padding: 10px; - font-size: 13px; - color: var(--gl-neutral-button-muted-icon); -} - -.palette-error { - padding: 14px 16px; - border: 1px solid var(--gl-neutral-button-border); - border-radius: 8px; - background-color: var(--gl-graph-node-panel-fill); - font-family: var(--gl-font-family, system-ui, sans-serif); - color: var(--gl-neutral-button-icon); -} - -.bridge-error-title { - font-weight: 600; - margin-bottom: 4px; -} - -.bridge-error-reason { - margin: 0 0 4px 0; - font-size: 13px; -} - -.bridge-error-details { - margin: 0; - padding-left: 16px; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} - -.bridge-error-hint { - margin: 8px 0 0 0; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} diff --git a/web_ui/src/islands/composer-context/App.test.tsx b/web_ui/src/islands/composer-context/App.test.tsx deleted file mode 100644 index 519c8e85..00000000 --- a/web_ui/src/islands/composer-context/App.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import App from "./App"; -import { initialComposerContextState } from "./bridgeTypes"; -import type { QtWindow } from "../../lib/bridge-core/transport"; - -const ANCHOR = { id: "node-1", label: "Root cause analysis", type: "Chat node" }; -const ITEMS = [ - { id: "attachment-1", name: "notes.txt", kind: "document", tokenCount: 120 }, - { id: "attachment-2", name: "diagram.png", kind: "image", tokenCount: 300 }, -]; - -describe("App against the mock bridge", () => { - afterEach(cleanup); - - it("renders zero tokens and no list when there is no context", () => { - render(); - - expect(screen.getByText("Estimated context · 0 tokens")).toBeInTheDocument(); - }); -}); - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - removeContextItem: vi.fn(), - resize: vi.fn(), - close: vi.fn(), - }; - class FakeQWebChannel { - constructor(_t: unknown, cb: (channel: { objects: Record }) => void) { - cb({ objects: { composerContextBridge: remote } }); - } - } - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - return remote; -} - -function uninstall() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -type Remote = ReturnType; - -function push(remote: Remote, overrides: Record = {}) { - const handler = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - handler(JSON.stringify({ ...initialComposerContextState, revision: 1, ...overrides })); -} - -describe("App against a real (faked) QWebChannel connection", () => { - afterEach(() => { - cleanup(); - uninstall(); - vi.restoreAllMocks(); - }); - - it("renders the anchor row (non-removable) and each item row (removable) plus the token total", async () => { - const remote = installFakeQWebChannel(); - render(); - - push(remote, { anchor: ANCHOR, items: ITEMS, totalTokens: 420 }); - - expect(await screen.findByText("Root cause analysis")).toBeInTheDocument(); - expect(screen.getByText("notes.txt")).toBeInTheDocument(); - expect(screen.getByText("diagram.png")).toBeInTheDocument(); - expect(screen.getByText("Estimated context · 420 tokens")).toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "Remove Root cause analysis" })).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Remove notes.txt" })).toBeInTheDocument(); - }); - - it("clicking Remove on an item calls removeContextItem with its id", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { anchor: ANCHOR, items: ITEMS, totalTokens: 420 }); - await screen.findByText("notes.txt"); - - await user.click(screen.getByRole("button", { name: "Remove notes.txt" })); - - expect(remote.removeContextItem).toHaveBeenCalledWith("attachment-1"); - }); - - it("Close button calls close", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { anchor: ANCHOR, items: ITEMS, totalTokens: 420 }); - await screen.findByText("notes.txt"); - - await user.click(screen.getByRole("button", { name: "Close context review" })); - - expect(remote.close).toHaveBeenCalledTimes(1); - }); - - it("Escape anywhere in the surface calls close", async () => { - const remote = installFakeQWebChannel(); - render(); - push(remote, { anchor: ANCHOR, items: ITEMS, totalTokens: 420 }); - await screen.findByText("notes.txt"); - - const escape = new KeyboardEvent("keydown", { key: "Escape" }); - window.dispatchEvent(escape); - - expect(remote.close).toHaveBeenCalledTimes(1); - }); - - it("renders the shared error state on a rejected payload", async () => { - const remote = installFakeQWebChannel(); - render(); - const handler = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - handler(JSON.stringify({ ...initialComposerContextState, minCompatibleSchemaVersion: 999 })); - - expect(await screen.findByRole("alert")).toBeInTheDocument(); - expect(screen.getByText("Context review is unavailable")).toBeInTheDocument(); - }); -}); diff --git a/web_ui/src/islands/composer-context/App.tsx b/web_ui/src/islands/composer-context/App.tsx deleted file mode 100644 index 2d4b97fa..00000000 --- a/web_ui/src/islands/composer-context/App.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { ComposerContextState, initialComposerContextState } from "./bridgeTypes"; -import { BridgeRejection, ComposerContextBridge, createComposerContextBridge } from "./bridge"; -import { BridgeErrorState } from "../../lib/ui/BridgeErrorState"; - -function App() { - const [state, setState] = useState(initialComposerContextState); - const [rejection, setRejection] = useState(null); - const bridgeRef = useRef(null); - const shellRef = useRef(null); - - useEffect(() => { - const bridge = createComposerContextBridge(setState, setRejection); - bridgeRef.current = bridge; - bridge.ready(); - return () => { - bridge.dispose(); - bridgeRef.current = null; - }; - }, []); - - // Content-driven height negotiation, matching PinOverlayHost's own - // ResizeObserver-based reporting. - useEffect(() => { - const element = shellRef.current; - if (!element) return; - const observer = new ResizeObserver((entries) => { - const height = entries[0]?.contentRect.height; - if (height) bridgeRef.current?.resize(Math.ceil(height)); - }); - observer.observe(element); - return () => observer.disconnect(); - }, []); - - // Escape closes from anywhere in the surface, matching the legacy native - // popup's own keyPressEvent override - outside-click-close itself is - // handled natively (WebIslandHost's dismiss_on_outside_focus). - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") bridgeRef.current?.close(); - } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - if (rejection) { - return ( - - ); - } - - const hasRows = state.anchor !== null || state.items.length > 0; - - return ( -
-
-
-

Context

-

Included context

-
- -
- - {hasRows && ( -
    - {state.anchor && ( -
  • - {state.anchor.type || "Graph"} - - {state.anchor.label} - -
  • - )} - {state.items.map((item) => ( -
  • - {item.kind} - - {item.name} - - -
  • - ))} -
- )} - -

Estimated context · {state.totalTokens.toLocaleString()} tokens

-
- ); -} - -export default App; diff --git a/web_ui/src/islands/composer-context/bridge.test.ts b/web_ui/src/islands/composer-context/bridge.test.ts deleted file mode 100644 index 8b69ff9e..00000000 --- a/web_ui/src/islands/composer-context/bridge.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createComposerContextBridge } from "./bridge"; -import { initialComposerContextState, ComposerContextState } from "./bridgeTypes"; -import { QtWindow } from "../../lib/bridge-core/transport"; - -function stateJson(overrides: Partial = {}): string { - return JSON.stringify({ ...initialComposerContextState, ...overrides }); -} - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - removeContextItem: vi.fn(), - resize: vi.fn(), - close: vi.fn(), - }; - - class FakeQWebChannel { - constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) { - callback({ objects: { composerContextBridge: remote } }); - } - } - - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - - return remote; -} - -function uninstallFakeQWebChannel() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -describe("createComposerContextBridge with no QWebChannel available", () => { - it("falls back to the mock bridge and publishes the initial state on ready()", () => { - const listener = vi.fn(); - const bridge = createComposerContextBridge(listener); - - bridge.ready(); - - expect(listener).toHaveBeenCalledExactlyOnceWith(initialComposerContextState); - }); - - it("intents on the mock bridge do not throw", () => { - const bridge = createComposerContextBridge(() => {}); - expect(() => { - bridge.removeContextItem("attachment-1"); - bridge.resize(300); - bridge.close(); - bridge.dispose(); - }).not.toThrow(); - }); -}); - -describe("createComposerContextBridge against a real QWebChannel connection", () => { - it("connects synchronously and calls remote.ready()", () => { - const remote = installFakeQWebChannel(); - try { - createComposerContextBridge(() => {}); - expect(remote.ready).toHaveBeenCalledTimes(1); - expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("forwards context pushed through stateChanged to the listener", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - createComposerContextBridge(listener); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push( - stateJson({ - revision: 2, - items: [{ id: "attachment-1", name: "notes.txt", kind: "document", tokenCount: 42 }], - totalTokens: 42, - }), - ); - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ items: [expect.objectContaining({ id: "attachment-1" })] }), - ); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("each intent calls through to the matching remote method with its args", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createComposerContextBridge(() => {}); - - bridge.removeContextItem("attachment-1"); - bridge.resize(321); - bridge.close(); - - expect(remote.removeContextItem).toHaveBeenCalledWith("attachment-1"); - expect(remote.resize).toHaveBeenCalledWith(321); - expect(remote.close).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("rejects a payload with an incompatible schema version instead of forwarding it", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - const onRejection = vi.fn(); - createComposerContextBridge(listener, onRejection); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - listener.mockClear(); - - push(stateJson({ minCompatibleSchemaVersion: 999 })); - - expect(listener).not.toHaveBeenCalled(); - expect(onRejection).toHaveBeenCalledWith(expect.objectContaining({ kind: "version" })); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("dispose() disconnects the stateChanged listener", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createComposerContextBridge(() => {}); - const handler = remote.stateChanged.connect.mock.calls[0][0]; - - bridge.dispose(); - - expect(remote.stateChanged.disconnect).toHaveBeenCalledWith(handler); - } finally { - uninstallFakeQWebChannel(); - } - }); -}); diff --git a/web_ui/src/islands/composer-context/bridge.ts b/web_ui/src/islands/composer-context/bridge.ts deleted file mode 100644 index 63c86362..00000000 --- a/web_ui/src/islands/composer-context/bridge.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { ComposerContextState, initialComposerContextState } from "./bridgeTypes"; -import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport"; -import { validateComposerContextState } from "../../lib/bridge-core/generated/composer-context-state"; -import { - BridgeRejection, - RejectionListener, - parseIslandState, -} from "../../lib/bridge-core/islandState"; - -export type { BridgeRejection, RejectionListener }; - -type StateListener = (state: ComposerContextState) => void; - -interface QtSignal { - connect(listener: (value: T) => void): void; - disconnect?: (listener: (value: T) => void) => void; -} - -interface QtComposerContextObject { - stateChanged: QtSignal; - ready: () => void; - removeContextItem: (id: string) => void; - resize: (height: number) => void; - close: () => void; -} - -export interface ComposerContextBridge { - ready(): void; - removeContextItem(id: string): void; - resize(height: number): void; - close(): void; - dispose(): void; -} - -function parseState(payload: string) { - return parseIslandState(payload, validateComposerContextState); -} - -class MockComposerContextBridge implements ComposerContextBridge { - private readonly state: ComposerContextState = structuredClone(initialComposerContextState); - private readonly listener: StateListener; - - constructor(listener: StateListener) { - this.listener = listener; - } - - ready(): void { - this.listener(this.state); - } - - removeContextItem(): void {} - resize(): void {} - close(): void {} - dispose(): void {} -} - -export function createComposerContextBridge( - listener: StateListener, - onRejection?: RejectionListener, -): ComposerContextBridge { - const fallback = new MockComposerContextBridge(listener); - - if (!isQWebChannelAvailable()) { - return fallback; - } - - let remote: QtComposerContextObject | null = null; - let connected = false; - const stateListener = (payload: string) => { - const outcome = parseState(payload); - if (outcome.ok) { - listener(outcome.state); - onRejection?.(null); - return; - } - console.error( - `[composer-context bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`, - outcome.rejection.details, - ); - onRejection?.(outcome.rejection); - }; - - connectQWebChannel((objects) => { - remote = objects.composerContextBridge as QtComposerContextObject; - remote.stateChanged.connect(stateListener); - connected = true; - remote.ready(); - }); - - const call = ( - method: K, - ...args: QtComposerContextObject[K] extends (...values: infer A) => void ? A : never - ) => { - if (connected && remote && typeof remote[method] === "function") { - (remote[method] as (...values: unknown[]) => void)(...args); - } - }; - - return { - ready: () => call("ready"), - removeContextItem: (id) => call("removeContextItem", id), - resize: (height) => call("resize", height), - close: () => call("close"), - dispose: () => { - remote?.stateChanged.disconnect?.(stateListener); - remote = null; - }, - }; -} diff --git a/web_ui/src/islands/composer-context/bridgeTypes.ts b/web_ui/src/islands/composer-context/bridgeTypes.ts deleted file mode 100644 index 9e4ebddd..00000000 --- a/web_ui/src/islands/composer-context/bridgeTypes.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * The composer-context island's state contract. - * - * See composer/bridgeTypes.ts for the fuller rationale (re-export from the - * generated file, not a hand mirror). `anchor`/`items`/`totalTokens` are - * forwarded verbatim from ComposerBridge's own context dict - see - * graphlink_composer_context_payload.py for the full rationale. - */ -export type { - ComposerContextState, - ComposerContextAnchor, - ComposerContextItem, -} from "../../lib/bridge-core/generated/composer-context-state"; - -import type { ComposerContextState } from "../../lib/bridge-core/generated/composer-context-state"; - -export const initialComposerContextState: ComposerContextState = { - schemaVersion: 1, - minCompatibleSchemaVersion: 1, - revision: 0, - items: [], - totalTokens: 0, - anchor: null, -}; diff --git a/web_ui/src/islands/composer-context/index.html b/web_ui/src/islands/composer-context/index.html deleted file mode 100644 index 0b7ad8f5..00000000 --- a/web_ui/src/islands/composer-context/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Graphlink Context Review - - -
- - - diff --git a/web_ui/src/islands/composer-context/main.tsx b/web_ui/src/islands/composer-context/main.tsx deleted file mode 100644 index bb58ae83..00000000 --- a/web_ui/src/islands/composer-context/main.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; -import "../../lib/ui/base.css"; -import "./styles.css"; - -// See composer/main.tsx for the full rationale: styles.css resolves colors -// through var(--gl-*), which only exist in the real app via the Python -// host's build-time :root injection - npm run dev has no Python in the -// loop, so this supplies the same variables for browser preview. -if (import.meta.env.DEV) { - await import("../../lib/tokens/gl-vars-dev.css"); -} - -createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/web_ui/src/islands/composer-context/styles.css b/web_ui/src/islands/composer-context/styles.css deleted file mode 100644 index 2eff3818..00000000 --- a/web_ui/src/islands/composer-context/styles.css +++ /dev/null @@ -1,160 +0,0 @@ -/* Colors reuse EXISTING app-wide --gl-* tokens, same convention every - island since token-counter follows. Height is content-driven (a - ResizeObserver reports it to Python, which bounds it to - [COMPOSER_CONTEXT_MIN_HEIGHT, COMPOSER_CONTEXT_MAX_HEIGHT] - see App.tsx). */ - -html, -body, -#root { - margin: 0; - padding: 0; - background: transparent; -} - -.composer-context-shell { - box-sizing: border-box; - width: 440px; - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; - font-family: var(--gl-font-family, system-ui, sans-serif); - color: var(--gl-neutral-button-icon); - background-color: var(--gl-graph-node-panel-fill); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 14px; -} - -.composer-context-header { - display: flex; - align-items: flex-start; - gap: 12px; -} - -.composer-context-heading { - flex: 1; - min-width: 0; -} - -.composer-context-eyebrow { - margin: 0; - font-size: 9px; - font-weight: 700; - letter-spacing: 1px; - text-transform: uppercase; - color: var(--gl-neutral-button-muted-icon); -} - -.composer-context-title { - margin: 4px 0 0 0; - font-size: 13px; - font-weight: 650; -} - -.composer-context-close-btn { - flex-shrink: 0; - width: 25px; - height: 25px; - padding: 0; - font-size: 17px; - line-height: 1; - font-family: inherit; - color: var(--gl-neutral-button-muted-icon); - background-color: transparent; - border: 0; - border-radius: 7px; - cursor: pointer; -} - -.composer-context-close-btn:hover { - color: var(--gl-neutral-button-icon); - background-color: var(--gl-neutral-button-hover); -} - -.composer-context-list { - margin: 0; - padding: 6px; - list-style: none; - max-height: 280px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 4px; - background-color: var(--gl-graph-node-body-start); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 10px; -} - -.composer-context-row { - box-sizing: border-box; - display: flex; - align-items: flex-start; - gap: 12px; - padding: 8px 10px; - background-color: var(--gl-neutral-button-background); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 8px; -} - -.composer-context-kind { - flex-shrink: 0; - min-width: 58px; - font-size: 9px; - font-weight: 700; - color: var(--gl-neutral-button-muted-icon); -} - -.composer-context-name { - flex: 1; - min-width: 0; - font-size: 11px; - overflow-wrap: break-word; -} - -.composer-context-remove { - flex-shrink: 0; - height: 26px; - padding: 0 8px; - font-size: 9px; - font-weight: 650; - font-family: inherit; - color: var(--gl-neutral-button-icon); - background-color: var(--gl-neutral-button-background); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 6px; - cursor: pointer; -} - -.composer-context-remove:hover { - background-color: var(--gl-neutral-button-hover); -} - -.composer-context-total { - margin: 0; - padding: 0 4px; - font-size: 10px; - color: var(--gl-neutral-button-muted-icon); -} - -.bridge-error-title { - font-weight: 600; - margin-bottom: 4px; -} - -.bridge-error-reason { - margin: 0 0 4px 0; - font-size: 13px; -} - -.bridge-error-details { - margin: 0; - padding-left: 16px; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} - -.bridge-error-hint { - margin: 8px 0 0 0; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} diff --git a/web_ui/src/islands/composer-picker/App.test.tsx b/web_ui/src/islands/composer-picker/App.test.tsx deleted file mode 100644 index fe306eff..00000000 --- a/web_ui/src/islands/composer-picker/App.test.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import App from "./App"; -import { initialComposerPickerState } from "./bridgeTypes"; -import type { QtWindow } from "../../lib/bridge-core/transport"; - -const MODEL_OPTIONS = [ - { id: "gpt-4", label: "GPT-4", meta: "Selected", current: true, unavailable: false }, - { id: "gpt-3.5", label: "GPT-3.5", meta: "Available", current: false, unavailable: false }, - { id: "old-model", label: "Old Model", meta: "Available - verify in Settings", current: false, unavailable: true }, -]; - -const REASONING_OPTIONS = [ - { id: "Quick", label: "Quick", meta: "Direct responses with less deliberation.", current: true, unavailable: false }, - { id: "Thinking", label: "Thinking", meta: "More deliberate reasoning for complex requests.", current: false, unavailable: false }, -]; - -describe("App against the mock bridge", () => { - afterEach(cleanup); - - it("renders the empty-catalog message and no search input when there are no options", () => { - render(); - - expect(screen.getByText("No model catalog available yet.")).toBeInTheDocument(); - }); -}); - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - selectOption: vi.fn(), - requestSettings: vi.fn(), - resize: vi.fn(), - close: vi.fn(), - }; - class FakeQWebChannel { - constructor(_t: unknown, cb: (channel: { objects: Record }) => void) { - cb({ objects: { composerPickerBridge: remote } }); - } - } - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - return remote; -} - -function uninstall() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -type Remote = ReturnType; - -function push(remote: Remote, overrides: Record = {}) { - const handler = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - handler(JSON.stringify({ ...initialComposerPickerState, revision: 1, ...overrides })); -} - -describe("App against a real (faked) QWebChannel connection - model kind", () => { - afterEach(() => { - cleanup(); - uninstall(); - vi.restoreAllMocks(); - }); - - it("renders each model option's label/meta, a search box, and the Current badge", async () => { - const remote = installFakeQWebChannel(); - render(); - - push(remote, { kind: "model", title: "Ollama", options: MODEL_OPTIONS, openToken: 1 }); - - expect(await screen.findByText("GPT-4")).toBeInTheDocument(); - expect(screen.getByText("Selected")).toBeInTheDocument(); - expect(screen.getByText("Current")).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Search available models" })).toBeInTheDocument(); - }); - - it("filters options client-side as the user types, with no bridge round-trip", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { kind: "model", title: "Ollama", options: MODEL_OPTIONS, openToken: 1 }); - await screen.findByText("GPT-4"); - - await user.type(screen.getByRole("textbox", { name: "Search available models" }), "3.5"); - - expect(screen.queryByText("GPT-4")).not.toBeInTheDocument(); - expect(screen.getByText("GPT-3.5")).toBeInTheDocument(); - }); - - it("clicking an available row calls selectOption", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { kind: "model", title: "Ollama", options: MODEL_OPTIONS, openToken: 1 }); - await screen.findByText("GPT-3.5"); - - await user.click(screen.getByText("GPT-3.5")); - - expect(remote.selectOption).toHaveBeenCalledWith("gpt-3.5"); - }); - - it("an unavailable row is disabled and never calls selectOption", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { kind: "model", title: "Ollama", options: MODEL_OPTIONS, openToken: 1 }); - const row = await screen.findByRole("option", { name: /Old Model/ }); - - expect(row.querySelector("button")).toBeDisabled(); - await user.click(row); - expect(remote.selectOption).not.toHaveBeenCalled(); - }); - - it("shows the settings hint only when there is no query and zero options", async () => { - const remote = installFakeQWebChannel(); - render(); - - push(remote, { kind: "model", title: "Ollama", options: [], openToken: 1 }); - - expect(await screen.findByText("Open Settings to discover models")).toBeInTheDocument(); - }); - - it("clicking the settings hint calls requestSettings", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { kind: "model", title: "Ollama", options: [], openToken: 1 }); - await screen.findByText("Open Settings to discover models"); - - await user.click(screen.getByText("Open Settings to discover models")); - - expect(remote.requestSettings).toHaveBeenCalledTimes(1); - }); - - it("Close button calls close", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { kind: "model", title: "Ollama", options: MODEL_OPTIONS, openToken: 1 }); - await screen.findByText("GPT-4"); - - await user.click(screen.getByRole("button", { name: "Close selector" })); - - expect(remote.close).toHaveBeenCalledTimes(1); - }); - - it("Escape anywhere in the surface calls close", async () => { - const remote = installFakeQWebChannel(); - render(); - push(remote, { kind: "model", title: "Ollama", options: MODEL_OPTIONS, openToken: 1 }); - await screen.findByText("GPT-4"); - - const escape = new KeyboardEvent("keydown", { key: "Escape" }); - window.dispatchEvent(escape); - - expect(remote.close).toHaveBeenCalledTimes(1); - }); - - it("resets the local search query when a fresh openToken arrives", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { kind: "model", title: "Ollama", options: MODEL_OPTIONS, openToken: 1 }); - await user.type(screen.getByRole("textbox", { name: "Search available models" }), "3.5"); - expect(screen.queryByText("GPT-4")).not.toBeInTheDocument(); - - push(remote, { kind: "model", title: "Ollama", options: MODEL_OPTIONS, openToken: 2 }); - - expect(await screen.findByText("GPT-4")).toBeInTheDocument(); - expect(screen.getByRole("textbox", { name: "Search available models" })).toHaveValue(""); - }); -}); - -describe("App against a real (faked) QWebChannel connection - reasoning kind", () => { - afterEach(() => { - cleanup(); - uninstall(); - vi.restoreAllMocks(); - }); - - it("renders reasoning options with no search box", async () => { - const remote = installFakeQWebChannel(); - render(); - - push(remote, { - kind: "reasoning", - title: "Choose response depth", - options: REASONING_OPTIONS, - openToken: 1, - }); - - expect(await screen.findByText("Thinking")).toBeInTheDocument(); - expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); - }); - - it("clicking Thinking calls selectOption with its id", async () => { - const remote = installFakeQWebChannel(); - const user = userEvent.setup(); - render(); - push(remote, { - kind: "reasoning", - title: "Choose response depth", - options: REASONING_OPTIONS, - openToken: 1, - }); - await screen.findByText("Thinking"); - - await user.click(screen.getByText("Thinking")); - - expect(remote.selectOption).toHaveBeenCalledWith("Thinking"); - }); - - it("renders the shared error state on a rejected payload", async () => { - const remote = installFakeQWebChannel(); - render(); - const handler = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - handler(JSON.stringify({ ...initialComposerPickerState, minCompatibleSchemaVersion: 999 })); - - expect(await screen.findByRole("alert")).toBeInTheDocument(); - expect(screen.getByText("The picker is unavailable")).toBeInTheDocument(); - }); -}); diff --git a/web_ui/src/islands/composer-picker/App.tsx b/web_ui/src/islands/composer-picker/App.tsx deleted file mode 100644 index d3f885d7..00000000 --- a/web_ui/src/islands/composer-picker/App.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { ComposerPickerState, initialComposerPickerState } from "./bridgeTypes"; -import { BridgeRejection, ComposerPickerBridge, createComposerPickerBridge } from "./bridge"; -import { BridgeErrorState } from "../../lib/ui/BridgeErrorState"; - -function App() { - const [state, setState] = useState(initialComposerPickerState); - const [rejection, setRejection] = useState(null); - const [query, setQuery] = useState(""); - const [lastOpenToken, setLastOpenToken] = useState(0); - const bridgeRef = useRef(null); - const shellRef = useRef(null); - const searchRef = useRef(null); - - useEffect(() => { - const bridge = createComposerPickerBridge(setState, setRejection); - bridgeRef.current = bridge; - bridge.ready(); - return () => { - bridge.dispose(); - bridgeRef.current = null; - }; - }, []); - - // A fresh open() call bumps openToken server-side - reset the local - // search query whenever that happens, during render rather than an - // effect (the same "reset local state when a fresh X begins" pattern - // pin-overlay's own draft editor already uses) - a stale query from the - // LAST time this surface was open must not silently filter whatever kind - // just opened. - if (state.openToken !== lastOpenToken) { - setLastOpenToken(state.openToken); - setQuery(""); - } - - useEffect(() => { - searchRef.current?.focus(); - }, [state.openToken]); - - // Content-driven height negotiation, matching PinOverlayHost's own - // ResizeObserver-based reporting. - useEffect(() => { - const element = shellRef.current; - if (!element) return; - const observer = new ResizeObserver((entries) => { - const height = entries[0]?.contentRect.height; - if (height) bridgeRef.current?.resize(Math.ceil(height)); - }); - observer.observe(element); - return () => observer.disconnect(); - }, []); - - // Escape closes from anywhere in the surface, matching the legacy native - // popup's own keyPressEvent override - outside-click-close itself is - // handled natively (WebIslandHost's dismiss_on_outside_focus). - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if (event.key === "Escape") bridgeRef.current?.close(); - } - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, []); - - const term = query.trim().toLowerCase(); - const filtered = useMemo(() => { - if (!term) return state.options; - return state.options.filter( - (option) => option.label.toLowerCase().includes(term) || option.id.toLowerCase().includes(term), - ); - }, [term, state.options]); - - if (rejection) { - return ( - - ); - } - - const isModelKind = state.kind === "model"; - const showSettingsHint = isModelKind && term.length === 0 && state.options.length === 0; - const emptyMessage = - term.length > 0 - ? "No model matches this search." - : isModelKind - ? "No model catalog available yet." - : "No reasoning levels are available for this provider."; - - return ( -
-
-
-

{isModelKind ? "Model" : "Reasoning"}

-

{state.title}

-
- -
- - {isModelKind && ( - setQuery(event.target.value)} - placeholder="Search available models" - aria-label="Search available models" - autoComplete="off" - spellCheck={false} - /> - )} - - {filtered.length > 0 ? ( -
    - {filtered.map((option) => ( -
  • - -
  • - ))} -
- ) : ( -

{emptyMessage}

- )} - - {showSettingsHint && ( - - )} -
- ); -} - -export default App; diff --git a/web_ui/src/islands/composer-picker/bridge.test.ts b/web_ui/src/islands/composer-picker/bridge.test.ts deleted file mode 100644 index 9a036281..00000000 --- a/web_ui/src/islands/composer-picker/bridge.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createComposerPickerBridge } from "./bridge"; -import { initialComposerPickerState, ComposerPickerState } from "./bridgeTypes"; -import { QtWindow } from "../../lib/bridge-core/transport"; - -function stateJson(overrides: Partial = {}): string { - return JSON.stringify({ ...initialComposerPickerState, ...overrides }); -} - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - selectOption: vi.fn(), - requestSettings: vi.fn(), - resize: vi.fn(), - close: vi.fn(), - }; - - class FakeQWebChannel { - constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) { - callback({ objects: { composerPickerBridge: remote } }); - } - } - - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - - return remote; -} - -function uninstallFakeQWebChannel() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -describe("createComposerPickerBridge with no QWebChannel available", () => { - it("falls back to the mock bridge and publishes the initial state on ready()", () => { - const listener = vi.fn(); - const bridge = createComposerPickerBridge(listener); - - bridge.ready(); - - expect(listener).toHaveBeenCalledExactlyOnceWith(initialComposerPickerState); - }); - - it("intents on the mock bridge do not throw", () => { - const bridge = createComposerPickerBridge(() => {}); - expect(() => { - bridge.selectOption("gpt"); - bridge.requestSettings(); - bridge.resize(300); - bridge.close(); - bridge.dispose(); - }).not.toThrow(); - }); -}); - -describe("createComposerPickerBridge against a real QWebChannel connection", () => { - it("connects synchronously and calls remote.ready()", () => { - const remote = installFakeQWebChannel(); - try { - createComposerPickerBridge(() => {}); - expect(remote.ready).toHaveBeenCalledTimes(1); - expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("forwards options pushed through stateChanged to the listener", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - createComposerPickerBridge(listener); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push( - stateJson({ - revision: 2, - kind: "reasoning", - title: "Choose response depth", - options: [{ id: "Quick", label: "Quick", meta: "Direct", current: true, unavailable: false }], - }), - ); - - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({ kind: "reasoning", options: [expect.objectContaining({ id: "Quick" })] }), - ); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("each intent calls through to the matching remote method with its args", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createComposerPickerBridge(() => {}); - - bridge.selectOption("gpt-4"); - bridge.requestSettings(); - bridge.resize(321); - bridge.close(); - - expect(remote.selectOption).toHaveBeenCalledWith("gpt-4"); - expect(remote.requestSettings).toHaveBeenCalledTimes(1); - expect(remote.resize).toHaveBeenCalledWith(321); - expect(remote.close).toHaveBeenCalledTimes(1); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("rejects a payload with an incompatible schema version instead of forwarding it", () => { - const remote = installFakeQWebChannel(); - try { - const listener = vi.fn(); - const onRejection = vi.fn(); - createComposerPickerBridge(listener, onRejection); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - listener.mockClear(); - - push(stateJson({ minCompatibleSchemaVersion: 999 })); - - expect(listener).not.toHaveBeenCalled(); - expect(onRejection).toHaveBeenCalledWith(expect.objectContaining({ kind: "version" })); - } finally { - uninstallFakeQWebChannel(); - } - }); - - it("dispose() disconnects the stateChanged listener", () => { - const remote = installFakeQWebChannel(); - try { - const bridge = createComposerPickerBridge(() => {}); - const handler = remote.stateChanged.connect.mock.calls[0][0]; - - bridge.dispose(); - - expect(remote.stateChanged.disconnect).toHaveBeenCalledWith(handler); - } finally { - uninstallFakeQWebChannel(); - } - }); -}); diff --git a/web_ui/src/islands/composer-picker/bridge.ts b/web_ui/src/islands/composer-picker/bridge.ts deleted file mode 100644 index b04bae29..00000000 --- a/web_ui/src/islands/composer-picker/bridge.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { ComposerPickerState, initialComposerPickerState } from "./bridgeTypes"; -import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport"; -import { installTextFocusReporting } from "../../lib/bridge-core/textFocus"; -import { validateComposerPickerState } from "../../lib/bridge-core/generated/composer-picker-state"; -import { - BridgeRejection, - RejectionListener, - parseIslandState, -} from "../../lib/bridge-core/islandState"; - -export type { BridgeRejection, RejectionListener }; - -type StateListener = (state: ComposerPickerState) => void; - -interface QtSignal { - connect(listener: (value: T) => void): void; - disconnect?: (listener: (value: T) => void) => void; -} - -interface QtComposerPickerObject { - stateChanged: QtSignal; - ready: () => void; - selectOption: (id: string) => void; - requestSettings: () => void; - resize: (height: number) => void; - close: () => void; -} - -export interface ComposerPickerBridge { - ready(): void; - selectOption(id: string): void; - requestSettings(): void; - resize(height: number): void; - close(): void; - dispose(): void; -} - -function parseState(payload: string) { - return parseIslandState(payload, validateComposerPickerState); -} - -class MockComposerPickerBridge implements ComposerPickerBridge { - private readonly state: ComposerPickerState = structuredClone(initialComposerPickerState); - private readonly listener: StateListener; - - constructor(listener: StateListener) { - this.listener = listener; - } - - ready(): void { - this.listener(this.state); - } - - selectOption(): void {} - requestSettings(): void {} - resize(): void {} - close(): void {} - dispose(): void {} -} - -export function createComposerPickerBridge( - listener: StateListener, - onRejection?: RejectionListener, -): ComposerPickerBridge { - const fallback = new MockComposerPickerBridge(listener); - - if (!isQWebChannelAvailable()) { - return fallback; - } - - let remote: QtComposerPickerObject | null = null; - let connected = false; - const stateListener = (payload: string) => { - const outcome = parseState(payload); - if (outcome.ok) { - listener(outcome.state); - onRejection?.(null); - return; - } - console.error( - `[composer-picker bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`, - outcome.rejection.details, - ); - onRejection?.(outcome.rejection); - }; - - connectQWebChannel((objects) => { - remote = objects.composerPickerBridge as QtComposerPickerObject; - remote.stateChanged.connect(stateListener); - connected = true; - remote.ready(); - // Real search input (model kind) means this island must participate in - // the keyboard-arbitration protocol, like command-palette/pin-overlay. - installTextFocusReporting(objects); - }); - - const call = ( - method: K, - ...args: QtComposerPickerObject[K] extends (...values: infer A) => void ? A : never - ) => { - if (connected && remote && typeof remote[method] === "function") { - (remote[method] as (...values: unknown[]) => void)(...args); - } - }; - - return { - ready: () => call("ready"), - selectOption: (id) => call("selectOption", id), - requestSettings: () => call("requestSettings"), - resize: (height) => call("resize", height), - close: () => call("close"), - dispose: () => { - remote?.stateChanged.disconnect?.(stateListener); - remote = null; - }, - }; -} diff --git a/web_ui/src/islands/composer-picker/bridgeTypes.ts b/web_ui/src/islands/composer-picker/bridgeTypes.ts deleted file mode 100644 index edb13ca6..00000000 --- a/web_ui/src/islands/composer-picker/bridgeTypes.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * The composer-picker island's state contract. - * - * See composer/bridgeTypes.ts for the fuller rationale (re-export from the - * generated file, not a hand mirror). Filtering is pure client-side - * (matching every prior list-bearing island's own precedent) - Python - * always sends the full option list. `openToken` bumps once per open() call - * so App.tsx can reset its local search query on a genuinely fresh open, - * distinct from any other republish. - */ -export type { ComposerPickerState, ComposerPickerOption } from "../../lib/bridge-core/generated/composer-picker-state"; - -import type { ComposerPickerState } from "../../lib/bridge-core/generated/composer-picker-state"; - -export const initialComposerPickerState: ComposerPickerState = { - schemaVersion: 1, - minCompatibleSchemaVersion: 1, - revision: 0, - kind: "model", - title: "Choose a model", - options: [], - openToken: 0, -}; diff --git a/web_ui/src/islands/composer-picker/index.html b/web_ui/src/islands/composer-picker/index.html deleted file mode 100644 index 2adb9baf..00000000 --- a/web_ui/src/islands/composer-picker/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Graphlink Model Picker - - -
- - - diff --git a/web_ui/src/islands/composer-picker/main.tsx b/web_ui/src/islands/composer-picker/main.tsx deleted file mode 100644 index bb58ae83..00000000 --- a/web_ui/src/islands/composer-picker/main.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import App from "./App"; -import "../../lib/ui/base.css"; -import "./styles.css"; - -// See composer/main.tsx for the full rationale: styles.css resolves colors -// through var(--gl-*), which only exist in the real app via the Python -// host's build-time :root injection - npm run dev has no Python in the -// loop, so this supplies the same variables for browser preview. -if (import.meta.env.DEV) { - await import("../../lib/tokens/gl-vars-dev.css"); -} - -createRoot(document.getElementById("root")!).render( - - - , -); diff --git a/web_ui/src/islands/composer-picker/styles.css b/web_ui/src/islands/composer-picker/styles.css deleted file mode 100644 index c7803a86..00000000 --- a/web_ui/src/islands/composer-picker/styles.css +++ /dev/null @@ -1,216 +0,0 @@ -/* Colors reuse EXISTING app-wide --gl-* tokens, same convention every - island since token-counter follows. Height is content-driven (a - ResizeObserver reports it to Python, which bounds it to - [COMPOSER_PICKER_MIN_HEIGHT, COMPOSER_PICKER_MAX_HEIGHT] - see App.tsx). */ - -html, -body, -#root { - margin: 0; - padding: 0; - background: transparent; -} - -.composer-picker-shell { - box-sizing: border-box; - width: 400px; - display: flex; - flex-direction: column; - gap: 12px; - padding: 16px; - font-family: var(--gl-font-family, system-ui, sans-serif); - color: var(--gl-neutral-button-icon); - background-color: var(--gl-graph-node-panel-fill); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 14px; -} - -.composer-picker-header { - display: flex; - align-items: flex-start; - gap: 12px; -} - -.composer-picker-heading { - flex: 1; - min-width: 0; -} - -.composer-picker-eyebrow { - margin: 0; - font-size: 9px; - font-weight: 700; - letter-spacing: 1px; - text-transform: uppercase; - color: var(--gl-neutral-button-muted-icon); -} - -.composer-picker-title { - margin: 4px 0 0 0; - font-size: 13px; - font-weight: 650; -} - -.composer-picker-close-btn { - flex-shrink: 0; - width: 25px; - height: 25px; - padding: 0; - font-size: 17px; - line-height: 1; - font-family: inherit; - color: var(--gl-neutral-button-muted-icon); - background-color: transparent; - border: 0; - border-radius: 7px; - cursor: pointer; -} - -.composer-picker-close-btn:hover { - color: var(--gl-neutral-button-icon); - background-color: var(--gl-neutral-button-hover); -} - -.composer-picker-search-input { - box-sizing: border-box; - width: 100%; - padding: 8px 12px; - min-height: 20px; - font-size: 13px; - font-family: inherit; - color: var(--gl-neutral-button-icon); - background-color: var(--gl-graph-node-body-start); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 9px; - outline: none; -} - -.composer-picker-search-input:focus { - border-color: var(--gl-palette-selection); -} - -.composer-picker-list { - margin: 0; - padding: 6px; - list-style: none; - max-height: 300px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 4px; - background-color: var(--gl-graph-node-body-start); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 10px; -} - -.composer-picker-empty { - margin: 0; - padding: 12px 8px; - font-size: 10px; - color: var(--gl-neutral-button-muted-icon); - text-align: center; -} - -.composer-picker-row { - box-sizing: border-box; - width: 100%; - display: flex; - align-items: center; - gap: 14px; - padding: 8px 12px; - min-height: 50px; - text-align: left; - background: transparent; - border: none; - border-radius: 7px; - color: inherit; - font: inherit; - cursor: pointer; -} - -.composer-picker-row:hover:not(:disabled) { - background-color: var(--gl-neutral-button-hover); -} - -.composer-picker-row.unavailable { - opacity: 0.5; - cursor: default; -} - -.composer-picker-row-copy { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.composer-picker-row-label { - font-size: 11px; - font-weight: 650; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.composer-picker-row-meta { - font-size: 9px; - color: var(--gl-neutral-button-muted-icon); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.composer-picker-current-badge { - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - width: 60px; - height: 22px; - font-size: 9px; - font-weight: 700; - color: var(--gl-neutral-button-icon); - background-color: var(--gl-neutral-button-hover); - border: 1px solid var(--gl-neutral-button-border); - border-radius: 6px; -} - -.composer-picker-settings-btn { - align-self: flex-start; - padding: 0 8px; - font-size: 10px; - font-weight: 650; - font-family: inherit; - color: var(--gl-neutral-button-muted-icon); - background-color: transparent; - border: 0; - cursor: pointer; -} - -.composer-picker-settings-btn:hover { - color: var(--gl-neutral-button-icon); -} - -.bridge-error-title { - font-weight: 600; - margin-bottom: 4px; -} - -.bridge-error-reason { - margin: 0 0 4px 0; - font-size: 13px; -} - -.bridge-error-details { - margin: 0; - padding-left: 16px; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} - -.bridge-error-hint { - margin: 8px 0 0 0; - font-size: 12px; - color: var(--gl-neutral-button-muted-icon); -} diff --git a/web_ui/src/islands/composer/ComposerApp.reject-recover.test.tsx b/web_ui/src/islands/composer/ComposerApp.reject-recover.test.tsx deleted file mode 100644 index 0a5fac7c..00000000 --- a/web_ui/src/islands/composer/ComposerApp.reject-recover.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; -import ComposerApp from "./ComposerApp"; -import { initialComposerState } from "./bridgeTypes"; -import type { QtWindow } from "../../lib/bridge-core/transport"; - -/** - * Regression coverage for a real bug adversarial review found: the - * ResizeObserver-attaching effect had a `[]` dependency array, so it ran - * exactly once for the component's whole lifetime. Before BridgeErrorState - * existed that was safe -
was the only thing - * ComposerApp ever rendered. Once a rejected payload could unmount it - * (replaced by BridgeErrorState) and a later valid payload could remount a - * BRAND NEW
node, the observer kept watching the original, now- - * detached node forever: resize() would never fire again for the real one, - * silently freezing the host's negotiated height. - * - * Fixed by keying the effect on `rejection` instead of `[]`. These tests - * mount the real ComposerApp against a real (faked) QWebChannel - not the - * jsdom mock-bridge fallback other tests use - because the bug is entirely - * about what happens when real payloads flow through the real bridge and - * cause a real mount/unmount/remount cycle. - */ - -function installFakeQWebChannel() { - const remote = { - stateChanged: { connect: vi.fn(), disconnect: vi.fn() }, - ready: vi.fn(), - updateDraft: vi.fn(), - send: vi.fn(), - cancel: vi.fn(), - reviewContext: vi.fn(), - requestAttachment: vi.fn(), - stageTextAttachment: vi.fn(), - removeContextItem: vi.fn(), - selectModel: vi.fn(), - setReasoningLevel: vi.fn(), - openSettings: vi.fn(), - openModelSelector: vi.fn(), - openReasoningSelector: vi.fn(), - resize: vi.fn(), - }; - class FakeQWebChannel { - constructor(_t: unknown, cb: (channel: { objects: Record }) => void) { - cb({ objects: { composerBridge: remote } }); - } - } - const qtWindow = window as unknown as QtWindow; - qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"]; - qtWindow.qt = { webChannelTransport: {} }; - return remote; -} - -function uninstall() { - const qtWindow = window as unknown as QtWindow; - delete qtWindow.QWebChannel; - delete qtWindow.qt; -} - -const validPayload = () => JSON.stringify({ ...initialComposerState, minCompatibleSchemaVersion: 1 }); - -describe("ComposerApp reject -> recover DOM/resize lifecycle", () => { - afterEach(() => { - cleanup(); - uninstall(); - vi.restoreAllMocks(); - }); - - it("mounts a NEW shell DOM node on recovery, not the original", async () => { - const remote = installFakeQWebChannel(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push(validPayload()); - const shellBefore = document.querySelector(".composer-shell:not(.bridge-error)"); - expect(shellBefore).not.toBeNull(); - - push("not json"); // rejection -> unmounts
- await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); - expect(document.querySelector(".composer-shell:not(.bridge-error)")).toBeNull(); - - push(validPayload()); // recovery -> remounts a NEW
- await waitFor(() => - expect(document.querySelector(".composer-shell:not(.bridge-error)")).not.toBeNull(), - ); - const shellAfter = document.querySelector(".composer-shell:not(.bridge-error)"); - - expect(shellAfter).not.toBe(shellBefore); - }); - - it("calls resize() again for the new shell after a reject/recover cycle - the actual regression", async () => { - const remote = installFakeQWebChannel(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - push(validPayload()); - await waitFor(() => expect(remote.resize.mock.calls.length).toBeGreaterThan(0)); - const callsBeforeReject = remote.resize.mock.calls.length; - - push("not json"); - await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); - - push(validPayload()); - await waitFor(() => - expect(document.querySelector(".composer-shell:not(.bridge-error)")).not.toBeNull(), - ); - - // The bug: with a `[]`-keyed effect this next assertion fails, because the - // ResizeObserver effect never re-ran for the new shell and reportHeight() - // was never called again. - await waitFor(() => expect(remote.resize.mock.calls.length).toBeGreaterThan(callsBeforeReject)); - }); - - it("does not throw when a rejection arrives immediately, before any successful mount", async () => { - const remote = installFakeQWebChannel(); - render(); - const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void; - - expect(() => push("not json")).not.toThrow(); - await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument()); - }); -}); diff --git a/web_ui/src/islands/composer/ComposerApp.test.tsx b/web_ui/src/islands/composer/ComposerApp.test.tsx deleted file mode 100644 index 481ea558..00000000 --- a/web_ui/src/islands/composer/ComposerApp.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import ComposerApp from "./ComposerApp"; - -// jsdom has no window.QWebChannel, so createComposerBridge() (in bridge.ts) -// falls through to MockComposerBridge automatically - this is the "vitest -// green with a mock-bridge composer test" Phase 1 exit criterion, exercised -// through ComposerApp's real public behavior rather than reaching into -// MockComposerBridge internals directly. - -describe("ComposerApp against the mock bridge", () => { - it("renders the message input and starts with the send button disabled", () => { - render(); - expect(screen.getByLabelText("Message composer")).toBeInTheDocument(); - expect(screen.getByLabelText("Send message")).toBeDisabled(); - }); - - it("enables send once the draft has non-whitespace text", async () => { - const user = userEvent.setup(); - render(); - - const input = screen.getByLabelText("Message composer"); - await user.type(input, "Hello there"); - - expect(screen.getByLabelText("Send message")).toBeEnabled(); - }); - - it("keeps send disabled for whitespace-only text", async () => { - const user = userEvent.setup(); - render(); - - const input = screen.getByLabelText("Message composer"); - await user.type(input, " "); - - expect(screen.getByLabelText("Send message")).toBeDisabled(); - }); - - it("sending transitions into the mock bridge's preview-generating state", async () => { - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText("Message composer"), "Hello there"); - await user.click(screen.getByLabelText("Send message")); - - expect(await screen.findByText("Preview mode — waiting for the desktop bridge")).toBeInTheDocument(); - expect(screen.getByLabelText("Cancel response")).toBeInTheDocument(); - }); - - it("Enter sends when sendMode is enter_to_send (the mock bridge's default)", async () => { - const user = userEvent.setup(); - render(); - - const input = screen.getByLabelText("Message composer"); - await user.type(input, "Hello there{Enter}"); - - expect(await screen.findByText("Preview mode — waiting for the desktop bridge")).toBeInTheDocument(); - }); - - it("large pastes are staged as a context attachment instead of inserted as text", async () => { - const user = userEvent.setup(); - render(); - - const input = screen.getByLabelText("Message composer") as HTMLTextAreaElement; - const bigText = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n"); - - await user.click(input); - await user.paste(bigText); - - expect(input.value).toBe(""); - expect(await screen.findByLabelText(/Review 1 attached item/)).toBeInTheDocument(); - }); - - it("attach button is enabled per the mock bridge's initial capabilities", () => { - render(); - expect(screen.getByLabelText("Attach context")).toBeEnabled(); - }); -}); diff --git a/web_ui/src/islands/composer/ComposerApp.theme.test.tsx b/web_ui/src/islands/composer/ComposerApp.theme.test.tsx deleted file mode 100644 index 4242632e..00000000 --- a/web_ui/src/islands/composer/ComposerApp.theme.test.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render } from "@testing-library/react"; - -// The default browser-preview mock (bridgeTypes.ts's initialComposerState) -// deliberately carries an empty theme.cssVariables - real values normally -// come from the Python bridge, which jsdom has no equivalent of. This proves -// ComposerApp's theme-application effect actually runs end-to-end on real -// bridge-shaped state, not just that the extracted applyThemeCssVariables() -// function works in isolation (see theme.test.ts for that). -vi.mock("./bridgeTypes", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - initialComposerState: { - ...actual.initialComposerState, - theme: { - ...actual.initialComposerState.theme, - cssVariables: { - "--gl-composer-shell-background": "#1f1f1f", - "--gl-composer-shell-border": "rgba(150, 150, 150, 0.34)", - }, - }, - }, - }; -}); - -const { default: ComposerApp } = await import("./ComposerApp"); - -describe("ComposerApp theme wiring", () => { - afterEach(() => { - cleanup(); - document.documentElement.removeAttribute("style"); - }); - - it("applies the mock bridge's initial theme.cssVariables to documentElement on mount", () => { - render(); - - expect( - document.documentElement.style.getPropertyValue("--gl-composer-shell-background"), - ).toBe("#1f1f1f"); - expect(document.documentElement.style.getPropertyValue("--gl-composer-shell-border")).toBe( - "rgba(150, 150, 150, 0.34)", - ); - }); -}); diff --git a/web_ui/src/islands/composer/ComposerApp.tsx b/web_ui/src/islands/composer/ComposerApp.tsx deleted file mode 100644 index f3c84fac..00000000 --- a/web_ui/src/islands/composer/ComposerApp.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { ComposerState, initialComposerState } from "./bridgeTypes"; -import { BridgeRejection, ComposerBridge, createComposerBridge } from "./bridge"; -import { BridgeErrorState } from "../../lib/ui/BridgeErrorState"; -import { isBusy, requestLabel } from "./state"; -import { useAppliedThemeCssVariables } from "./theme"; - -const LARGE_PASTE_CHAR_THRESHOLD = 1400; -const LARGE_PASTE_LINE_THRESHOLD = 24; - -function Icon({ name }: { name: "attach" | "send" | "stop" | "chevron" }) { - const paths: Record = { - attach: "M12 5.5 6.4 11.1a3.6 3.6 0 0 0 5.1 5.1l6-6a2.5 2.5 0 0 0-3.5-3.5l-6.1 6.1a1.35 1.35 0 0 0 1.9 1.9l5.5-5.5", - send: "M3.5 4.6 20.5 12 3.5 19.4l2.2-6.2L15 12 5.7 10.8 3.5 4.6Z", - stop: "M6.5 6.5h11v11h-11z", - chevron: "m7 10 5 5 5-5", - }; - return ( - - ); -} - -function ComposerApp() { - const [state, setState] = useState(initialComposerState); - const [rejection, setRejection] = useState(null); - const bridgeRef = useRef(null); - const shellRef = useRef(null); - const inputRef = useRef(null); - const isRequestBusy = isBusy(state); - const attachmentCount = state.context.items.length; - - useEffect(() => { - const bridge = createComposerBridge(setState, setRejection); - bridgeRef.current = bridge; - bridge.ready(); - return () => { - bridge.dispose(); - bridgeRef.current = null; - }; - }, []); - - // Applies the active theme's --gl-* custom properties to the document root - // whenever they genuinely change (content, not object reference - see - // theme.ts), so an in-app theme change re-styles composer without a - // reload. First paint already has a value for every property (the host - // injects a build-time :root block before this ever runs) - this is what - // makes a LIVE theme switch visible; it does not do the initial styling. - useAppliedThemeCssVariables(document.documentElement, state.theme.cssVariables); - - // Keyed on `rejection`, not `[]`:
is only rendered - // when there is no rejection (see the early return below), so a reject then - // recover cycle unmounts and later remounts a NEW
DOM node. A `[]` - // dependency array would run this effect exactly once for the component's - // whole lifetime, leaving the ResizeObserver watching the original, - // detached node forever - resize() would never fire again for the new one, - // silently freezing the host's negotiated height while composer's real - // content keeps growing. Keying on `rejection` makes React tear down and - // re-create the observer across every mount/unmount boundary: the cleanup - // fires (disconnecting the old observer) as `rejection` transitions to - // truthy, and shellRef.current is correctly null at that point since React - // nulls refs on unmount before running effects - so the guard below is not - // dead code, it is what makes the rejected render a safe no-op here. - useEffect(() => { - const shell = shellRef.current; - if (!shell) return; - - const reportHeight = () => { - bridgeRef.current?.resize(Math.ceil(shell.getBoundingClientRect().height)); - }; - const observer = - typeof ResizeObserver === "undefined" ? null : new ResizeObserver(reportHeight); - observer?.observe(shell); - reportHeight(); - return () => observer?.disconnect(); - }, [rejection]); - - useLayoutEffect(() => { - const input = inputRef.current; - if (!input) return; - input.style.height = "auto"; - input.style.height = `${Math.max(42, Math.min(160, input.scrollHeight))}px`; - }, [state.draft.text]); - - const canSend = state.request.canSend && !isRequestBusy; - const routeDetail = useMemo(() => { - if (state.route.modelId) return state.route.label + " \u00b7 " + state.route.modelId; - return state.route.label; - }, [state.route.label, state.route.modelId, state.route.modelLabel]); - const modelDisplayLabel = state.route.modelLabel || state.route.modelId || "Select a model"; - - function submit() { - if (isRequestBusy) { - bridgeRef.current?.cancel(state.request.id || undefined); - } else if (canSend) { - bridgeRef.current?.send(); - } - } - - function onKeyDown(event: React.KeyboardEvent) { - const sendOnEnter = - state.draft.sendMode === "enter_to_send" && event.key === "Enter" && !event.shiftKey; - const sendOnCtrlEnter = - state.draft.sendMode === "ctrl_enter_to_send" && - event.key === "Enter" && - (event.ctrlKey || event.metaKey); - if (sendOnEnter || sendOnCtrlEnter) { - event.preventDefault(); - submit(); - } - } - - function onPaste(event: React.ClipboardEvent) { - if (isRequestBusy || event.clipboardData.files.length > 0) return; - - const pastedText = event.clipboardData.getData("text/plain"); - const isLargePaste = - pastedText.length >= LARGE_PASTE_CHAR_THRESHOLD || - pastedText.split("\n").length >= LARGE_PASTE_LINE_THRESHOLD; - if (!pastedText.trim() || !isLargePaste) return; - - event.preventDefault(); - bridgeRef.current?.stageTextAttachment(pastedText); - } - - // Replaces the composer entirely rather than annotating it: after a rejected - // payload, anything still rendered is stale, and an input box that still - // looks usable would invite typing into state the desktop side no longer - // shares. Placed after the hooks above so hook order stays unconditional. - if (rejection) { - return ; - } - - return ( -
-
-