- {visibleNotice} -
- )} - --
- {filtered.length === 0 &&
- No matching commands } - {filtered.map((command, index) => ( -
- setSelectedIndex(index)} - onClick={() => execute(command.id)} - > - {command.name} - - ))} -
Version {state.appVersion}
- - - -Project
- - -Developed By
-{state.developerName}
- - -- {visibleNotice} -
- )} - -{row.title}
-- Updated {row.updatedLabel} · Created {row.createdLabel} -
-- {statusText} -
-- {visibleNotice} -
- )} - -Context
-Included context
-Estimated context · {state.totalTokens.toLocaleString()} tokens
-{isModelKind ? "Model" : "Reasoning"}
-{state.title}
-{emptyMessage}
- )} - - {showSettingsHint && ( - - )} - with a hljs/language
- // class and splits tokens into their own s - textContent joins them
- // back into the original source line.
- const codeBlock = document.querySelector("code.hljs.language-python");
- expect(codeBlock).not.toBeNull();
- expect(codeBlock?.textContent).toBe("print('hi')\n");
- expect(document.querySelector(".hljs-built_in")?.textContent).toBe("print");
- expect(document.querySelector(".hljs-string")?.textContent).toBe("'hi'");
- expect(screen.getByRole("table")).toBeInTheDocument();
- expect(screen.getByText("one")).toBeInTheDocument();
- expect(screen.getByText("two")).toBeInTheDocument();
- });
-
- 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("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({ ...initialDocumentViewerState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("Document View is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/document-viewer/App.tsx b/web_ui/src/islands/document-viewer/App.tsx
deleted file mode 100644
index 186ceddd..00000000
--- a/web_ui/src/islands/document-viewer/App.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import rehypeHighlight from "rehype-highlight";
-import { DocumentViewerBridge, BridgeRejection, createDocumentViewerBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-function App() {
- const [rejection, setRejection] = useState(null);
- const [content, setContent] = useState("");
- const bridgeRef = useRef(null);
-
- useEffect(() => {
- const bridge = createDocumentViewerBridge((state) => setContent(state.content), setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- return (
-
-
- Document View
-
-
-
- {content.trim().length > 0 ? (
-
-
- {content}
-
-
- ) : (
- No document content is available yet.
- )}
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/document-viewer/bridge.test.ts b/web_ui/src/islands/document-viewer/bridge.test.ts
deleted file mode 100644
index 5848cd18..00000000
--- a/web_ui/src/islands/document-viewer/bridge.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createDocumentViewerBridge } from "./bridge";
-import { initialDocumentViewerState, DocumentViewerState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialDocumentViewerState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- close: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { documentViewerBridge: 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("createDocumentViewerBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial (empty-content) state on ready()", () => {
- const listener = vi.fn();
- const bridge = createDocumentViewerBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialDocumentViewerState);
- });
-
- it("close() on the mock bridge does not throw", () => {
- const bridge = createDocumentViewerBridge(() => {});
- expect(() => bridge.close()).not.toThrow();
- });
-
- it("dispose() on the mock bridge does not throw", () => {
- const bridge = createDocumentViewerBridge(() => {});
- expect(() => bridge.dispose()).not.toThrow();
- });
-});
-
-describe("createDocumentViewerBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createDocumentViewerBridge(() => {});
- expect(remote.ready).toHaveBeenCalledTimes(1);
- expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("forwards content pushed through stateChanged to the listener", () => {
- const remote = installFakeQWebChannel();
- try {
- const listener = vi.fn();
- createDocumentViewerBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ revision: 3, content: "## Code\n\n```python\nprint(1)\n```" }));
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ revision: 3, content: "## Code\n\n```python\nprint(1)\n```" }),
- );
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("close calls through to the remote method", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createDocumentViewerBridge(() => {});
-
- bridge.close();
-
- 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();
- createDocumentViewerBridge(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 = createDocumentViewerBridge(() => {});
- 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/document-viewer/bridge.ts b/web_ui/src/islands/document-viewer/bridge.ts
deleted file mode 100644
index a036a59a..00000000
--- a/web_ui/src/islands/document-viewer/bridge.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { DocumentViewerState, initialDocumentViewerState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateDocumentViewerState } from "../../lib/bridge-core/generated/document-viewer-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: DocumentViewerState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtDocumentViewerObject {
- stateChanged: QtSignal;
- ready: () => void;
- close: () => void;
-}
-
-export interface DocumentViewerBridge {
- ready(): void;
- close(): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validateDocumentViewerState);
-}
-
-class MockDocumentViewerBridge implements DocumentViewerBridge {
- private readonly state: DocumentViewerState = structuredClone(initialDocumentViewerState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- close(): void {
- // No real embedded host to hide in the mock/test environment.
- }
-
- dispose(): void {}
-}
-
-export function createDocumentViewerBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): DocumentViewerBridge {
- const fallback = new MockDocumentViewerBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtDocumentViewerObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[document-viewer bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.documentViewerBridge as QtDocumentViewerObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- const call = (
- method: K,
- ...args: QtDocumentViewerObject[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"),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/document-viewer/bridgeTypes.ts b/web_ui/src/islands/document-viewer/bridgeTypes.ts
deleted file mode 100644
index 812ac89d..00000000
--- a/web_ui/src/islands/document-viewer/bridgeTypes.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * The document-viewer island's state contract.
- *
- * See composer/bridgeTypes.ts for the fuller rationale (re-export from the
- * generated file, not a hand mirror). Unlike help/bridgeTypes.ts, `content`
- * IS a real Python-side field here: the markdown text
- * _extract_document_view_content() (graphlink_window.py) produced for
- * whichever node the user last opened.
- */
-export type { DocumentViewerState } from "../../lib/bridge-core/generated/document-viewer-state";
-
-import type { DocumentViewerState } from "../../lib/bridge-core/generated/document-viewer-state";
-
-export const initialDocumentViewerState: DocumentViewerState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- content: "",
-};
diff --git a/web_ui/src/islands/document-viewer/index.html b/web_ui/src/islands/document-viewer/index.html
deleted file mode 100644
index f5fd8e34..00000000
--- a/web_ui/src/islands/document-viewer/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Document View
-
-
-
-
-
-
diff --git a/web_ui/src/islands/document-viewer/main.tsx b/web_ui/src/islands/document-viewer/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/document-viewer/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/document-viewer/styles.css b/web_ui/src/islands/document-viewer/styles.css
deleted file mode 100644
index 799d658e..00000000
--- a/web_ui/src/islands/document-viewer/styles.css
+++ /dev/null
@@ -1,240 +0,0 @@
-/* Colors reuse EXISTING app-wide --gl-* tokens, same convention every
- island since token-counter follows. Flush side panel (border-right
- only, no rounded corners - see graphlink_document_viewer_web.py's
- corner_radius=0), matching the legacy DocumentViewerPanel's own
- background-color/border-right-only chrome, not a floating card like
- about/help. Syntax-highlight colors (.hljs-*, from rehype-highlight)
- deliberately reuse only 3 EXISTING tokens rather than inventing a
- multi-color syntax palette that doesn't exist anywhere else in the app
- today - see the master plan's Phase 4 increment 3 decision note. */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- height: 100%;
- background: transparent;
-}
-
-.document-viewer-shell {
- box-sizing: border-box;
- width: 100%;
- height: 100vh;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 10px;
- 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-right: 1px solid var(--gl-neutral-button-border);
- overflow: hidden;
-}
-
-.document-viewer-header {
- flex-shrink: 0;
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.document-viewer-title {
- flex: 1;
- min-width: 0;
- margin: 0;
- font-size: 14px;
- font-weight: 700;
-}
-
-.document-viewer-close-btn {
- flex-shrink: 0;
- padding: 6px 14px;
- font-size: 12px;
- 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;
- cursor: pointer;
-}
-
-.document-viewer-close-btn:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.document-viewer-scroll-area {
- flex: 1;
- min-height: 0;
- overflow-y: auto;
- box-sizing: border-box;
- padding: 8px 12px;
- font-size: 13px;
- line-height: 1.5;
- background-color: var(--gl-graph-node-body-start);
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 6px;
-}
-
-.document-viewer-empty {
- margin: 0;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-/* --- Markdown body ------------------------------------------------- */
-
-.document-viewer-markdown h1,
-.document-viewer-markdown h2,
-.document-viewer-markdown h3,
-.document-viewer-markdown h4,
-.document-viewer-markdown h5,
-.document-viewer-markdown h6 {
- margin: 0 0 8px 0;
- font-weight: 700;
- line-height: 1.3;
-}
-
-.document-viewer-markdown h1 { font-size: 18px; }
-.document-viewer-markdown h2 { font-size: 16px; }
-.document-viewer-markdown h3 { font-size: 14px; }
-.document-viewer-markdown h4,
-.document-viewer-markdown h5,
-.document-viewer-markdown h6 { font-size: 13px; }
-
-.document-viewer-markdown p {
- margin: 0 0 10px 0;
-}
-
-.document-viewer-markdown ul,
-.document-viewer-markdown ol {
- margin: 0 0 10px 0;
- padding-left: 20px;
-}
-
-.document-viewer-markdown li {
- margin: 0 0 4px 0;
-}
-
-.document-viewer-markdown a {
- color: var(--gl-palette-selection);
-}
-
-.document-viewer-markdown blockquote {
- margin: 0 0 10px 0;
- padding-left: 10px;
- border-left: 3px solid var(--gl-neutral-button-border);
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.document-viewer-markdown hr {
- margin: 16px 0;
- border: 0;
- border-top: 1px solid var(--gl-neutral-button-border);
-}
-
-.document-viewer-markdown code {
- font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
- font-size: 0.9em;
- background-color: var(--gl-neutral-button-background);
- border-radius: 3px;
- padding: 2px 4px;
-}
-
-.document-viewer-markdown pre {
- margin: 0 0 10px 0;
- padding: 10px;
- overflow-x: auto;
- background-color: var(--gl-neutral-button-background);
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 6px;
-}
-
-.document-viewer-markdown pre code {
- background-color: transparent;
- border-radius: 0;
- padding: 0;
- font-size: 0.85em;
-}
-
-.document-viewer-markdown table {
- width: 100%;
- margin: 0 0 10px 0;
- border-collapse: collapse;
-}
-
-.document-viewer-markdown th,
-.document-viewer-markdown td {
- padding: 6px 8px;
- text-align: left;
- border: 1px solid var(--gl-neutral-button-border);
-}
-
-.document-viewer-markdown th {
- font-weight: 700;
- background-color: var(--gl-neutral-button-background);
-}
-
-/* --- Syntax highlighting (rehype-highlight's .hljs-* classes) ------- */
-/* Reuses 3 EXISTING tokens (default text / muted / accent) rather than
- inventing a multi-color syntax palette - the app's actual --gl-*
- vocabulary today is grayscale-only. */
-
-.document-viewer-markdown .hljs {
- color: var(--gl-neutral-button-icon);
- background: transparent;
-}
-
-.document-viewer-markdown .hljs-comment,
-.document-viewer-markdown .hljs-quote {
- color: var(--gl-neutral-button-muted-icon);
- font-style: italic;
-}
-
-.document-viewer-markdown .hljs-keyword,
-.document-viewer-markdown .hljs-selector-tag,
-.document-viewer-markdown .hljs-literal,
-.document-viewer-markdown .hljs-string,
-.document-viewer-markdown .hljs-number,
-.document-viewer-markdown .hljs-title,
-.document-viewer-markdown .hljs-section,
-.document-viewer-markdown .hljs-name,
-.document-viewer-markdown .hljs-type,
-.document-viewer-markdown .hljs-attribute,
-.document-viewer-markdown .hljs-symbol,
-.document-viewer-markdown .hljs-built_in,
-.document-viewer-markdown .hljs-variable,
-.document-viewer-markdown .hljs-template-tag,
-.document-viewer-markdown .hljs-template-variable {
- color: var(--gl-palette-selection);
-}
-
-.document-viewer-markdown .hljs-emphasis {
- font-style: italic;
-}
-
-.document-viewer-markdown .hljs-strong {
- font-weight: 700;
-}
-
-.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/drag-speed/App.test.tsx b/web_ui/src/islands/drag-speed/App.test.tsx
deleted file mode 100644
index 8105a650..00000000
--- a/web_ui/src/islands/drag-speed/App.test.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import App from "./App";
-import { initialDragSpeedState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- setDragFactor: vi.fn(),
- resize: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { dragSpeedBridge: 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({ ...initialDragSpeedState, revision: 1, ...overrides }));
-}
-
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- it("renders every preset from state, highlighting the default 100% value", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote);
-
- expect(await screen.findByText("100%")).toHaveClass("active");
- expect(screen.getByText("25%")).not.toHaveClass("active");
- });
-
- it("clicking a preset calls setDragFactor with the preset divided by 100", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByText("50%");
-
- await user.click(screen.getByText("50%"));
-
- expect(remote.setDragFactor).toHaveBeenCalledWith(0.5);
- });
-
- it("moving the slider calls setDragFactor with the slider value divided by 100", async () => {
- const remote = installFakeQWebChannel();
- render( );
- push(remote, { percentMin: 10, percentMax: 100 });
- const slider = await screen.findByLabelText("Drag speed");
-
- fireEvent.change(slider, { target: { value: "40" } });
-
- expect(remote.setDragFactor).toHaveBeenCalledWith(0.4);
- });
-
- 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({ ...initialDragSpeedState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("The drag speed panel is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/drag-speed/App.tsx b/web_ui/src/islands/drag-speed/App.tsx
deleted file mode 100644
index 092ecb44..00000000
--- a/web_ui/src/islands/drag-speed/App.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { DragSpeedState, initialDragSpeedState } from "./bridgeTypes";
-import { BridgeRejection, DragSpeedBridge, createDragSpeedBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-const DEFAULT_PERCENT = 100;
-
-function App() {
- const [state, setState] = useState(initialDragSpeedState);
- const [rejection, setRejection] = useState(null);
- // Fire-and-forget, matching the legacy control_widget exactly - it never
- // read a live drag factor back either (its slider always started at a
- // hardcoded 100%).
- const [percent, setPercent] = useState(DEFAULT_PERCENT);
- const bridgeRef = useRef(null);
- const shellRef = useRef(null);
-
- useEffect(() => {
- const bridge = createDragSpeedBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- 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();
- }, []);
-
- function applyPercent(value: number) {
- setPercent(value);
- bridgeRef.current?.setDragFactor(value / 100);
- }
-
- if (rejection) {
- return (
-
- );
- }
-
- return (
-
-
- Drag
- applyPercent(Number(event.target.value))}
- />
-
-
-
- {state.percentPresets.map((preset) => (
-
- ))}
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/drag-speed/bridge.test.ts b/web_ui/src/islands/drag-speed/bridge.test.ts
deleted file mode 100644
index 9de6445a..00000000
--- a/web_ui/src/islands/drag-speed/bridge.test.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createDragSpeedBridge } from "./bridge";
-import { initialDragSpeedState, DragSpeedState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialDragSpeedState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- setDragFactor: vi.fn(),
- resize: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { dragSpeedBridge: 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("createDragSpeedBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createDragSpeedBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialDragSpeedState);
- });
-
- it("intents on the mock bridge do not throw", () => {
- const bridge = createDragSpeedBridge(() => {});
- expect(() => {
- bridge.setDragFactor(0.5);
- bridge.resize(90);
- bridge.dispose();
- }).not.toThrow();
- });
-});
-
-describe("createDragSpeedBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createDragSpeedBridge(() => {});
- expect(remote.ready).toHaveBeenCalledTimes(1);
- expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("forwards a real published state to the listener", () => {
- const remote = installFakeQWebChannel();
- try {
- const listener = vi.fn();
- createDragSpeedBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ revision: 2, percentPresets: [50, 100] }));
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ percentPresets: [50, 100] }));
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("each intent calls through to the matching remote method with its args", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createDragSpeedBridge(() => {});
-
- bridge.setDragFactor(0.75);
- bridge.resize(100);
-
- expect(remote.setDragFactor).toHaveBeenCalledWith(0.75);
- expect(remote.resize).toHaveBeenCalledWith(100);
- } 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();
- createDragSpeedBridge(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 = createDragSpeedBridge(() => {});
- 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/drag-speed/bridge.ts b/web_ui/src/islands/drag-speed/bridge.ts
deleted file mode 100644
index cc89ce9b..00000000
--- a/web_ui/src/islands/drag-speed/bridge.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { DragSpeedState, initialDragSpeedState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateDragSpeedState } from "../../lib/bridge-core/generated/drag-speed-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: DragSpeedState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtDragSpeedObject {
- stateChanged: QtSignal;
- ready: () => void;
- setDragFactor: (factor: number) => void;
- resize: (height: number) => void;
-}
-
-export interface DragSpeedBridge {
- ready(): void;
- setDragFactor(factor: number): void;
- resize(height: number): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validateDragSpeedState);
-}
-
-class MockDragSpeedBridge implements DragSpeedBridge {
- private readonly state: DragSpeedState = structuredClone(initialDragSpeedState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- setDragFactor(): void {}
- resize(): void {}
- dispose(): void {}
-}
-
-export function createDragSpeedBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): DragSpeedBridge {
- const fallback = new MockDragSpeedBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtDragSpeedObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[drag-speed bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.dragSpeedBridge as QtDragSpeedObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- const call = (
- method: K,
- ...args: QtDragSpeedObject[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"),
- setDragFactor: (factor) => call("setDragFactor", factor),
- resize: (height) => call("resize", height),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/drag-speed/bridgeTypes.ts b/web_ui/src/islands/drag-speed/bridgeTypes.ts
deleted file mode 100644
index 63314723..00000000
--- a/web_ui/src/islands/drag-speed/bridgeTypes.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * The drag-speed island's state contract.
- *
- * Carries only static configuration (`percentPresets`/`percentMin`/
- * `percentMax`) - drag speed has always been a pure fire-and-forget
- * control, matching font-control's own precedent: the legacy slider never
- * read a live value back either (it always started at a hardcoded 100%).
- */
-export type { DragSpeedState } from "../../lib/bridge-core/generated/drag-speed-state";
-
-import type { DragSpeedState } from "../../lib/bridge-core/generated/drag-speed-state";
-
-export const initialDragSpeedState: DragSpeedState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- percentPresets: [25, 50, 75, 100],
- percentMin: 10,
- percentMax: 100,
-};
diff --git a/web_ui/src/islands/drag-speed/index.html b/web_ui/src/islands/drag-speed/index.html
deleted file mode 100644
index 7387c73c..00000000
--- a/web_ui/src/islands/drag-speed/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Drag Speed
-
-
-
-
-
-
diff --git a/web_ui/src/islands/drag-speed/main.tsx b/web_ui/src/islands/drag-speed/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/drag-speed/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/drag-speed/styles.css b/web_ui/src/islands/drag-speed/styles.css
deleted file mode 100644
index 01abce89..00000000
--- a/web_ui/src/islands/drag-speed/styles.css
+++ /dev/null
@@ -1,98 +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
- [DRAG_SPEED_MIN_HEIGHT, DRAG_SPEED_MAX_HEIGHT] - see App.tsx). */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.drag-speed-shell {
- box-sizing: border-box;
- width: 220px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 14px;
- 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: 12px;
-}
-
-.drag-speed-row {
- display: flex;
- align-items: center;
- gap: 10px;
-}
-
-.drag-speed-label {
- flex-shrink: 0;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.drag-speed-slider {
- flex: 1;
- min-width: 0;
- accent-color: var(--gl-palette-selection);
-}
-
-.drag-speed-presets {
- display: flex;
- gap: 4px;
-}
-
-.drag-speed-preset-btn {
- flex: 1;
- min-width: 0;
- padding: 5px 2px;
- font: inherit;
- font-size: 10px;
- font-weight: 650;
- color: var(--gl-neutral-button-icon);
- background-color: var(--gl-graph-node-body-start);
- border: 1px solid transparent;
- border-radius: 6px;
- cursor: pointer;
-}
-
-.drag-speed-preset-btn:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.drag-speed-preset-btn.active {
- border-color: var(--gl-palette-selection);
- color: var(--gl-palette-selection);
-}
-
-.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/font-control/App.test.tsx b/web_ui/src/islands/font-control/App.test.tsx
deleted file mode 100644
index c568b183..00000000
--- a/web_ui/src/islands/font-control/App.test.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import App from "./App";
-import { initialFontControlState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-const FAMILIES = ["Segoe UI", "Arial", "Consolas"];
-const COLOR_PRESETS = ["#F0F0F0", "#C7C7C7", "#949494", "#818181"];
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- setFontFamily: vi.fn(),
- setFontSize: vi.fn(),
- setFontColor: vi.fn(),
- resize: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { fontControlBridge: 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({
- ...initialFontControlState,
- revision: 1,
- fontFamilies: FAMILIES,
- colorPresets: COLOR_PRESETS,
- ...overrides,
- }),
- );
-}
-
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- it("renders every font family option and every color swatch from state", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote);
-
- expect(await screen.findByRole("option", { name: "Consolas" })).toBeInTheDocument();
- expect(screen.getByLabelText("Font color #818181")).toBeInTheDocument();
- });
-
- it("selecting a family calls setFontFamily", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByRole("option", { name: "Consolas" });
-
- await user.selectOptions(screen.getByLabelText("Font family"), "Consolas");
-
- expect(remote.setFontFamily).toHaveBeenCalledWith("Consolas");
- });
-
- it("moving the size slider calls setFontSize", async () => {
- const remote = installFakeQWebChannel();
- render( );
- push(remote, { sizeMin: 8, sizeMax: 16 });
- const slider = await screen.findByLabelText("Font size");
-
- fireEvent.change(slider, { target: { value: "14" } });
-
- expect(remote.setFontSize).toHaveBeenCalledWith(14);
- });
-
- it("clicking a color swatch calls setFontColor", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByLabelText("Font color #C7C7C7");
-
- await user.click(screen.getByLabelText("Font color #C7C7C7"));
-
- expect(remote.setFontColor).toHaveBeenCalledWith("#C7C7C7");
- });
-
- 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({ ...initialFontControlState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("The font control panel is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/font-control/App.tsx b/web_ui/src/islands/font-control/App.tsx
deleted file mode 100644
index 6322ba3d..00000000
--- a/web_ui/src/islands/font-control/App.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { FontControlState, initialFontControlState } from "./bridgeTypes";
-import { BridgeRejection, FontControlBridge, createFontControlBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-const DEFAULT_FONT_SIZE = 10;
-
-function App() {
- const [state, setState] = useState(initialFontControlState);
- const [rejection, setRejection] = useState(null);
- // Fire-and-forget controls, matching the legacy FontControl widget exactly
- // - it never read the scene's live font state back either (ChatScene has
- // always been the sole owner of "current" font family/size/color). Local
- // UI state here just tracks what the user has picked in THIS control, the
- // same way the legacy QComboBox/QSlider's own widget-internal value did.
- const [family, setFamily] = useState(state.fontFamilies[0] ?? "Segoe UI");
- const [size, setSize] = useState(DEFAULT_FONT_SIZE);
- const bridgeRef = useRef(null);
- const shellRef = useRef(null);
-
- useEffect(() => {
- const bridge = createFontControlBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- 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();
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- return (
-
- Font
-
-
-
- {
- const value = Number(event.target.value);
- setSize(value);
- bridgeRef.current?.setFontSize(value);
- }}
- />
-
-
- {state.colorPresets.map((color) => (
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/font-control/bridge.test.ts b/web_ui/src/islands/font-control/bridge.test.ts
deleted file mode 100644
index a405b2c2..00000000
--- a/web_ui/src/islands/font-control/bridge.test.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createFontControlBridge } from "./bridge";
-import { initialFontControlState, FontControlState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialFontControlState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- setFontFamily: vi.fn(),
- setFontSize: vi.fn(),
- setFontColor: vi.fn(),
- resize: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { fontControlBridge: 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("createFontControlBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createFontControlBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialFontControlState);
- });
-
- it("intents on the mock bridge do not throw", () => {
- const bridge = createFontControlBridge(() => {});
- expect(() => {
- bridge.setFontFamily("Consolas");
- bridge.setFontSize(12);
- bridge.setFontColor("#ABCDEF");
- bridge.resize(180);
- bridge.dispose();
- }).not.toThrow();
- });
-});
-
-describe("createFontControlBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createFontControlBridge(() => {});
- expect(remote.ready).toHaveBeenCalledTimes(1);
- expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("forwards a real published state to the listener", () => {
- const remote = installFakeQWebChannel();
- try {
- const listener = vi.fn();
- createFontControlBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ revision: 2, fontFamilies: ["Consolas", "Arial"] }));
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ fontFamilies: ["Consolas", "Arial"] }),
- );
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("each intent calls through to the matching remote method with its args", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createFontControlBridge(() => {});
-
- bridge.setFontFamily("Georgia");
- bridge.setFontSize(14);
- bridge.setFontColor("#F0F0F0");
- bridge.resize(200);
-
- expect(remote.setFontFamily).toHaveBeenCalledWith("Georgia");
- expect(remote.setFontSize).toHaveBeenCalledWith(14);
- expect(remote.setFontColor).toHaveBeenCalledWith("#F0F0F0");
- expect(remote.resize).toHaveBeenCalledWith(200);
- } 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();
- createFontControlBridge(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 = createFontControlBridge(() => {});
- 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/font-control/bridge.ts b/web_ui/src/islands/font-control/bridge.ts
deleted file mode 100644
index b593415e..00000000
--- a/web_ui/src/islands/font-control/bridge.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-import { FontControlState, initialFontControlState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateFontControlState } from "../../lib/bridge-core/generated/font-control-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: FontControlState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtFontControlObject {
- stateChanged: QtSignal;
- ready: () => void;
- setFontFamily: (family: string) => void;
- setFontSize: (size: number) => void;
- setFontColor: (hex: string) => void;
- resize: (height: number) => void;
-}
-
-export interface FontControlBridge {
- ready(): void;
- setFontFamily(family: string): void;
- setFontSize(size: number): void;
- setFontColor(hex: string): void;
- resize(height: number): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validateFontControlState);
-}
-
-class MockFontControlBridge implements FontControlBridge {
- private readonly state: FontControlState = structuredClone(initialFontControlState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- setFontFamily(): void {}
- setFontSize(): void {}
- setFontColor(): void {}
- resize(): void {}
- dispose(): void {}
-}
-
-export function createFontControlBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): FontControlBridge {
- const fallback = new MockFontControlBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtFontControlObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[font-control bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.fontControlBridge as QtFontControlObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- const call = (
- method: K,
- ...args: QtFontControlObject[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"),
- setFontFamily: (family) => call("setFontFamily", family),
- setFontSize: (size) => call("setFontSize", size),
- setFontColor: (hex) => call("setFontColor", hex),
- resize: (height) => call("resize", height),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/font-control/bridgeTypes.ts b/web_ui/src/islands/font-control/bridgeTypes.ts
deleted file mode 100644
index e2ab78dd..00000000
--- a/web_ui/src/islands/font-control/bridgeTypes.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * The font-control island's state contract.
- *
- * Carries only static configuration (`fontFamilies`/`colorPresets`/
- * `sizeMin`/`sizeMax`) - font state has always lived entirely on
- * ChatScene, never on this control, so there is no "current value" to
- * round-trip here (the legacy widget never read it back either). See
- * graphlink_font_control_payload.py's own docstring for the full rationale.
- */
-export type { FontControlState } from "../../lib/bridge-core/generated/font-control-state";
-
-import type { FontControlState } from "../../lib/bridge-core/generated/font-control-state";
-
-export const initialFontControlState: FontControlState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- fontFamilies: ["Segoe UI"],
- colorPresets: ["#F0F0F0", "#C7C7C7", "#949494", "#818181"],
- sizeMin: 8,
- sizeMax: 16,
-};
diff --git a/web_ui/src/islands/font-control/index.html b/web_ui/src/islands/font-control/index.html
deleted file mode 100644
index 6b988a86..00000000
--- a/web_ui/src/islands/font-control/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Font Control
-
-
-
-
-
-
diff --git a/web_ui/src/islands/font-control/main.tsx b/web_ui/src/islands/font-control/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/font-control/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/font-control/styles.css b/web_ui/src/islands/font-control/styles.css
deleted file mode 100644
index c02483a8..00000000
--- a/web_ui/src/islands/font-control/styles.css
+++ /dev/null
@@ -1,91 +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
- [FONT_CONTROL_MIN_HEIGHT, FONT_CONTROL_MAX_HEIGHT] - see App.tsx). */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.font-control-shell {
- box-sizing: border-box;
- width: 220px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 14px;
- 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: 12px;
-}
-
-.font-control-title {
- margin: 0;
- text-align: center;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.font-control-family-select {
- box-sizing: border-box;
- width: 100%;
- padding: 6px 8px;
- font-size: 11px;
- 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: 6px;
-}
-
-.font-control-slider {
- width: 100%;
- accent-color: var(--gl-palette-selection);
-}
-
-.font-control-row {
- display: flex;
- gap: 10px;
-}
-
-.font-control-color-swatch {
- flex: 1;
- min-width: 0;
- height: 20px;
- padding: 0;
- border: 2px solid var(--gl-neutral-button-border);
- border-radius: 5px;
- cursor: pointer;
-}
-
-.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/grid-control/App.test.tsx b/web_ui/src/islands/grid-control/App.test.tsx
deleted file mode 100644
index 00786875..00000000
--- a/web_ui/src/islands/grid-control/App.test.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import App from "./App";
-import { initialGridControlState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- setGridSize: vi.fn(),
- setGridOpacityPercent: vi.fn(),
- setGridStyle: vi.fn(),
- setGridColor: vi.fn(),
- setSnapToGrid: vi.fn(),
- setOrthogonalConnections: vi.fn(),
- setSmartGuides: vi.fn(),
- setFadeConnections: vi.fn(),
- resize: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { gridControlBridge: 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({ ...initialGridControlState, revision: 1, ...overrides }));
-}
-
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- it("renders the size/style/color presets from state, highlighting the current ones", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote, { gridSize: 20, gridStyle: "Lines" });
-
- expect(await screen.findByText("20px")).toHaveClass("active");
- expect(screen.getByText("10px")).not.toHaveClass("active");
- expect(screen.getByText("Lines")).toHaveClass("active");
- expect(screen.getByText("Dots")).not.toHaveClass("active");
- });
-
- it("clicking a size preset calls setGridSize", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByText("10px");
-
- await user.click(screen.getByText("50px"));
-
- expect(remote.setGridSize).toHaveBeenCalledWith(50);
- });
-
- it("clicking a style preset calls setGridStyle", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByText("Cross");
-
- await user.click(screen.getByText("Cross"));
-
- expect(remote.setGridStyle).toHaveBeenCalledWith("Cross");
- });
-
- it("clicking a color swatch calls setGridColor", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote, { colorPresets: ["#111111", "#222222"] });
- await screen.findByLabelText("Grid color #222222");
-
- await user.click(screen.getByLabelText("Grid color #222222"));
-
- expect(remote.setGridColor).toHaveBeenCalledWith("#222222");
- });
-
- it("moving the opacity slider calls setGridOpacityPercent", async () => {
- const remote = installFakeQWebChannel();
- render( );
- push(remote, { gridOpacityPercent: 30 });
- const slider = await screen.findByLabelText("Grid opacity");
-
- fireEvent.change(slider, { target: { value: "75" } });
-
- expect(remote.setGridOpacityPercent).toHaveBeenCalledWith(75);
- });
-
- it("toggling Snap to Grid checkbox calls setSnapToGrid and keeps the box checked", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- const checkbox = await screen.findByRole("checkbox", { name: "Snap to Grid" });
-
- await user.click(checkbox);
-
- expect(remote.setSnapToGrid).toHaveBeenCalledWith(true);
- expect(checkbox).toBeChecked();
- });
-
- it("toggling the other 3 checkboxes calls their matching intents", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByRole("checkbox", { name: "Snap to Grid" });
-
- await user.click(screen.getByRole("checkbox", { name: "Orthogonal Connections" }));
- await user.click(screen.getByRole("checkbox", { name: "Smart Guides" }));
- await user.click(screen.getByRole("checkbox", { name: "Faded Connections" }));
-
- expect(remote.setOrthogonalConnections).toHaveBeenCalledWith(true);
- expect(remote.setSmartGuides).toHaveBeenCalledWith(true);
- expect(remote.setFadeConnections).toHaveBeenCalledWith(true);
- });
-
- 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({ ...initialGridControlState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("The grid control panel is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/grid-control/App.tsx b/web_ui/src/islands/grid-control/App.tsx
deleted file mode 100644
index 2cfbe69d..00000000
--- a/web_ui/src/islands/grid-control/App.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { GridControlState, initialGridControlState } from "./bridgeTypes";
-import { BridgeRejection, GridControlBridge, createGridControlBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-function App() {
- const [state, setState] = useState(initialGridControlState);
- const [rejection, setRejection] = useState(null);
- // The 4 routing/alignment checkboxes are pure client-side state, matching
- // the toolbar's own `controlsChecked` precedent - nothing else in the app
- // reads this back (it writes straight onto ChatScene), so there is no
- // server round-trip to keep in sync. Persists across show/hide toggles
- // the same way the legacy widget's own living QCheckBox instances did,
- // since this island is only ever hidden/shown, never unmounted.
- const [snapToGrid, setSnapToGrid] = useState(false);
- const [orthogonalConnections, setOrthogonalConnections] = useState(false);
- const [smartGuides, setSmartGuides] = useState(false);
- const [fadeConnections, setFadeConnections] = useState(false);
- const bridgeRef = useRef(null);
- const shellRef = useRef(null);
-
- useEffect(() => {
- const bridge = createGridControlBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- 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();
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- return (
-
- Grid
-
- bridgeRef.current?.setGridOpacityPercent(Number(event.target.value))}
- />
-
-
- {state.sizePresets.map((size) => (
-
- ))}
-
-
-
- {state.stylePresets.map((style) => (
-
- ))}
-
-
-
- {state.colorPresets.map((color) => (
-
-
- Alignment & Routing
-
-
-
-
- Connection Rendering
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/grid-control/bridge.test.ts b/web_ui/src/islands/grid-control/bridge.test.ts
deleted file mode 100644
index f2c5582c..00000000
--- a/web_ui/src/islands/grid-control/bridge.test.ts
+++ /dev/null
@@ -1,160 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createGridControlBridge } from "./bridge";
-import { initialGridControlState, GridControlState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialGridControlState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- setGridSize: vi.fn(),
- setGridOpacityPercent: vi.fn(),
- setGridStyle: vi.fn(),
- setGridColor: vi.fn(),
- setSnapToGrid: vi.fn(),
- setOrthogonalConnections: vi.fn(),
- setSmartGuides: vi.fn(),
- setFadeConnections: vi.fn(),
- resize: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { gridControlBridge: 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("createGridControlBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createGridControlBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialGridControlState);
- });
-
- it("intents on the mock bridge do not throw", () => {
- const bridge = createGridControlBridge(() => {});
- expect(() => {
- bridge.setGridSize(50);
- bridge.setGridOpacityPercent(80);
- bridge.setGridStyle("Lines");
- bridge.setGridColor("#ABCDEF");
- bridge.setSnapToGrid(true);
- bridge.setOrthogonalConnections(true);
- bridge.setSmartGuides(true);
- bridge.setFadeConnections(true);
- bridge.resize(300);
- bridge.dispose();
- }).not.toThrow();
- });
-});
-
-describe("createGridControlBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createGridControlBridge(() => {});
- expect(remote.ready).toHaveBeenCalledTimes(1);
- expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("forwards a real published state to the listener", () => {
- const remote = installFakeQWebChannel();
- try {
- const listener = vi.fn();
- createGridControlBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ revision: 2, gridSize: 50, gridStyle: "Cross" }));
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ gridSize: 50, gridStyle: "Cross" }),
- );
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("each intent calls through to the matching remote method with its args", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createGridControlBridge(() => {});
-
- bridge.setGridSize(20);
- bridge.setGridOpacityPercent(60);
- bridge.setGridStyle("Lines");
- bridge.setGridColor("#123456");
- bridge.setSnapToGrid(true);
- bridge.setOrthogonalConnections(false);
- bridge.setSmartGuides(true);
- bridge.setFadeConnections(false);
- bridge.resize(321);
-
- expect(remote.setGridSize).toHaveBeenCalledWith(20);
- expect(remote.setGridOpacityPercent).toHaveBeenCalledWith(60);
- expect(remote.setGridStyle).toHaveBeenCalledWith("Lines");
- expect(remote.setGridColor).toHaveBeenCalledWith("#123456");
- expect(remote.setSnapToGrid).toHaveBeenCalledWith(true);
- expect(remote.setOrthogonalConnections).toHaveBeenCalledWith(false);
- expect(remote.setSmartGuides).toHaveBeenCalledWith(true);
- expect(remote.setFadeConnections).toHaveBeenCalledWith(false);
- expect(remote.resize).toHaveBeenCalledWith(321);
- } 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();
- createGridControlBridge(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 = createGridControlBridge(() => {});
- 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/grid-control/bridge.ts b/web_ui/src/islands/grid-control/bridge.ts
deleted file mode 100644
index 5d958175..00000000
--- a/web_ui/src/islands/grid-control/bridge.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { GridControlState, initialGridControlState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateGridControlState } from "../../lib/bridge-core/generated/grid-control-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: GridControlState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtGridControlObject {
- stateChanged: QtSignal;
- ready: () => void;
- setGridSize: (size: number) => void;
- setGridOpacityPercent: (percent: number) => void;
- setGridStyle: (style: string) => void;
- setGridColor: (hex: string) => void;
- setSnapToGrid: (enabled: boolean) => void;
- setOrthogonalConnections: (enabled: boolean) => void;
- setSmartGuides: (enabled: boolean) => void;
- setFadeConnections: (enabled: boolean) => void;
- resize: (height: number) => void;
-}
-
-export interface GridControlBridge {
- ready(): void;
- setGridSize(size: number): void;
- setGridOpacityPercent(percent: number): void;
- setGridStyle(style: string): void;
- setGridColor(hex: string): void;
- setSnapToGrid(enabled: boolean): void;
- setOrthogonalConnections(enabled: boolean): void;
- setSmartGuides(enabled: boolean): void;
- setFadeConnections(enabled: boolean): void;
- resize(height: number): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validateGridControlState);
-}
-
-class MockGridControlBridge implements GridControlBridge {
- private readonly state: GridControlState = structuredClone(initialGridControlState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- setGridSize(): void {}
- setGridOpacityPercent(): void {}
- setGridStyle(): void {}
- setGridColor(): void {}
- setSnapToGrid(): void {}
- setOrthogonalConnections(): void {}
- setSmartGuides(): void {}
- setFadeConnections(): void {}
- resize(): void {}
- dispose(): void {}
-}
-
-export function createGridControlBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): GridControlBridge {
- const fallback = new MockGridControlBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtGridControlObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[grid-control bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.gridControlBridge as QtGridControlObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- const call = (
- method: K,
- ...args: QtGridControlObject[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"),
- setGridSize: (size) => call("setGridSize", size),
- setGridOpacityPercent: (percent) => call("setGridOpacityPercent", percent),
- setGridStyle: (style) => call("setGridStyle", style),
- setGridColor: (hex) => call("setGridColor", hex),
- setSnapToGrid: (enabled) => call("setSnapToGrid", enabled),
- setOrthogonalConnections: (enabled) => call("setOrthogonalConnections", enabled),
- setSmartGuides: (enabled) => call("setSmartGuides", enabled),
- setFadeConnections: (enabled) => call("setFadeConnections", enabled),
- resize: (height) => call("resize", height),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/grid-control/bridgeTypes.ts b/web_ui/src/islands/grid-control/bridgeTypes.ts
deleted file mode 100644
index 9dc4add2..00000000
--- a/web_ui/src/islands/grid-control/bridgeTypes.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-/**
- * The grid-control island's state contract.
- *
- * `gridSize`/`gridOpacityPercent`/`gridStyle`/`gridColor` reflect the real,
- * live `GridViewSettings` model on the desktop side - unlike the toolbar's
- * `controlsChecked`, this genuinely IS the authoritative state
- * ChatView.drawBackground() reads, so it round-trips for real.
- * `sizePresets`/`stylePresets`/`colorPresets` are Python-owned static
- * configuration (see graphlink_grid_control_bridge.py) - not hardcoded here.
- */
-export type { GridControlState } from "../../lib/bridge-core/generated/grid-control-state";
-
-import type { GridControlState } from "../../lib/bridge-core/generated/grid-control-state";
-
-export const initialGridControlState: GridControlState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- gridSize: 10,
- gridOpacityPercent: 30,
- gridStyle: "Dots",
- gridColor: "#555555",
- sizePresets: [10, 20, 50, 100],
- stylePresets: ["Dots", "Lines", "Cross"],
- colorPresets: ["#404040", "#555555", "#3E7BFA", "#4CAF50", "#9C27B0"],
-};
diff --git a/web_ui/src/islands/grid-control/index.html b/web_ui/src/islands/grid-control/index.html
deleted file mode 100644
index c744ae47..00000000
--- a/web_ui/src/islands/grid-control/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Grid Control
-
-
-
-
-
-
diff --git a/web_ui/src/islands/grid-control/main.tsx b/web_ui/src/islands/grid-control/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/grid-control/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/grid-control/styles.css b/web_ui/src/islands/grid-control/styles.css
deleted file mode 100644
index dff88e47..00000000
--- a/web_ui/src/islands/grid-control/styles.css
+++ /dev/null
@@ -1,128 +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
- [GRID_CONTROL_MIN_HEIGHT, GRID_CONTROL_MAX_HEIGHT] - see App.tsx). */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.grid-control-shell {
- box-sizing: border-box;
- width: 220px;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: 14px;
- 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: 12px;
-}
-
-.grid-control-title {
- margin: 0;
- text-align: center;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.grid-control-slider {
- width: 100%;
- accent-color: var(--gl-palette-selection);
-}
-
-.grid-control-row {
- display: flex;
- gap: 4px;
-}
-
-.grid-control-preset-btn {
- flex: 1;
- min-width: 0;
- padding: 5px 2px;
- font: inherit;
- font-size: 10px;
- font-weight: 650;
- color: var(--gl-neutral-button-icon);
- background-color: var(--gl-graph-node-body-start);
- border: 1px solid transparent;
- border-radius: 6px;
- cursor: pointer;
-}
-
-.grid-control-preset-btn:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.grid-control-preset-btn.active {
- border-color: var(--gl-palette-selection);
- color: var(--gl-palette-selection);
-}
-
-.grid-control-color-swatch {
- flex: 1;
- min-width: 0;
- height: 22px;
- padding: 0;
- border: 2px solid var(--gl-neutral-button-border);
- border-radius: 5px;
- cursor: pointer;
-}
-
-.grid-control-color-swatch.active {
- border-color: var(--gl-palette-selection);
-}
-
-.grid-control-section-label {
- margin: 4px 0 0 0;
- font-size: 9px;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.grid-control-checkbox-row {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 11px;
- color: var(--gl-neutral-button-icon);
- cursor: pointer;
-}
-
-.grid-control-checkbox-row input[type="checkbox"] {
- accent-color: var(--gl-palette-selection);
-}
-
-.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/help/App.test.tsx b/web_ui/src/islands/help/App.test.tsx
deleted file mode 100644
index e3ddcad4..00000000
--- a/web_ui/src/islands/help/App.test.tsx
+++ /dev/null
@@ -1,112 +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 { initialHelpState } from "./bridgeTypes";
-import { HELP_SECTIONS } from "./data/sections";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-// jsdom has no window.QWebChannel, so createHelpBridge() 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 all 9 rail sections with Overview active by default", () => {
- render( );
-
- for (const section of HELP_SECTIONS) {
- expect(screen.getByRole("button", { name: section.name })).toBeInTheDocument();
- }
- expect(screen.getByRole("button", { name: "Overview" })).toHaveAttribute("aria-current", "page");
- });
-
- it("renders the active section's heading, description, and item cards", () => {
- render( );
-
- const overview = HELP_SECTIONS[0];
- expect(screen.getByRole("heading", { name: overview.name })).toBeInTheDocument();
- expect(screen.getByText(overview.description)).toBeInTheDocument();
- expect(screen.getByText(overview.subsections[0].items[0].action)).toBeInTheDocument();
- });
-
- it("clicking a rail button switches the active section without a bridge round-trip", async () => {
- const user = userEvent.setup();
- render( );
- const secondSection = HELP_SECTIONS[1];
-
- await user.click(screen.getByRole("button", { name: secondSection.name }));
-
- expect(screen.getByRole("button", { name: secondSection.name })).toHaveAttribute("aria-current", "page");
- expect(screen.getByRole("button", { name: "Overview" })).not.toHaveAttribute("aria-current");
- expect(screen.getByRole("heading", { name: secondSection.name })).toBeInTheDocument();
- });
-
- it("clicking Close does not throw against the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
-
- 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(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { helpBridge: 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 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({ ...initialHelpState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("Help is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/help/App.tsx b/web_ui/src/islands/help/App.tsx
deleted file mode 100644
index eea34b54..00000000
--- a/web_ui/src/islands/help/App.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { HelpBridge, BridgeRejection, createHelpBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-import { HELP_SECTIONS } from "./data/sections";
-
-const RAIL_INTRO =
- "Graphlink is a visual AI workspace. Start with the overview, then jump directly to the workflow, tool, or project area you need.";
-
-function App() {
- const [rejection, setRejection] = useState(null);
- const bridgeRef = useRef(null);
- // Which section is showing is pure client-side state - Python never needs
- // to know (see graphlink_help_bridge.py's module docstring), so there is
- // no bridge round-trip for this at all, unlike the settings island's
- // outwardly-similar rail.
- const [activeSectionName, setActiveSectionName] = useState(HELP_SECTIONS[0].name);
-
- useEffect(() => {
- const bridge = createHelpBridge(() => {}, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- useEffect(() => {
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") {
- bridgeRef.current?.close();
- }
- };
- window.addEventListener("keydown", onKeyDown);
- return () => window.removeEventListener("keydown", onKeyDown);
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- const activeSection = HELP_SECTIONS.find((section) => section.name === activeSectionName) ?? HELP_SECTIONS[0];
-
- return (
-
-
-
-
-
-
- {activeSection.name}
- {activeSection.description}
-
-
-
-
-
- {activeSection.subsections.map((subsection) => (
-
- {subsection.title}
- {subsection.items.map((item, index) => (
-
- {item.action}
- {item.description}
-
- ))}
-
- ))}
-
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/help/bridge.test.ts b/web_ui/src/islands/help/bridge.test.ts
deleted file mode 100644
index cfefbe6f..00000000
--- a/web_ui/src/islands/help/bridge.test.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createHelpBridge } from "./bridge";
-import { initialHelpState, HelpState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialHelpState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- close: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { helpBridge: 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("createHelpBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createHelpBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialHelpState);
- });
-
- it("close() on the mock bridge does not throw", () => {
- const bridge = createHelpBridge(() => {});
- expect(() => bridge.close()).not.toThrow();
- });
-
- it("dispose() on the mock bridge does not throw", () => {
- const bridge = createHelpBridge(() => {});
- expect(() => bridge.dispose()).not.toThrow();
- });
-});
-
-describe("createHelpBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createHelpBridge(() => {});
- 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();
- createHelpBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ revision: 3 }));
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ revision: 3 }));
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("close calls through to the remote method", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createHelpBridge(() => {});
-
- bridge.close();
-
- 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();
- createHelpBridge(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 = createHelpBridge(() => {});
- 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/help/bridge.ts b/web_ui/src/islands/help/bridge.ts
deleted file mode 100644
index 253f2193..00000000
--- a/web_ui/src/islands/help/bridge.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-import { HelpState, initialHelpState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateHelpState } from "../../lib/bridge-core/generated/help-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: HelpState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtHelpObject {
- stateChanged: QtSignal;
- ready: () => void;
- close: () => void;
-}
-
-export interface HelpBridge {
- ready(): void;
- close(): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validateHelpState);
-}
-
-class MockHelpBridge implements HelpBridge {
- private readonly state: HelpState = structuredClone(initialHelpState);
- 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.
- }
-
- dispose(): void {}
-}
-
-export function createHelpBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): HelpBridge {
- const fallback = new MockHelpBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtHelpObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[help bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.helpBridge as QtHelpObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- const call = (
- method: K,
- ...args: QtHelpObject[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"),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/help/bridgeTypes.ts b/web_ui/src/islands/help/bridgeTypes.ts
deleted file mode 100644
index 0b4f1f86..00000000
--- a/web_ui/src/islands/help/bridgeTypes.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * The help-dialog island's state contract.
- *
- * See composer/bridgeTypes.ts for the fuller rationale (re-export from the
- * generated file, not a hand mirror). This island has zero Python-side
- * content fields - the envelope alone is the whole payload; see
- * data/sections.ts for the actual (static, client-side-only) content.
- */
-export type { HelpState } from "../../lib/bridge-core/generated/help-state";
-
-import type { HelpState } from "../../lib/bridge-core/generated/help-state";
-
-export const initialHelpState: HelpState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
-};
diff --git a/web_ui/src/islands/help/data/sections.test.ts b/web_ui/src/islands/help/data/sections.test.ts
deleted file mode 100644
index a9be2b61..00000000
--- a/web_ui/src/islands/help/data/sections.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { HELP_SECTIONS } from "./sections";
-
-// sections.ts was mechanically generated from the legacy HelpDialog.
-// SECTION_DEFS (see that file's own header) and verified byte-exact against
-// it at generation time - this test guards against a FUTURE hand-edit
-// silently dropping content, not against the original transcription.
-describe("HELP_SECTIONS", () => {
- it("has exactly 9 sections, 19 subsections, and 76 items", () => {
- expect(HELP_SECTIONS).toHaveLength(9);
-
- const subsectionCount = HELP_SECTIONS.reduce((sum, section) => sum + section.subsections.length, 0);
- expect(subsectionCount).toBe(19);
-
- const itemCount = HELP_SECTIONS.reduce(
- (sum, section) => sum + section.subsections.reduce((subSum, sub) => subSum + sub.items.length, 0),
- 0,
- );
- expect(itemCount).toBe(76);
- });
-
- it("every section/subsection/item has non-empty text fields", () => {
- for (const section of HELP_SECTIONS) {
- expect(section.name.length).toBeGreaterThan(0);
- expect(section.description.length).toBeGreaterThan(0);
- for (const subsection of section.subsections) {
- expect(subsection.title.length).toBeGreaterThan(0);
- for (const item of subsection.items) {
- expect(item.action.length).toBeGreaterThan(0);
- expect(item.description.length).toBeGreaterThan(0);
- }
- }
- }
- });
-
- it("starts with Overview, matching the legacy default section", () => {
- expect(HELP_SECTIONS[0].name).toBe("Overview");
- });
-});
diff --git a/web_ui/src/islands/help/data/sections.ts b/web_ui/src/islands/help/data/sections.ts
deleted file mode 100644
index 081e18f1..00000000
--- a/web_ui/src/islands/help/data/sections.ts
+++ /dev/null
@@ -1,480 +0,0 @@
-/* GENERATED - do not hand-edit. Source of truth was the legacy
- * HelpDialog.SECTION_DEFS in graphlink_ui_dialogs/graphlink_system_dialogs.py
- * (deleted in Phase 4's shim-collapse increment once this file existed) -
- * mechanically extracted (icon fields dropped per the Phase 4 scope note;
- * see doc/FRONTEND_WEB_MIGRATION_MASTER_PLAN.md), not hand-retyped, so the
- * 76 items/19 subsections/9 sections of copy carried over byte-exact.
- * This is now the single canonical source - nothing else reads this
- * content, per "adding a section requires no Python change." */
-
-export interface HelpItem {
- action: string;
- description: string;
-}
-
-export interface HelpSubsection {
- title: string;
- items: HelpItem[];
-}
-
-export interface HelpSection {
- name: string;
- description: string;
- subsections: HelpSubsection[];
-}
-
-export const HELP_SECTIONS: HelpSection[] = [
- {
- "name": "Overview",
- "description": "What Graphlink is, how work is structured, and how a typical project moves from prompt to polished output.",
- "subsections": [
- {
- "title": "What Graphlink Does",
- "items": [
- {
- "action": "Visual AI Workspace",
- "description": "Graphlink turns prompts, replies, notes, charts, documents, code, and plugin runs into connected items on one canvas. Instead of losing work inside a single scrolling transcript, you can keep multiple lines of thought visible at once."
- },
- {
- "action": "Branch-Based Conversations",
- "description": "Every selected node becomes the anchor for what you do next. That makes it easy to fork alternatives, compare directions, and keep complex work organized as a family of branches instead of one long thread."
- },
- {
- "action": "Mixed Content Graph",
- "description": "A single project can contain chat nodes, code blocks, visible reasoning, attached files, generated images, notes, charts, and specialist tool nodes. The connections preserve where an idea came from and what it produced."
- },
- {
- "action": "Saved Project Space",
- "description": "Chats, plugin state, frames, containers, pins, and notes are persisted and can be reopened from the Library. Graphlink is designed for ongoing project work, not just one-off prompts."
- }
- ]
- },
- {
- "title": "Typical Session Flow",
- "items": [
- {
- "action": "Start or Resume",
- "description": "Open a recent project from the Library, or press Ctrl+T to start a new chat. The app is comfortable for quick ideation and for returning to long-running work."
- },
- {
- "action": "Build a Branch",
- "description": "Send a prompt, review the result, then select the exact node you want to continue from. Each selection creates a deliberate branch point so you can refine or challenge a result without overwriting earlier work."
- },
- {
- "action": "Add Specialist Nodes",
- "description": "When a branch needs something more than a plain reply, open Plugins to add reasoning, web research, coding, sandbox execution, drafting, HTML rendering, or branch comparison tools."
- },
- {
- "action": "Capture the Outcome",
- "description": "Use notes, explainers, takeaways, charts, exports, and document views to turn raw responses into a clearer project record. The goal is a canvas you can revisit and understand later."
- }
- ]
- }
- ]
- },
- {
- "name": "Canvas & Branching",
- "description": "How selection, context inheritance, branching, and side-by-side exploration work inside the graph.",
- "subsections": [
- {
- "title": "How Branches Work",
- "items": [
- {
- "action": "Active Context Node",
- "description": "The node you select becomes the parent for your next message or specialist tool. The input placeholder updates to show what you are responding to, which helps you stay oriented in large graphs."
- },
- {
- "action": "Parent and Child History",
- "description": "Branches inherit the relevant conversation history from their anchor node. Sibling branches can explore different ideas from the same point without stomping on each other."
- },
- {
- "action": "Focus One Branch",
- "description": "Chat nodes can temporarily hide other branches so you can concentrate on one path. This is especially useful once a graph starts to fan out into several competing directions."
- },
- {
- "action": "Regenerate from the Same Context",
- "description": "Assistant responses can be regenerated from their parent branch when you want a fresh answer without manually rebuilding the surrounding context."
- }
- ]
- },
- {
- "title": "Working on the Canvas",
- "items": [
- {
- "action": "Select and Move",
- "description": "Click a node to select it, drag on empty space to rubber-band select, and move one item or a whole group together. The graph behaves like a visual workspace, not a locked transcript."
- },
- {
- "action": "Collapse Dense Areas",
- "description": "Most major node types can collapse to keep the canvas readable. Collapse is especially helpful when a branch is stable but still needs to stay connected to the rest of the project."
- },
- {
- "action": "Keep Notes Beside the Work",
- "description": "Notes live alongside AI nodes rather than inside them. Use them for decisions, TODOs, pasted snippets, executive summaries, or anything that should stay visible but should not be sent back to the model."
- },
- {
- "action": "Compare Alternatives",
- "description": "When two branch tips represent different approaches, Branch Lens can inspect both branches and explain where their logic, code direction, and intent diverge."
- }
- ]
- }
- ]
- },
- {
- "name": "Nodes & Content",
- "description": "The main node types you will see on the canvas and what each one is best at.",
- "subsections": [
- {
- "title": "Core Conversation Nodes",
- "items": [
- {
- "action": "Chat Nodes",
- "description": "User and assistant chat nodes are the backbone of the application. Selecting one lets you continue exactly that line of thought, which makes branching feel intentional instead of accidental."
- },
- {
- "action": "Code Nodes",
- "description": "When a response includes fenced code, Graphlink breaks it into dedicated code nodes. Those nodes can be copied, exported, regenerated through their parent reply, or used as a starting point for coding tools."
- },
- {
- "action": "Thinking Nodes",
- "description": "If a model returns visible reasoning or analysis, the app stores it as a separate thinking node. This keeps long reasoning trails available without burying the main answer."
- },
- {
- "action": "Document and Image Nodes",
- "description": "Attachments become their own nodes on the branch so the source material remains visible next to the prompt that used it. That makes it easier to audit what the model actually saw."
- }
- ]
- },
- {
- "title": "Supporting Content",
- "items": [
- {
- "action": "Notes",
- "description": "Manual notes, takeaways, explainer notes, and multi-node summary notes all live as movable note items. They are ideal for turning raw model output into cleaner project knowledge."
- },
- {
- "action": "Charts",
- "description": "The app can turn text-derived or numeric content into bar, line, pie, histogram, and Sankey charts on the canvas. Charts are useful when a branch contains data you want to explain visually."
- },
- {
- "action": "Document View",
- "description": "Long chat content can be opened in the document viewer for easier reading and review. This is especially helpful for lengthy drafts, specifications, or dense research summaries."
- },
- {
- "action": "HTML Preview",
- "description": "The HTML Renderer node can take markup and show a live preview inside the graph, with an optional pop-out preview window for closer inspection."
- },
- {
- "action": "Node Context Menus",
- "description": "Right-click chat, code, and plugin nodes for copy, export, document view, branch isolation, regeneration, takeaways, explainers, charts, image generation, and deletion actions. When multiple chat nodes are selected, the menu also exposes group summary workflows."
- }
- ]
- }
- ]
- },
- {
- "name": "Navigation",
- "description": "Mouse, keyboard, search, command palette, and view controls for moving quickly through small or very large graphs.",
- "subsections": [
- {
- "title": "Mouse and View Controls",
- "items": [
- {
- "action": "Pan the Canvas",
- "description": "Hold the Middle Mouse Button and drag to move around the workspace. This is the fastest way to traverse large graphs once you stop thinking of the canvas like a normal chat window."
- },
- {
- "action": "Zoom and Focus",
- "description": "Use Ctrl + Mouse Wheel to zoom, or use the toolbar buttons when you want a fixed step. Q and E also zoom out and in from the keyboard."
- },
- {
- "action": "Zoom to an Area",
- "description": "Hold Shift and drag a rectangle to zoom the view to a specific region. It is a precise way to jump into a dense subsection of a large project."
- },
- {
- "action": "Fit All and Reset",
- "description": "Fit All reframes the canvas around everything currently on it, while Reset restores the default zoom level. These two actions are the quickest recovery tools when you get lost."
- },
- {
- "action": "Minimap and Overlays",
- "description": "Use the Controls toggle to reveal drag, grid, and font tools, and use the minimap to jump directly to distant nodes. This becomes more valuable as your project spreads out."
- }
- ]
- },
- {
- "title": "Keyboard Workflow",
- "items": [
- {
- "action": "WASD and Branch Navigation",
- "description": "W, A, S, and D pan the view, while Ctrl + Arrow Keys move between parent, child, and sibling nodes in the current branch. This makes branch review much faster than constant mouse travel."
- },
- {
- "action": "Command Palette",
- "description": "Press Ctrl + K to open a searchable command palette for layout, note creation, selection, navigation, plugin insertion, chart generation, and more. It is the fastest way to discover power-user actions."
- },
- {
- "action": "Canvas Search",
- "description": "Press Ctrl + F to search across chat, code, document, image, thinking, and plugin nodes. Search results can be stepped through so you can quickly revisit important content."
- },
- {
- "action": "Selection Utilities",
- "description": "Delete removes the current selection, and the command palette also exposes focus selection, select all, collapse all, and expand all. These commands are useful once a graph becomes visually dense."
- }
- ]
- },
- {
- "title": "Shortcut Reference",
- "items": [
- {
- "action": "Ctrl + T / Ctrl + L / Ctrl + S",
- "description": "Start a new chat, open the Library, or save the current project. These are the main project-level shortcuts you will use most often."
- },
- {
- "action": "Ctrl + G / Ctrl + Shift + G / Ctrl + N",
- "description": "Wrap the current selection in a Frame, create a Container, or add a new Note. These three actions cover most day-to-day organization work."
- },
- {
- "action": "Ctrl + Left-Click / Ctrl + Right-Click",
- "description": "Add a connection pin to reroute a line, or remove a pin that you no longer need. Connection pins are helpful when you want to untangle overlapping paths."
- }
- ]
- }
- ]
- },
- {
- "name": "Organization",
- "description": "Ways to group, label, tidy, and visually manage complex canvases as a project grows.",
- "subsections": [
- {
- "title": "Structuring Large Graphs",
- "items": [
- {
- "action": "Frames",
- "description": "Select node-like items and press Ctrl + G to wrap them in a frame. Frames are best for labeling a visual section of the project without forcing the grouped items to move as one rigid unit."
- },
- {
- "action": "Containers",
- "description": "Use Ctrl + Shift + G to create a container when a set of items should move together. Containers are useful for keeping a working cluster intact while you reorganize the rest of the canvas."
- },
- {
- "action": "Titles and Colors",
- "description": "Frames and containers can be renamed and recolored so sections of the graph read like chapters or workstreams. This helps when you want the canvas to double as a presentation surface."
- },
- {
- "action": "Auto-Organize Tree Layout",
- "description": "The Organize button arranges conversational and plugin branches into a cleaner horizontal tree. It is a fast way to recover readability after a burst of exploratory work."
- }
- ]
- },
- {
- "title": "Orientation Aids",
- "items": [
- {
- "action": "Navigation Pins",
- "description": "Pins mark important places on the canvas and are saved with the chat. Use the Pins toolbar button to reveal the overlay and jump back to major milestones or hotspots."
- },
- {
- "action": "Connection Pins",
- "description": "Ctrl + left-click a connection to add a routing pin and shape the line, then Ctrl + right-click a pin to remove it. This is especially helpful when several branches overlap visually."
- },
- {
- "action": "Grid and Guide Controls",
- "description": "The controls overlay can enable snap-to-grid, smart guides, orthogonal routing, font controls, and faded connections. These tools are useful when a canvas needs visual cleanup rather than new AI output."
- },
- {
- "action": "Reduce Visual Noise",
- "description": "Combine collapse, grouping, branch isolation, and layout tools to keep the graph readable. Graphlink works best when you treat organization as part of the thinking process, not just post-processing."
- }
- ]
- }
- ]
- },
- {
- "name": "Plugins & Tools",
- "description": "Specialist nodes that extend a branch into research, reasoning, coding, drafting, rendering, and comparison workflows.",
- "subsections": [
- {
- "title": "Research and Analysis",
- "items": [
- {
- "action": "Graphlink-Web",
- "description": "Use this when a branch depends on current information or external sources. The node runs a web retrieval flow, summarizes the findings, and stores source links directly in the result."
- },
- {
- "action": "Conversation Node",
- "description": "Creates a self-contained linear chat surface inside the graph. Use it when you want a focused sub-conversation that can be pruned and iterated without expanding the main branch too aggressively."
- }
- ]
- },
- {
- "title": "Build, Draft, and Render",
- "items": [
- {
- "action": "System Prompt",
- "description": "Adds a branch-scoped system prompt note that changes assistant behavior only for that conversation path. This is ideal when you want a role, tone, or instruction change without affecting the rest of the project."
- },
- {
- "action": "Py-Coder",
- "description": "A coding workspace with AI-driven and manual modes, generated code, terminal output, and final analysis tabs. Use it for fast implementation, debugging, code generation, and lightweight computation."
- },
- {
- "action": "Execution Sandbox",
- "description": "Runs Python in a per-node virtualenv with declared dependencies - the venv isolates installed packages, not the operating system; code still runs with your full account privileges. Choose this over Py-Coder when you need dependency-aware execution or a cleaner reproducible runtime."
- },
- {
- "action": "Artifact / Drafter",
- "description": "A living markdown drafting surface for reports, specs, briefs, and other documents that need repeated revision. It is the most natural place to keep polished long-form output inside the graph."
- },
- {
- "action": "HTML Renderer",
- "description": "Turns HTML or UI markup into a preview pane inside the graph and can pop the preview into a separate window. It is the right tool when a branch needs visual feedback instead of plain text output."
- }
- ]
- }
- ]
- },
- {
- "name": "Settings & Models",
- "description": "How runtime modes, providers, model routing, and quality-of-life settings shape the behavior of the application.",
- "subsections": [
- {
- "title": "Runtime Modes",
- "items": [
- {
- "action": "Ollama (Local)",
- "description": "Use local Ollama when you want on-device chat and reasoning. You can choose a default chat model and switch between Quick mode and Thinking mode for different response styles."
- },
- {
- "action": "API Endpoint Mode",
- "description": "Use API Endpoint mode for OpenAI-compatible providers or Gemini. This unlocks hosted models, per-task model routing, and the image-generation path used by the canvas."
- },
- {
- "action": "Per-Task Model Selection",
- "description": "Graphlink can store different models for title generation, main chat, chart generation, image generation, web validation, and web summarization. This lets you optimize cost and quality across different tools."
- },
- {
- "action": "Live Provider Reconfiguration",
- "description": "Switching modes reinitializes the active provider for the current session. If a provider is missing credentials or cannot initialize, the app warns you before you keep working."
- }
- ]
- },
- {
- "title": "Personalization and Feedback",
- "items": [
- {
- "action": "Appearance",
- "description": "Theme and appearance settings restyle the app, including flyouts and node accents, around the current palette. This is mainly cosmetic, but it helps tailor the workspace to your preference."
- },
- {
- "action": "Token Counter",
- "description": "When enabled, the token counter shows prompt, context, output, and running session totals. It is useful when you want a sense of branch size or model budget."
- },
- {
- "action": "Provider-Specific Guidance",
- "description": "API settings explain which providers are supported and which fields matter for each one. OpenAI-compatible endpoints and Gemini use slightly different setup and model-loading paths."
- },
- {
- "action": "Optional Feature Dependencies",
- "description": "Some features rely on optional libraries, such as PDF or DOCX import-export support and HTML preview support. When something is unavailable, Graphlink tries to tell you what dependency is missing."
- }
- ]
- }
- ]
- },
- {
- "name": "Saving & Output",
- "description": "Persistence, attachments, exports, and the ways Graphlink turns branch results into reusable project deliverables.",
- "subsections": [
- {
- "title": "Persistence and Library",
- "items": [
- {
- "action": "Background Saves",
- "description": "Use Save to persist the current graph, including chats, plugin nodes, notes, pins, frames, containers, and view state. This makes it practical to treat a chat like a reusable workspace instead of a disposable session."
- },
- {
- "action": "Chat Library",
- "description": "Open the Library with the toolbar or Ctrl + L to reopen, rename, organize, or continue past projects. The Library is the main entry point for resuming work across sessions."
- },
- {
- "action": "Local Project Record",
- "description": "Because the graph is saved as a full project state, your canvas can function as a working notebook, task map, and decision trail all at once."
- }
- ]
- },
- {
- "title": "Attachments and Exports",
- "items": [
- {
- "action": "Supported Attachments",
- "description": "You can attach common text and code files, JSON, CSV, XML, HTML, CSS, JS, and Markdown. PDF and DOCX attachments are also supported when their reader libraries are installed."
- },
- {
- "action": "Drag and Drop",
- "description": "Files can be staged by dragging them onto the canvas or by using the paperclip button. The attachment becomes a visible branch node so it is clear what source material was provided."
- },
- {
- "action": "Export Formats",
- "description": "Chat, document, and code content can be exported in practical formats such as TXT, Markdown, HTML, and PDF. Chat and document content also support DOCX, and code can be exported as a Python script."
- },
- {
- "action": "Generated Outputs",
- "description": "Image generation, chart creation, takeaway notes, explainers, group summaries, and branch diff notes all let you promote transient AI output into reusable project artifacts."
- }
- ]
- }
- ]
- },
- {
- "name": "Use Cases",
- "description": "Practical ways to use the full application beyond simple back-and-forth prompting.",
- "subsections": [
- {
- "title": "Common Workflows",
- "items": [
- {
- "action": "Research and Comparison",
- "description": "Start with a broad question, branch different answers, use Graphlink-Web to ground facts, and finish with Branch Lens when you want a disciplined comparison between competing paths."
- },
- {
- "action": "Coding and Debugging",
- "description": "Ask for code on the main canvas, move promising results into Py-Coder for iteration, and switch to Execution Sandbox when dependencies or reproducibility matter. This keeps ideation and execution connected."
- },
- {
- "action": "Planning and Execution",
- "description": "Use Workflow Architect when a goal feels large or undefined. It can convert a vague request into a clearer plugin sequence so your next few steps are already framed."
- },
- {
- "action": "Shipping and Hardening",
- "description": "When a branch is close to done, run Quality Gate to judge whether it actually meets the bar. It is especially useful before handoff, release, demos, or any moment when confidence matters more than momentum."
- },
- {
- "action": "Long-Form Writing",
- "description": "Keep source research nearby, use reasoning where needed, and let Artifact / Drafter hold the polished document. This is a strong workflow for specs, reports, proposals, and internal documentation."
- }
- ]
- },
- {
- "title": "Knowledge Work Patterns",
- "items": [
- {
- "action": "Meeting and Study Notes",
- "description": "Generate takeaways, explainer notes, and group summaries around important nodes so the final canvas reads like a structured knowledge map rather than a raw transcript dump."
- },
- {
- "action": "Product and UX Exploration",
- "description": "Branch alternative product directions, draft requirements, render HTML prototypes, and compare variants without losing the reasoning that led to each option."
- },
- {
- "action": "Data Storytelling",
- "description": "Turn a branch that contains numbers or structure into charts, then keep the explanation, source inputs, and conclusions beside the visualization on the same canvas."
- },
- {
- "action": "Prompt Variation by Branch",
- "description": "Use System Prompt nodes and branching to test different assistant behaviors against the same underlying project. This is especially useful when you want to compare tone, role, or working style."
- }
- ]
- }
- ]
- }
-];
diff --git a/web_ui/src/islands/help/index.html b/web_ui/src/islands/help/index.html
deleted file mode 100644
index 4d1ebc4c..00000000
--- a/web_ui/src/islands/help/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Help
-
-
-
-
-
-
diff --git a/web_ui/src/islands/help/main.tsx b/web_ui/src/islands/help/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/help/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/help/styles.css b/web_ui/src/islands/help/styles.css
deleted file mode 100644
index f3c858f7..00000000
--- a/web_ui/src/islands/help/styles.css
+++ /dev/null
@@ -1,193 +0,0 @@
-/* Colors reuse EXISTING app-wide --gl-* tokens, same convention every
- island since token-counter follows. Sized to match the legacy
- HelpDialog's resize(900, 620). */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.help-shell {
- box-sizing: border-box;
- width: 900px;
- height: 620px;
- display: flex;
- flex-direction: row;
- 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;
- overflow: hidden;
-}
-
-.help-rail {
- box-sizing: border-box;
- width: 200px;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 12px;
- border-right: 1px solid var(--gl-neutral-button-border);
-}
-
-.help-rail-eyebrow {
- margin: 0;
- font-size: 11px;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.help-rail-intro {
- margin: 0 0 4px 0;
- font-size: 12px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.help-rail-buttons {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.help-rail-button {
- box-sizing: border-box;
- width: 100%;
- padding: 10px 12px;
- text-align: left;
- font-size: 13px;
- font-family: inherit;
- color: var(--gl-neutral-button-icon);
- background-color: transparent;
- border: none;
- border-radius: 4px;
- cursor: pointer;
-}
-
-.help-rail-button:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.help-rail-button[aria-current="page"] {
- background-color: var(--gl-neutral-button-background);
- color: var(--gl-semantic-status-success);
-}
-
-.help-content {
- flex: 1;
- display: flex;
- flex-direction: column;
- min-width: 0;
- padding: 12px 14px;
-}
-
-.help-content-header {
- display: flex;
- align-items: flex-start;
- gap: 10px;
-}
-
-.help-content-heading {
- flex: 1;
- min-width: 0;
-}
-
-.help-content-title {
- margin: 0;
- font-size: 16px;
- font-weight: 700;
-}
-
-.help-content-description {
- margin: 4px 0 0 0;
- font-size: 12px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.help-close-btn {
- flex-shrink: 0;
- padding: 6px 14px;
- font-size: 12px;
- 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;
- cursor: pointer;
-}
-
-.help-close-btn:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.help-scroll-area {
- flex: 1;
- margin-top: 10px;
- padding-right: 4px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.help-section-block {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.help-section-title {
- margin: 0;
- font-size: 12px;
- font-weight: 700;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.help-item-card {
- box-sizing: border-box;
- padding: 10px 12px;
- background-color: var(--gl-neutral-button-background);
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 6px;
-}
-
-.help-item-action {
- margin: 0 0 4px 0;
- font-size: 13px;
- font-weight: 600;
-}
-
-.help-item-description {
- margin: 0;
- font-size: 12px;
- 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/minimap/App.test.tsx b/web_ui/src/islands/minimap/App.test.tsx
deleted file mode 100644
index 9972cee4..00000000
--- a/web_ui/src/islands/minimap/App.test.tsx
+++ /dev/null
@@ -1,93 +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 { initialMinimapState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-const NODES = [
- { id: "1", isUser: true, preview: "Hello there" },
- { id: "2", isUser: false, preview: "Hi back" },
-];
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- selectNode: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { minimapBridge: 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({ ...initialMinimapState, revision: 1, nodes: NODES, ...overrides }));
-}
-
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- it("renders one indicator per node, colored by isUser, with the preview as its accessible name", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote);
-
- const userIndicator = await screen.findByRole("listitem", { name: "Hello there" });
- const aiIndicator = screen.getByRole("listitem", { name: "Hi back" });
- expect(userIndicator).toHaveClass("user");
- expect(aiIndicator).toHaveClass("ai");
- });
-
- it("clicking an indicator calls selectNode with its id", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- const indicator = await screen.findByRole("listitem", { name: "Hi back" });
-
- await user.click(indicator);
-
- expect(remote.selectNode).toHaveBeenCalledWith("2");
- });
-
- it("renders nothing in the list when there are no nodes", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote, { nodes: [] });
-
- expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
- });
-
- 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({ ...initialMinimapState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("The minimap is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/minimap/App.tsx b/web_ui/src/islands/minimap/App.tsx
deleted file mode 100644
index 816504fb..00000000
--- a/web_ui/src/islands/minimap/App.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { MinimapState, initialMinimapState } from "./bridgeTypes";
-import { BridgeRejection, MinimapBridge, createMinimapBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-function App() {
- const [state, setState] = useState(initialMinimapState);
- const [rejection, setRejection] = useState(null);
- const bridgeRef = useRef(null);
-
- useEffect(() => {
- const bridge = createMinimapBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- return (
-
-
- {state.nodes.map((node) => (
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/minimap/bridge.test.ts b/web_ui/src/islands/minimap/bridge.test.ts
deleted file mode 100644
index f6ccf4d4..00000000
--- a/web_ui/src/islands/minimap/bridge.test.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createMinimapBridge } from "./bridge";
-import { initialMinimapState, MinimapState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialMinimapState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- selectNode: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { minimapBridge: 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("createMinimapBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createMinimapBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialMinimapState);
- });
-
- it("intents on the mock bridge do not throw", () => {
- const bridge = createMinimapBridge(() => {});
- expect(() => {
- bridge.selectNode("123");
- bridge.dispose();
- }).not.toThrow();
- });
-});
-
-describe("createMinimapBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createMinimapBridge(() => {});
- expect(remote.ready).toHaveBeenCalledTimes(1);
- expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("forwards real published nodes to the listener", () => {
- const remote = installFakeQWebChannel();
- try {
- const listener = vi.fn();
- createMinimapBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(
- stateJson({
- revision: 2,
- nodes: [{ id: "111", isUser: true, preview: "Hello" }],
- }),
- );
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ nodes: [expect.objectContaining({ id: "111" })] }),
- );
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("selectNode calls through to the matching remote method with the id", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createMinimapBridge(() => {});
-
- bridge.selectNode("456");
-
- expect(remote.selectNode).toHaveBeenCalledWith("456");
- } 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();
- createMinimapBridge(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 = createMinimapBridge(() => {});
- 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/minimap/bridge.ts b/web_ui/src/islands/minimap/bridge.ts
deleted file mode 100644
index aa3dcb01..00000000
--- a/web_ui/src/islands/minimap/bridge.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { MinimapState, initialMinimapState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateMinimapState } from "../../lib/bridge-core/generated/minimap-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: MinimapState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtMinimapObject {
- stateChanged: QtSignal;
- ready: () => void;
- selectNode: (id: string) => void;
-}
-
-export interface MinimapBridge {
- ready(): void;
- selectNode(id: string): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validateMinimapState);
-}
-
-class MockMinimapBridge implements MinimapBridge {
- private readonly state: MinimapState = structuredClone(initialMinimapState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- selectNode(): void {}
- dispose(): void {}
-}
-
-export function createMinimapBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): MinimapBridge {
- const fallback = new MockMinimapBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtMinimapObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[minimap bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.minimapBridge as QtMinimapObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- const call = (
- method: K,
- ...args: QtMinimapObject[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"),
- selectNode: (id) => call("selectNode", id),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/minimap/bridgeTypes.ts b/web_ui/src/islands/minimap/bridgeTypes.ts
deleted file mode 100644
index e9619b43..00000000
--- a/web_ui/src/islands/minimap/bridgeTypes.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * The minimap island's state contract.
- *
- * `nodes` is the full, debounced snapshot of every ChatNode currently in
- * the scene (see graphlink_minimap_bridge.py's own docstring for the
- * debounce rationale) - unlike the legacy MinimapWidget, there is no
- * MAX_VISIBLE_NODES windowing/scroll-pagination here: a plain scrollable
- * list handles arbitrarily many nodes via native browser scrolling, which
- * doesn't carry the per-frame QPainter repaint cost that motivated the
- * original 25-node cap. `id` is a wire-only identifier
- * (`str(id(python_object))`), not a persisted node property - stable only
- * for the lifetime of the node within this running session.
- */
-export type { MinimapState, MinimapNodeEntry } from "../../lib/bridge-core/generated/minimap-state";
-
-import type { MinimapState } from "../../lib/bridge-core/generated/minimap-state";
-
-export const initialMinimapState: MinimapState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- nodes: [],
-};
diff --git a/web_ui/src/islands/minimap/index.html b/web_ui/src/islands/minimap/index.html
deleted file mode 100644
index fb7324c2..00000000
--- a/web_ui/src/islands/minimap/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Minimap
-
-
-
-
-
-
diff --git a/web_ui/src/islands/minimap/main.tsx b/web_ui/src/islands/minimap/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/minimap/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/minimap/styles.css b/web_ui/src/islands/minimap/styles.css
deleted file mode 100644
index 2458d34c..00000000
--- a/web_ui/src/islands/minimap/styles.css
+++ /dev/null
@@ -1,99 +0,0 @@
-/* Colors reuse EXISTING app-wide --gl-* tokens (--gl-palette-user-node /
- --gl-palette-ai-node, the same palette.USER_NODE/AI_NODE the legacy
- MinimapWidget painted with). Height is EXTERNALLY imposed by Python
- (ChatView._update_overlay_positions sets a fixed height based on the
- viewport) - this island never reports its own height, unlike every other
- panel in this migration; it just fills whatever box it's given. */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
- height: 100%;
-}
-
-.minimap-shell {
- box-sizing: border-box;
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- opacity: 0.4;
- transition: opacity 150ms ease;
-}
-
-.minimap-shell:hover {
- opacity: 1;
-}
-
-.minimap-list {
- box-sizing: border-box;
- width: 100%;
- max-height: 100%;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: stretch;
- gap: 3px;
- padding: 4px 2px;
- scrollbar-width: none;
-}
-
-.minimap-list::-webkit-scrollbar {
- display: none;
-}
-
-.minimap-indicator {
- flex-shrink: 0;
- width: 100%;
- height: 3px;
- padding: 0;
- border: none;
- border-radius: 2px;
- cursor: pointer;
- background: linear-gradient(
- to right,
- transparent,
- var(--indicator-color) 20%,
- var(--indicator-color) 80%,
- transparent
- );
-}
-
-.minimap-indicator.user {
- --indicator-color: var(--gl-palette-user-node);
-}
-
-.minimap-indicator.ai {
- --indicator-color: var(--gl-palette-ai-node);
-}
-
-.minimap-indicator:hover {
- --indicator-color: var(--gl-palette-selection);
-}
-
-.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/notification/App.test.tsx b/web_ui/src/islands/notification/App.test.tsx
deleted file mode 100644
index 13c58725..00000000
--- a/web_ui/src/islands/notification/App.test.tsx
+++ /dev/null
@@ -1,126 +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 { initialNotificationState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-// jsdom has no window.QWebChannel, so createNotificationBridge() falls
-// through to the mock bridge automatically for the smoke test below - same
-// pattern as ComposerApp.test.tsx/token-counter's App.test.tsx.
-
-describe("App against the mock bridge", () => {
- it("starts hidden, matching the initial visible:false state", () => {
- render( );
- expect(screen.getByRole("status", { hidden: true })).not.toBeVisible();
- });
-});
-
-// The mock bridge is deliberately inert (copyDetails/dismiss are no-ops -
-// Python is fully authoritative for visibility, see bridge.ts's docstring),
-// so interactive behavior needs a real (faked) QWebChannel connection to
-// drive real state through, same approach as
-// composer/ComposerApp.reject-recover.test.tsx.
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- copyDetails: vi.fn(),
- dismiss: vi.fn(),
- resize: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { notificationBridge: 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("renders the message and type-specific title 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(
- JSON.stringify({
- ...initialNotificationState,
- visible: true,
- message: "Careful, this cannot be undone.",
- msgType: "warning",
- }),
- );
-
- await waitFor(() => expect(screen.getByRole("status")).toBeVisible());
- expect(screen.getByText("Warning")).toBeInTheDocument();
- expect(screen.getByText("Careful, this cannot be undone.")).toBeInTheDocument();
- });
-
- it("Dismiss and the close button both call through to the remote's dismiss()", 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({ ...initialNotificationState, visible: true, message: "Hi.", msgType: "info" }));
- await waitFor(() => expect(screen.getByRole("status")).toBeVisible());
-
- await user.click(screen.getByRole("button", { name: "Dismiss" }));
- await user.click(screen.getByRole("button", { name: "Dismiss notification" }));
-
- expect(remote.dismiss).toHaveBeenCalledTimes(2);
- });
-
- it("Copy details calls through to the remote and shows local 'Copied' feedback", 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({
- ...initialNotificationState,
- visible: true,
- message: "Copy this text.",
- msgType: "info",
- }),
- );
- await waitFor(() => expect(screen.getByRole("status")).toBeVisible());
-
- await user.click(screen.getByRole("button", { name: "Copy details" }));
-
- expect(remote.copyDetails).toHaveBeenCalledTimes(1);
- expect(await screen.findByRole("button", { name: "Copied" })).toBeInTheDocument();
- });
-
- // 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({ ...initialNotificationState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("Notifications unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/notification/App.tsx b/web_ui/src/islands/notification/App.tsx
deleted file mode 100644
index 62395b0d..00000000
--- a/web_ui/src/islands/notification/App.tsx
+++ /dev/null
@@ -1,141 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { NotificationState, initialNotificationState } from "./bridgeTypes";
-import { BridgeRejection, NotificationBridge, createNotificationBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-type MsgType = NotificationState["msgType"];
-
-// Titles match the old widget's TYPE_STYLES[*]["title"] verbatim.
-const TYPE_META: Record = {
- info: { title: "Notice" },
- success: { title: "Success" },
- warning: { title: "Warning" },
- error: { title: "Action Needed" },
-};
-
-// Matches the old widget's copy_feedback_timer duration.
-const COPY_FEEDBACK_MS = 1600;
-
-function Icon({ name }: { name: MsgType | "close" }) {
- const paths: Record = {
- info: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 5.2a1.3 1.3 0 1 1 0 2.6 1.3 1.3 0 0 1 0-2.6ZM13.25 17h-2.5v-7h2.5v7Z",
- success: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm-1.15 14.15-4-4 1.4-1.4 2.6 2.6 5.6-5.6 1.4 1.4-7 7Z",
- warning: "M12 2 1 21h22L12 2Zm0 6.3 1.05 6.45h-2.1L12 8.3Zm0 8.85a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Z",
- error: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1.1 14.5h-2.2v-2.2h2.2v2.2Zm0-4.4h-2.2V6.7h2.2v5.4Z",
- close: "m6.4 5 12.6 12.6-1.4 1.4L5 6.4 6.4 5Zm12.6 1.4L6.4 19 5 17.6 17.6 5l1.4 1.4Z",
- };
- return (
-
- );
-}
-
-function App() {
- const [state, setState] = useState(initialNotificationState);
- const [rejection, setRejection] = useState(null);
- const [copied, setCopied] = useState(false);
- // Resets "Copied" the moment a NEW Python-published state arrives, even
- // mid-feedback - done during render (React's documented "adjusting state
- // when a prop changes" pattern), not in an effect, since setState directly
- // inside an effect body triggers an avoidable cascading extra render.
- const [copyResetRevision, setCopyResetRevision] = useState(state.revision);
- if (copyResetRevision !== state.revision) {
- setCopyResetRevision(state.revision);
- setCopied(false);
- }
- const bridgeRef = useRef(null);
- const shellRef = useRef(null);
- const copyTimerRef = useRef(undefined);
-
- useEffect(() => {
- const bridge = createNotificationBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- // Keyed on `rejection`, not `[]` - see ComposerApp.tsx's identical effect
- // for the full rationale (a reject-then-recover cycle mounts a new ).
- 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]);
-
- useEffect(() => {
- return () => window.clearTimeout(copyTimerRef.current);
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- function copyDetails() {
- bridgeRef.current?.copyDetails();
- setCopied(true);
- window.clearTimeout(copyTimerRef.current);
- copyTimerRef.current = window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
- }
-
- function dismiss() {
- bridgeRef.current?.dismiss();
- }
-
- return (
-
-
-
-
-
- {TYPE_META[state.msgType].title}
-
-
-
- {state.message}
-
-
-
-
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/notification/bridge.test.ts b/web_ui/src/islands/notification/bridge.test.ts
deleted file mode 100644
index f4ab2dfe..00000000
--- a/web_ui/src/islands/notification/bridge.test.ts
+++ /dev/null
@@ -1,163 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createNotificationBridge } from "./bridge";
-import { initialNotificationState, NotificationState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialNotificationState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- copyDetails: vi.fn(),
- dismiss: vi.fn(),
- resize: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { notificationBridge: 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("createNotificationBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createNotificationBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialNotificationState);
- });
-
- it("dispose() on the mock bridge does not throw", () => {
- const bridge = createNotificationBridge(() => {});
- expect(() => bridge.dispose()).not.toThrow();
- });
-});
-
-describe("createNotificationBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createNotificationBridge(() => {});
- 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();
- createNotificationBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ visible: true, message: "Saved.", msgType: "success" }));
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ visible: true, message: "Saved.", msgType: "success" }),
- );
- } 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();
- createNotificationBridge(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 = createNotificationBridge(() => {});
- bridge.copyDetails();
- bridge.dismiss();
-
- expect(remote.copyDetails).toHaveBeenCalledTimes(1);
- expect(remote.dismiss).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("queues a resize requested before connection and applies it once connected", () => {
- // Deferred callback so resize() can genuinely be called before
- // "connected" flips true - see composer/bridge.test.ts's identical case.
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- copyDetails: vi.fn(),
- dismiss: vi.fn(),
- resize: vi.fn(),
- };
- let pendingCallback: ((channel: { objects: Record }) => void) | null = null;
-
- class DeferredFakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- pendingCallback = callback;
- }
- }
-
- const qtWindow = window as unknown as QtWindow;
- qtWindow.QWebChannel = DeferredFakeQWebChannel as unknown as QtWindow["QWebChannel"];
- qtWindow.qt = { webChannelTransport: {} };
-
- try {
- const bridge = createNotificationBridge(() => {});
- bridge.resize(150);
- expect(remote.resize).not.toHaveBeenCalled();
-
- pendingCallback!({ objects: { notificationBridge: remote } });
- expect(remote.resize).toHaveBeenCalledWith(150);
- } finally {
- delete qtWindow.QWebChannel;
- delete qtWindow.qt;
- }
- });
-
- it("dispose() disconnects the state listener", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createNotificationBridge(() => {});
- 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/notification/bridge.ts b/web_ui/src/islands/notification/bridge.ts
deleted file mode 100644
index 46054d37..00000000
--- a/web_ui/src/islands/notification/bridge.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-import { NotificationState, initialNotificationState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateNotificationState } from "../../lib/bridge-core/generated/notification-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: NotificationState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtNotificationObject {
- stateChanged: QtSignal;
- ready: () => void;
- copyDetails: () => void;
- dismiss: () => void;
- resize: (height: number) => void;
-}
-
-export interface NotificationBridge {
- ready(): void;
- copyDetails(): void;
- dismiss(): void;
- resize(height: number): void;
- dispose(): void;
-}
-
-/**
- * Event-push toast: Python drives visible/message/msgType, JS sends back
- * only one real intent (copyDetails) plus the shared resize() height-
- * negotiation call every negotiated-sizing island uses (see composer's
- * bridge.ts for the fuller rationale on that one).
- */
-function parseState(payload: string) {
- return parseIslandState(payload, validateNotificationState);
-}
-
-class MockNotificationBridge implements NotificationBridge {
- private state: NotificationState = structuredClone(initialNotificationState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- copyDetails(): void {}
- dismiss(): void {}
- resize(): void {}
- dispose(): void {}
-}
-
-export function createNotificationBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): NotificationBridge {
- const fallback = new MockNotificationBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtNotificationObject | null = null;
- let connected = false;
- let pendingHeight: number | null = null;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[notification bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.notificationBridge as QtNotificationObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- if (pendingHeight !== null) remote.resize(pendingHeight);
- });
-
- const call = (
- method: K,
- ...args: QtNotificationObject[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"),
- copyDetails: () => call("copyDetails"),
- dismiss: () => call("dismiss"),
- resize: (height) => {
- pendingHeight = height;
- call("resize", height);
- },
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/notification/bridgeTypes.ts b/web_ui/src/islands/notification/bridgeTypes.ts
deleted file mode 100644
index bc7f0b74..00000000
--- a/web_ui/src/islands/notification/bridgeTypes.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * The notification 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:
- * initialNotificationState, the mock snapshot used for browser-preview/dev
- * and by jsdom tests.
- */
-export type { NotificationState } from "../../lib/bridge-core/generated/notification-state";
-
-import type { NotificationState } from "../../lib/bridge-core/generated/notification-state";
-
-export const initialNotificationState: NotificationState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- visible: false,
- message: "",
- msgType: "info",
-};
diff --git a/web_ui/src/islands/notification/index.html b/web_ui/src/islands/notification/index.html
deleted file mode 100644
index c07b4536..00000000
--- a/web_ui/src/islands/notification/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Notification
-
-
-
-
-
-
diff --git a/web_ui/src/islands/notification/main.tsx b/web_ui/src/islands/notification/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/notification/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/notification/styles.css b/web_ui/src/islands/notification/styles.css
deleted file mode 100644
index 329e81ee..00000000
--- a/web_ui/src/islands/notification/styles.css
+++ /dev/null
@@ -1,199 +0,0 @@
-/* Colors reuse EXISTING app-wide --gl-* tokens rather than registering a new
- island-scoped THEME_TOKENS group. Unlike token-counter's 3-color HUD
- panel, this island genuinely needs 4-way differentiation (info/success/
- warning/error) - but that need is already met exactly by the existing
- app-wide "semantic" group (status_info/status_success/status_warning/
- status_error, see graphlink_styles.py's THEME_TOKENS), which was named
- for precisely this purpose. So notification does NOT turn out to be the
- "island #2" moment css_custom_properties()'s docstring and
- tests/test_theme_tokens.py's two second-island tripwire tests are
- watching for - both are left untouched by this island on purpose, same
- as token-counter.
-
- Simplifications from the old QWidget, deliberate and recorded rather than
- silent (same convention as token-counter's dropped box-shadow):
- - No QGraphicsDropShadowEffect. A flat border reads clearly enough at
- this size; approximating the shadow would need a CSS box-shadow with a
- literal rgba() the no-raw-colors lint would reject, for a cosmetic
- difference not worth a new token.
- - No slide-in/slide-out QPropertyAnimation. Show/hide is now driven by
- NotificationWebHost toggling the whole host widget's Qt-level
- visibility (see graphlink_notification_bridge.py's visibilityChanged
- signal) - instant, matching how every other Qt-hidden widget in this
- app behaves. Animating that would mean re-introducing a JS-side delay
- between "Python says hide" and "widget actually disappears," which is
- more machinery than this cosmetic polish is worth right now.
- - One neutral hover/pressed pair for all buttons (copy/dismiss/close)
- across all 4 types, instead of the old widget's per-type copy_hover/
- dismiss_hover/close_hover (12 distinct hex/rgba values). Only the
- accent stripe + icon differ by type now; button chrome reuses the same
- --gl-neutral-button-* tokens every other island's buttons already use. */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.notification-shell {
- box-sizing: border-box;
- width: 460px;
- min-height: 108px;
- max-height: 400px;
- overflow-y: auto;
- padding: 14px 14px 14px 16px;
- border: 1px solid var(--gl-neutral-button-border);
- border-left: 3px solid var(--gl-semantic-status-info);
- 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);
-}
-
-.notification-shell[data-msg-type="success"] {
- border-left-color: var(--gl-semantic-status-success);
-}
-
-.notification-shell[data-msg-type="warning"] {
- border-left-color: var(--gl-semantic-status-warning);
-}
-
-.notification-shell[data-msg-type="error"] {
- border-left-color: var(--gl-semantic-status-error);
-}
-
-.notification-header {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-bottom: 10px;
-}
-
-.notification-status-icon {
- display: inline-flex;
- width: 18px;
- height: 18px;
- color: var(--gl-semantic-status-info);
-}
-
-.notification-shell[data-msg-type="success"] .notification-status-icon {
- color: var(--gl-semantic-status-success);
-}
-
-.notification-shell[data-msg-type="warning"] .notification-status-icon {
- color: var(--gl-semantic-status-warning);
-}
-
-.notification-shell[data-msg-type="error"] .notification-status-icon {
- color: var(--gl-semantic-status-error);
-}
-
-.notification-status-label {
- flex: 1;
- font-size: 13px;
- font-weight: 600;
- color: var(--gl-neutral-button-icon);
-}
-
-.notification-close-button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- padding: 0;
- border: none;
- border-radius: 6px;
- background: transparent;
- color: var(--gl-neutral-button-muted-icon);
- cursor: pointer;
-}
-
-.notification-close-button:hover {
- background-color: var(--gl-neutral-button-hover);
- color: var(--gl-neutral-button-icon);
-}
-
-.notification-icon {
- width: 100%;
- height: 100%;
- fill: currentColor;
-}
-
-.notification-close-button .notification-icon {
- width: 14px;
- height: 14px;
-}
-
-.notification-message {
- margin: 0 0 12px 0;
- font-size: 13px;
- line-height: 1.5;
- color: var(--gl-neutral-button-icon);
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- user-select: text;
-}
-
-.notification-footer {
- display: flex;
- justify-content: flex-end;
- gap: 8px;
-}
-
-.notification-dismiss-button,
-.notification-copy-button {
- height: 30px;
- padding: 0 12px;
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 6px;
- background-color: var(--gl-neutral-button-background);
- color: var(--gl-neutral-button-icon);
- font-family: inherit;
- font-size: 12px;
- cursor: pointer;
-}
-
-.notification-dismiss-button:hover,
-.notification-copy-button:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.notification-dismiss-button:active,
-.notification-copy-button:active {
- background-color: var(--gl-neutral-button-pressed);
-}
-
-.notification-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/pin-overlay/App.test.tsx b/web_ui/src/islands/pin-overlay/App.test.tsx
deleted file mode 100644
index 7016eb95..00000000
--- a/web_ui/src/islands/pin-overlay/App.test.tsx
+++ /dev/null
@@ -1,306 +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 { initialPinOverlayState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-const ROWS = [
- { id: "p1", title: "First pin", note: "a note" },
- { id: "p2", title: "Second pin", note: "" },
-];
-
-describe("App against the mock bridge", () => {
- afterEach(cleanup);
-
- it("renders the empty placeholder and a disabled-nothing Add button", () => {
- render( );
-
- expect(screen.getByText("No saved locations yet.")).toBeInTheDocument();
- expect(screen.getByText("No saved locations")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Add pin here" })).toBeEnabled();
- });
-});
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- selectPin: vi.fn(),
- deletePin: vi.fn(),
- createPin: vi.fn(),
- editPin: vi.fn(),
- commitDraft: vi.fn(),
- discardDraft: vi.fn(),
- resize: vi.fn(),
- close: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { pinOverlayBridge: 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({ ...initialPinOverlayState, revision: 1, rows, ...extra }));
-}
-
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- it("renders each saved pin's title and note", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote);
-
- expect(await screen.findByText("First pin")).toBeInTheDocument();
- expect(screen.getByText("a note")).toBeInTheDocument();
- expect(screen.getByText("Second pin")).toBeInTheDocument();
- expect(screen.getByText("2 saved locations")).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 pin");
-
- await user.type(screen.getByRole("textbox", { name: "Search navigation pins" }), "second");
-
- expect(screen.queryByText("First pin")).not.toBeInTheDocument();
- expect(screen.getByText("Second pin")).toBeInTheDocument();
- expect(screen.getByText("Showing 1 of 2 saved locations")).toBeInTheDocument();
- });
-
- it("clicking a row's title calls selectPin", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByText("First pin");
-
- await user.click(screen.getByText("First pin"));
-
- expect(remote.selectPin).toHaveBeenCalledWith("p1");
- });
-
- it("the selected pin's row is marked aria-selected", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote, ROWS, { selectedPinId: "p2" });
-
- const rows = await screen.findAllByRole("option");
- const secondRow = rows.find((row) => row.textContent?.includes("Second pin"));
- expect(secondRow).toHaveAttribute("aria-selected", "true");
- });
-
- it("Edit and Delete call through without any confirmation, matching legacy", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByText("First pin");
-
- await user.click(screen.getByRole("button", { name: "Edit First pin" }));
- await user.click(screen.getByRole("button", { name: "Delete First pin" }));
-
- expect(remote.editPin).toHaveBeenCalledWith("p1");
- expect(remote.deletePin).toHaveBeenCalledWith("p1");
- });
-
- it("Add pin here calls createPin", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByText("First pin");
-
- await user.click(screen.getByRole("button", { name: "Add pin here" }));
-
- expect(remote.createPin).toHaveBeenCalledTimes(1);
- });
-
- it("Close button calls close", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByText("First pin");
-
- await user.click(screen.getByRole("button", { name: "Close" }));
-
- expect(remote.close).toHaveBeenCalledTimes(1);
- });
-
- it("Escape in the search box calls close", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByText("First pin");
-
- await user.click(screen.getByRole("textbox", { name: "Search navigation pins" }));
- 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 handler = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- handler(JSON.stringify({ ...initialPinOverlayState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("Navigation pins are unavailable")).toBeInTheDocument();
- });
-});
-
-describe("App's draft editor view (Phase 5 increment 2)", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- function pushDraft(remote: Remote, draft: Record | null, extra: Record = {}) {
- push(remote, ROWS, { draft, ...extra });
- }
-
- it("replaces the list with the editor view, prefilled, when a new-pin draft begins", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- pushDraft(remote, { pinId: "new-1", title: "Waypoint 3", note: "", isNew: true });
-
- expect(await screen.findByText("Add navigation pin")).toBeInTheDocument();
- expect(screen.getByRole("textbox", { name: "Navigation pin title" })).toHaveValue("Waypoint 3");
- expect(screen.queryByText("First pin")).not.toBeInTheDocument();
- });
-
- it("shows Edit copy and the existing values when editing an existing pin", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- pushDraft(remote, { pinId: "p1", title: "First pin", note: "a note", isNew: false });
-
- expect(await screen.findByText("Edit navigation pin")).toBeInTheDocument();
- expect(screen.getByRole("textbox", { name: "Navigation pin title" })).toHaveValue("First pin");
- expect(screen.getByRole("textbox", { name: "Navigation pin note" })).toHaveValue("a note");
- });
-
- it("Save calls commitDraft with the (possibly edited) trimmed values", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- pushDraft(remote, { pinId: "new-1", title: "Waypoint 3", note: "", isNew: true });
- const titleInput = await screen.findByRole("textbox", { name: "Navigation pin title" });
-
- await user.clear(titleInput);
- await user.type(titleInput, " Named ");
- await user.type(screen.getByRole("textbox", { name: "Navigation pin note" }), " a note ");
- await user.click(screen.getByRole("button", { name: "Save" }));
-
- expect(remote.commitDraft).toHaveBeenCalledWith("Named", "a note");
- });
-
- it("Save is disabled and shows an inline error for an empty title, without calling commitDraft", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- pushDraft(remote, { pinId: "new-1", title: "Waypoint 3", note: "", isNew: true });
- const titleInput = await screen.findByRole("textbox", { name: "Navigation pin title" });
-
- await user.clear(titleInput);
-
- expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
- expect(remote.commitDraft).not.toHaveBeenCalled();
- });
-
- it("Cancel calls discardDraft", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- pushDraft(remote, { pinId: "new-1", title: "Waypoint 3", note: "", isNew: true });
- await screen.findByText("Add navigation pin");
-
- await user.click(screen.getByRole("button", { name: "Cancel" }));
-
- expect(remote.discardDraft).toHaveBeenCalledTimes(1);
- });
-
- it("Escape in the title field calls discardDraft, not close", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- pushDraft(remote, { pinId: "new-1", title: "Waypoint 3", note: "", isNew: true });
- const titleInput = await screen.findByRole("textbox", { name: "Navigation pin title" });
-
- await user.click(titleInput);
- await user.keyboard("{Escape}");
-
- expect(remote.discardDraft).toHaveBeenCalledTimes(1);
- expect(remote.close).not.toHaveBeenCalled();
- });
-
- it("Enter in the title field commits the draft", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- pushDraft(remote, { pinId: "new-1", title: "Waypoint 3", note: "", isNew: true });
- const titleInput = await screen.findByRole("textbox", { name: "Navigation pin title" });
-
- await user.click(titleInput);
- await user.keyboard("{Enter}");
-
- expect(remote.commitDraft).toHaveBeenCalledWith("Waypoint 3", "");
- });
-
- it("a server-side error (state.error) renders inline when there is no local validation error", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- pushDraft(remote, { pinId: "new-1", title: "Waypoint 3", note: "", isNew: true }, { error: "A title is required" });
-
- expect(await screen.findByRole("alert")).toHaveTextContent("A title is required");
- });
-
- it("resets the editable fields when a NEW draft begins after a previous one closed", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- pushDraft(remote, { pinId: "p1", title: "First pin", note: "a note", isNew: false });
- const titleInput = await screen.findByRole("textbox", { name: "Navigation pin title" });
- await user.clear(titleInput);
- await user.type(titleInput, "Edited but not saved");
-
- // Draft closes (e.g. committed/discarded), then a genuinely NEW draft
- // begins for a different pin - the stale local edit must not leak in.
- push(remote, ROWS);
- pushDraft(remote, { pinId: "p2", title: "Second pin", note: "", isNew: false });
-
- expect(await screen.findByRole("textbox", { name: "Navigation pin title" })).toHaveValue("Second pin");
- });
-});
diff --git a/web_ui/src/islands/pin-overlay/App.tsx b/web_ui/src/islands/pin-overlay/App.tsx
deleted file mode 100644
index 44c61839..00000000
--- a/web_ui/src/islands/pin-overlay/App.tsx
+++ /dev/null
@@ -1,303 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import { PinOverlayState, initialPinOverlayState } from "./bridgeTypes";
-import { BridgeRejection, PinOverlayBridge, createPinOverlayBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-// Mirrors graphlink_navigation_pins.py's MAX_PIN_TITLE_LENGTH/
-// MAX_PIN_NOTE_LENGTH exactly - compile-time constants on both sides, not
-// worth a wire round-trip for values that never change at runtime.
-const MAX_PIN_TITLE_LENGTH = 120;
-const MAX_PIN_NOTE_LENGTH = 4000;
-
-function validateDraft(title: string, note: string): string | null {
- const trimmedTitle = title.trim();
- if (!trimmedTitle) return "A title is required";
- if (trimmedTitle.length > MAX_PIN_TITLE_LENGTH) return "The title is too long";
- if (note.trim().length > MAX_PIN_NOTE_LENGTH) return "The note is too long";
- return null;
-}
-
-function App() {
- const [state, setState] = useState(initialPinOverlayState);
- const [rejection, setRejection] = useState(null);
- const [query, setQuery] = useState("");
- const [draftTitle, setDraftTitle] = useState("");
- const [draftNote, setDraftNote] = useState("");
- const [draftPinId, setDraftPinId] = useState(null);
- const [localDraftError, setLocalDraftError] = useState(null);
- const bridgeRef = useRef(null);
- const shellRef = useRef(null);
- const searchRef = useRef(null);
- const draftTitleRef = useRef(null);
-
- useEffect(() => {
- const bridge = createPinOverlayBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- useEffect(() => {
- searchRef.current?.focus();
- }, []);
-
- // Content-driven height negotiation, matching the legacy panel's own
- // _resize_for_content() - the host bounds this to [MIN_HEIGHT, MAX_HEIGHT]
- // on the Python side (PinOverlayBridge.resize).
- 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();
- }, []);
-
- // Sync the editable draft fields whenever a genuinely NEW draft begins
- // (keyed on pinId changing), during render rather than an effect -
- // matches this island family's established set-state-in-effect lint fix
- // (see command-palette's identical wasVisible pattern). Python never
- // round-trips what the user is typing - only the draft's STARTING values,
- // once, when the draft opens.
- if (state.draft && state.draft.pinId !== draftPinId) {
- setDraftPinId(state.draft.pinId);
- setDraftTitle(state.draft.title);
- setDraftNote(state.draft.note);
- setLocalDraftError(null);
- } else if (!state.draft && draftPinId !== null) {
- setDraftPinId(null);
- }
-
- const draftPinIdFromState = state.draft?.pinId;
- useEffect(() => {
- if (draftPinIdFromState) draftTitleRef.current?.focus();
- }, [draftPinIdFromState]);
-
- const filtered = useMemo(() => {
- const term = query.trim().toLowerCase();
- if (!term) return state.rows;
- return state.rows.filter(
- (row) => row.title.toLowerCase().includes(term) || row.note.toLowerCase().includes(term),
- );
- }, [query, state.rows]);
-
- if (rejection) {
- return (
-
- );
- }
-
- function commitDraft() {
- const validationError = validateDraft(draftTitle, draftNote);
- if (validationError) {
- setLocalDraftError(validationError);
- return;
- }
- setLocalDraftError(null);
- bridgeRef.current?.commitDraft(draftTitle.trim(), draftNote.trim());
- }
-
- function cancelDraft() {
- setLocalDraftError(null);
- bridgeRef.current?.discardDraft();
- }
-
- function onDraftTitleKeyDown(event: React.KeyboardEvent) {
- if (event.key === "Escape") {
- event.preventDefault();
- cancelDraft();
- } else if (event.key === "Enter") {
- event.preventDefault();
- commitDraft();
- }
- }
-
- function onDraftNoteKeyDown(event: React.KeyboardEvent) {
- if (event.key === "Escape") {
- event.preventDefault();
- cancelDraft();
- }
- // Enter is left alone here - a note is a multi-line field, so Enter
- // should insert a newline, not commit (matching the legacy QTextEdit's
- // own behavior in NavigationPinEditor).
- }
-
- if (state.draft) {
- const isNew = state.draft.isNew;
- const displayError = localDraftError ?? state.error;
- return (
-
-
-
- {isNew ? "Add navigation pin" : "Edit navigation pin"}
-
- {isNew ? "Name this canvas location" : "Update this canvas location"}
-
-
-
-
-
-
- setDraftTitle(event.target.value)}
- onKeyDown={onDraftTitleKeyDown}
- maxLength={MAX_PIN_TITLE_LENGTH}
- placeholder="e.g. Research checkpoint"
- aria-label="Navigation pin title"
- autoComplete="off"
- spellCheck={false}
- />
-
-
-
-
-
-
-
-
-
- );
- }
-
- function onSearchKeyDown(event: React.KeyboardEvent) {
- if (event.key === "Escape") {
- event.preventDefault();
- bridgeRef.current?.close();
- }
- }
-
- const total = state.rows.length;
- const countText =
- total === 0
- ? "No saved locations"
- : filtered.length !== total
- ? `Showing ${filtered.length} of ${total} saved location${total === 1 ? "" : "s"}`
- : `${total} saved location${total === 1 ? "" : "s"}`;
-
- return (
-
-
-
- Navigation pins
- Revisit saved canvas locations
-
-
-
-
- setQuery(event.target.value)}
- onKeyDown={onSearchKeyDown}
- placeholder="Search pins..."
- aria-label="Search navigation pins"
- autoComplete="off"
- spellCheck={false}
- />
-
-
- {filtered.length === 0 && (
- -
- {total === 0 ? "No saved locations yet." : "No pins match your search."}
-
- )}
- {filtered.map((row) => (
- -
-
-
-
-
-
-
- ))}
-
-
-
- {countText}
-
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/pin-overlay/bridge.test.ts b/web_ui/src/islands/pin-overlay/bridge.test.ts
deleted file mode 100644
index aeff3de1..00000000
--- a/web_ui/src/islands/pin-overlay/bridge.test.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createPinOverlayBridge } from "./bridge";
-import { initialPinOverlayState, PinOverlayState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialPinOverlayState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- selectPin: vi.fn(),
- deletePin: vi.fn(),
- createPin: vi.fn(),
- editPin: vi.fn(),
- commitDraft: vi.fn(),
- discardDraft: vi.fn(),
- resize: vi.fn(),
- close: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { pinOverlayBridge: 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("createPinOverlayBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createPinOverlayBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialPinOverlayState);
- });
-
- it("intents on the mock bridge do not throw", () => {
- const bridge = createPinOverlayBridge(() => {});
- expect(() => {
- bridge.selectPin("p1");
- bridge.deletePin("p1");
- bridge.createPin();
- bridge.editPin("p1");
- bridge.commitDraft("t", "n");
- bridge.discardDraft();
- bridge.resize(300);
- bridge.close();
- bridge.dispose();
- }).not.toThrow();
- });
-});
-
-describe("createPinOverlayBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createPinOverlayBridge(() => {});
- 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();
- createPinOverlayBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ revision: 2, rows: [{ id: "p1", title: "A", note: "" }] }));
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ rows: [{ id: "p1", title: "A", note: "" }] }),
- );
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("each intent calls through to the matching remote method with its args", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createPinOverlayBridge(() => {});
-
- bridge.selectPin("p1");
- bridge.deletePin("p2");
- bridge.createPin();
- bridge.editPin("p3");
- bridge.commitDraft("Title", "Note");
- bridge.discardDraft();
- bridge.resize(321);
- bridge.close();
-
- expect(remote.selectPin).toHaveBeenCalledWith("p1");
- expect(remote.deletePin).toHaveBeenCalledWith("p2");
- expect(remote.createPin).toHaveBeenCalledTimes(1);
- expect(remote.editPin).toHaveBeenCalledWith("p3");
- expect(remote.commitDraft).toHaveBeenCalledWith("Title", "Note");
- expect(remote.discardDraft).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();
- createPinOverlayBridge(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 = createPinOverlayBridge(() => {});
- 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/pin-overlay/bridge.ts b/web_ui/src/islands/pin-overlay/bridge.ts
deleted file mode 100644
index ea86b5cc..00000000
--- a/web_ui/src/islands/pin-overlay/bridge.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { PinOverlayState, initialPinOverlayState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { installTextFocusReporting } from "../../lib/bridge-core/textFocus";
-import { validatePinOverlayState } from "../../lib/bridge-core/generated/pin-overlay-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: PinOverlayState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtPinOverlayObject {
- stateChanged: QtSignal;
- ready: () => void;
- selectPin: (id: string) => void;
- deletePin: (id: string) => void;
- createPin: () => void;
- editPin: (id: string) => void;
- commitDraft: (title: string, note: string) => void;
- discardDraft: () => void;
- resize: (height: number) => void;
- close: () => void;
-}
-
-export interface PinOverlayBridge {
- ready(): void;
- selectPin(id: string): void;
- deletePin(id: string): void;
- createPin(): void;
- editPin(id: string): void;
- commitDraft(title: string, note: string): void;
- discardDraft(): void;
- resize(height: number): void;
- close(): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validatePinOverlayState);
-}
-
-class MockPinOverlayBridge implements PinOverlayBridge {
- private readonly state: PinOverlayState = structuredClone(initialPinOverlayState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- selectPin(): void {}
- deletePin(): void {}
- createPin(): void {}
- editPin(): void {}
- commitDraft(): void {}
- discardDraft(): void {}
- resize(): void {}
- close(): void {}
- dispose(): void {}
-}
-
-export function createPinOverlayBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): PinOverlayBridge {
- const fallback = new MockPinOverlayBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtPinOverlayObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[pin-overlay bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.pinOverlayBridge as QtPinOverlayObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- // Real text inputs (search + inline actions) mean this island must
- // participate in the keyboard-arbitration protocol, like command-palette.
- installTextFocusReporting(objects);
- });
-
- const call = (
- method: K,
- ...args: QtPinOverlayObject[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"),
- selectPin: (id) => call("selectPin", id),
- deletePin: (id) => call("deletePin", id),
- createPin: () => call("createPin"),
- editPin: (id) => call("editPin", id),
- commitDraft: (title, note) => call("commitDraft", title, note),
- discardDraft: () => call("discardDraft"),
- resize: (height) => call("resize", height),
- close: () => call("close"),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/pin-overlay/bridgeTypes.ts b/web_ui/src/islands/pin-overlay/bridgeTypes.ts
deleted file mode 100644
index 2f41c6eb..00000000
--- a/web_ui/src/islands/pin-overlay/bridgeTypes.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * The pin-overlay 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 ChatLibraryDialog's own precedent) - Python always sends the
- * full row list. `draft`/`error` are Phase 5 increment 2's async draft-edit
- * fields - see App.tsx for how they drive the in-panel editor view.
- */
-export type { PinOverlayState, PinRow, PinDraft } from "../../lib/bridge-core/generated/pin-overlay-state";
-
-import type { PinOverlayState } from "../../lib/bridge-core/generated/pin-overlay-state";
-
-export const initialPinOverlayState: PinOverlayState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- rows: [],
- selectedPinId: null,
- draft: null,
- error: null,
-};
diff --git a/web_ui/src/islands/pin-overlay/index.html b/web_ui/src/islands/pin-overlay/index.html
deleted file mode 100644
index a9b2de1e..00000000
--- a/web_ui/src/islands/pin-overlay/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Navigation Pins
-
-
-
-
-
-
diff --git a/web_ui/src/islands/pin-overlay/main.tsx b/web_ui/src/islands/pin-overlay/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/pin-overlay/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/pin-overlay/styles.css b/web_ui/src/islands/pin-overlay/styles.css
deleted file mode 100644
index 5b056c95..00000000
--- a/web_ui/src/islands/pin-overlay/styles.css
+++ /dev/null
@@ -1,325 +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 the legacy
- panel's own [MIN_HEIGHT, MAX_HEIGHT] range - see App.tsx). */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.pin-overlay-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;
-}
-
-.pin-overlay-header {
- display: flex;
- align-items: flex-start;
- gap: 12px;
-}
-
-.pin-overlay-heading {
- flex: 1;
- min-width: 0;
-}
-
-.pin-overlay-title {
- margin: 0;
- font-size: 15px;
- font-weight: 700;
-}
-
-.pin-overlay-meta {
- margin: 4px 0 0 0;
- font-size: 11px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.pin-overlay-close-btn {
- flex-shrink: 0;
- padding: 6px 14px;
- 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: 9px;
- cursor: pointer;
-}
-
-.pin-overlay-close-btn:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.pin-overlay-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;
-}
-
-.pin-overlay-search-input:focus {
- border-color: var(--gl-palette-selection);
-}
-
-.pin-overlay-list {
- margin: 0;
- padding: 7px;
- list-style: none;
- max-height: 340px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 2px;
- background-color: var(--gl-graph-node-body-start);
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 11px;
-}
-
-.pin-overlay-empty {
- padding: 12px;
- font-size: 12px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.pin-overlay-row {
- box-sizing: border-box;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 4px;
- border-radius: 10px;
- background-color: var(--gl-neutral-button-background);
-}
-
-.pin-overlay-row:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.pin-overlay-row.selected {
- background-color: var(--gl-palette-selection);
-}
-
-.pin-overlay-row-main {
- flex: 1;
- min-width: 0;
- text-align: left;
- padding: 6px 8px;
- background: transparent;
- border: none;
- color: inherit;
- font: inherit;
- cursor: pointer;
-}
-
-.pin-overlay-row-title {
- margin: 0;
- font-size: 13px;
- font-weight: 600;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.pin-overlay-row-note {
- margin: 2px 0 0 0;
- font-size: 11px;
- color: var(--gl-neutral-button-muted-icon);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.pin-overlay-row.selected .pin-overlay-row-note {
- color: var(--gl-neutral-button-icon);
-}
-
-.pin-overlay-row-actions {
- flex-shrink: 0;
- display: flex;
- gap: 4px;
- padding-right: 4px;
-}
-
-.pin-overlay-row-action {
- padding: 4px 8px;
- font-size: 10px;
- font-weight: 600;
- font-family: inherit;
- color: var(--gl-neutral-button-icon);
- background-color: transparent;
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 6px;
- cursor: pointer;
-}
-
-.pin-overlay-row-action:hover {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.pin-overlay-row-action.danger {
- color: var(--gl-semantic-status-error);
- border-color: var(--gl-semantic-status-error);
-}
-
-.pin-overlay-footer {
- display: flex;
- align-items: center;
- gap: 12px;
- padding: 6px 10px;
- background-color: var(--gl-graph-node-body-start);
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 10px;
-}
-
-.pin-overlay-count {
- flex: 1;
- min-width: 0;
- margin: 0;
- font-size: 11px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.pin-overlay-add-btn {
- flex-shrink: 0;
- padding: 0 16px;
- min-height: 36px;
- font-size: 11px;
- 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: 9px;
- cursor: pointer;
-}
-
-.pin-overlay-add-btn:hover:not(:disabled) {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.pin-overlay-add-btn:disabled {
- opacity: 0.5;
- cursor: default;
-}
-
-/* --- Draft editor view (Phase 5 increment 2) ------------------------- */
-
-.pin-overlay-editor {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.pin-overlay-editor-label {
- margin: 6px 0 0 0;
- font-size: 11px;
- font-weight: 600;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.pin-overlay-editor-label:first-child {
- margin-top: 0;
-}
-
-.pin-overlay-editor-input,
-.pin-overlay-editor-textarea {
- box-sizing: border-box;
- width: 100%;
- padding: 8px 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;
-}
-
-.pin-overlay-editor-textarea {
- min-height: 90px;
- max-height: 160px;
- resize: vertical;
-}
-
-.pin-overlay-editor-input:focus,
-.pin-overlay-editor-textarea:focus {
- border-color: var(--gl-palette-selection);
-}
-
-.pin-overlay-editor-error {
- margin: 4px 0 0 0;
- font-size: 12px;
- color: var(--gl-semantic-status-error);
-}
-
-.pin-overlay-button {
- padding: 6px 14px;
- 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: 9px;
- cursor: pointer;
-}
-
-.pin-overlay-button:hover:not(:disabled) {
- background-color: var(--gl-neutral-button-hover);
-}
-
-.pin-overlay-button:disabled {
- opacity: 0.5;
- cursor: default;
-}
-
-.pin-overlay-button.primary {
- margin-left: auto;
- color: var(--gl-palette-selection);
- border-color: var(--gl-palette-selection);
-}
-
-.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/plugin-picker/App.test.tsx b/web_ui/src/islands/plugin-picker/App.test.tsx
deleted file mode 100644
index 928c5a73..00000000
--- a/web_ui/src/islands/plugin-picker/App.test.tsx
+++ /dev/null
@@ -1,148 +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 { initialPluginPickerState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-const CATEGORIES = [
- {
- name: "Branch Foundations",
- description: "Core branch scaffolding.",
- plugins: [
- { name: "System Prompt", description: "Adds a special node to override the default system prompt." },
- { name: "Conversation Node", description: "Adds a node for a self-contained, linear chat conversation." },
- ],
- },
- {
- name: "Build & Execution",
- description: "Code generation and execution tools.",
- plugins: [{ name: "Py-Coder", description: "Opens a Python execution environment." }],
- },
-];
-
-describe("App against the mock bridge", () => {
- afterEach(cleanup);
-
- it("renders the empty message when there are no categories", () => {
- render( );
-
- expect(screen.getByText("No plugins are available.")).toBeInTheDocument();
- });
-});
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- executePlugin: vi.fn(),
- resize: vi.fn(),
- close: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { pluginPickerBridge: 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({ ...initialPluginPickerState, revision: 1, ...overrides }));
-}
-
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- it("defaults to the first category and renders its plugins", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote, { categories: CATEGORIES });
-
- expect(await screen.findByText("System Prompt")).toBeInTheDocument();
- expect(screen.getByText("Conversation Node")).toBeInTheDocument();
- expect(screen.queryByText("Py-Coder")).not.toBeInTheDocument();
- expect(screen.getByText("Branch Foundations", { selector: "p" })).toBeInTheDocument();
- expect(screen.getByText("2 plugins")).toBeInTheDocument();
- });
-
- it("clicking a category switches the visible plugin list", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote, { categories: CATEGORIES });
- await screen.findByText("System Prompt");
-
- await user.click(screen.getByRole("button", { name: "Build & Execution" }));
-
- expect(await screen.findByText("Py-Coder")).toBeInTheDocument();
- expect(screen.queryByText("System Prompt")).not.toBeInTheDocument();
- expect(screen.getByText("1 plugin")).toBeInTheDocument();
- });
-
- it("clicking a plugin row calls executePlugin with its name", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote, { categories: CATEGORIES });
- await screen.findByText("Conversation Node");
-
- await user.click(screen.getByText("Conversation Node"));
-
- expect(remote.executePlugin).toHaveBeenCalledWith("Conversation Node");
- });
-
- it("falls back to the first category if the remembered one disappears from a later publish", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote, { categories: CATEGORIES });
- await screen.findByText("System Prompt");
- await user.click(screen.getByRole("button", { name: "Build & Execution" }));
- await screen.findByText("Py-Coder");
-
- push(remote, { revision: 2, categories: [CATEGORIES[0]] });
-
- expect(await screen.findByText("System Prompt")).toBeInTheDocument();
- });
-
- it("Escape anywhere in the surface calls close", async () => {
- const remote = installFakeQWebChannel();
- render( );
- push(remote, { categories: CATEGORIES });
- await screen.findByText("System Prompt");
-
- 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({ ...initialPluginPickerState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("The plugin picker is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/plugin-picker/App.tsx b/web_ui/src/islands/plugin-picker/App.tsx
deleted file mode 100644
index 251fb032..00000000
--- a/web_ui/src/islands/plugin-picker/App.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { PluginPickerState, initialPluginPickerState } from "./bridgeTypes";
-import { BridgeRejection, PluginPickerBridge, createPluginPickerBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-function App() {
- const [state, setState] = useState(initialPluginPickerState);
- const [rejection, setRejection] = useState(null);
- const [currentCategoryName, setCurrentCategoryName] = useState(null);
- const bridgeRef = useRef(null);
- const shellRef = useRef(null);
-
- useEffect(() => {
- const bridge = createPluginPickerBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- // Mirrors PluginFlyoutPanel._build_category_buttons()'s own fallback: keep
- // whichever category is already selected as long as it still exists,
- // otherwise fall back to the first category. Computed during render
- // (the same "reset local state when a fresh X begins" pattern
- // composer-picker's own openToken reset uses), not in an effect - Categories
- // are static app-lifetime data, so this only ever really applies once, on
- // the first - and only - state publish.
- if (state.categories.length > 0 && !state.categories.some((category) => category.name === currentCategoryName)) {
- setCurrentCategoryName(state.categories[0].name);
- }
-
- // Content-driven height negotiation, matching every prior picker host'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 Qt.WindowType.Popup dismiss-on-focus-loss behavior for the
- // keyboard case - 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 activeCategory =
- state.categories.find((category) => category.name === currentCategoryName) ?? state.categories[0] ?? null;
-
- return (
-
-
- Categories
-
- {state.categories.map((category) => (
-
- ))}
-
-
-
-
- {activeCategory ? (
- <>
-
- {activeCategory.name}
-
- {activeCategory.plugins.length} plugin{activeCategory.plugins.length !== 1 ? "s" : ""}
-
-
-
- {activeCategory.plugins.map((plugin) => (
- -
-
-
- ))}
-
- >
- ) : (
- No plugins are available.
- )}
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/plugin-picker/bridge.test.ts b/web_ui/src/islands/plugin-picker/bridge.test.ts
deleted file mode 100644
index 877430b1..00000000
--- a/web_ui/src/islands/plugin-picker/bridge.test.ts
+++ /dev/null
@@ -1,149 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createPluginPickerBridge } from "./bridge";
-import { initialPluginPickerState, PluginPickerState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialPluginPickerState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- executePlugin: vi.fn(),
- resize: vi.fn(),
- close: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { pluginPickerBridge: 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("createPluginPickerBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createPluginPickerBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialPluginPickerState);
- });
-
- it("intents on the mock bridge do not throw", () => {
- const bridge = createPluginPickerBridge(() => {});
- expect(() => {
- bridge.executePlugin("Py-Coder");
- bridge.resize(300);
- bridge.close();
- bridge.dispose();
- }).not.toThrow();
- });
-});
-
-describe("createPluginPickerBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createPluginPickerBridge(() => {});
- expect(remote.ready).toHaveBeenCalledTimes(1);
- expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("forwards categories pushed through stateChanged to the listener", () => {
- const remote = installFakeQWebChannel();
- try {
- const listener = vi.fn();
- createPluginPickerBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(
- stateJson({
- revision: 2,
- categories: [
- {
- name: "Build & Execution",
- description: "Code generation and execution tools.",
- plugins: [{ name: "Py-Coder", description: "Run Python." }],
- },
- ],
- }),
- );
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({
- categories: [expect.objectContaining({ name: "Build & Execution" })],
- }),
- );
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("each intent calls through to the matching remote method with its args", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createPluginPickerBridge(() => {});
-
- bridge.executePlugin("Gitlink");
- bridge.resize(321);
- bridge.close();
-
- expect(remote.executePlugin).toHaveBeenCalledWith("Gitlink");
- 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();
- createPluginPickerBridge(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 = createPluginPickerBridge(() => {});
- 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/plugin-picker/bridge.ts b/web_ui/src/islands/plugin-picker/bridge.ts
deleted file mode 100644
index 7d69f2bd..00000000
--- a/web_ui/src/islands/plugin-picker/bridge.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { PluginPickerState, initialPluginPickerState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validatePluginPickerState } from "../../lib/bridge-core/generated/plugin-picker-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: PluginPickerState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtPluginPickerObject {
- stateChanged: QtSignal;
- ready: () => void;
- executePlugin: (pluginName: string) => void;
- resize: (height: number) => void;
- close: () => void;
-}
-
-export interface PluginPickerBridge {
- ready(): void;
- executePlugin(pluginName: string): void;
- resize(height: number): void;
- close(): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validatePluginPickerState);
-}
-
-class MockPluginPickerBridge implements PluginPickerBridge {
- private readonly state: PluginPickerState = structuredClone(initialPluginPickerState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- executePlugin(): void {}
- resize(): void {}
- close(): void {}
- dispose(): void {}
-}
-
-export function createPluginPickerBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): PluginPickerBridge {
- const fallback = new MockPluginPickerBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtPluginPickerObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[plugin-picker bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.pluginPickerBridge as QtPluginPickerObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- const call = (
- method: K,
- ...args: QtPluginPickerObject[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"),
- executePlugin: (pluginName) => call("executePlugin", pluginName),
- resize: (height) => call("resize", height),
- close: () => call("close"),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/plugin-picker/bridgeTypes.ts b/web_ui/src/islands/plugin-picker/bridgeTypes.ts
deleted file mode 100644
index 5fa8eb24..00000000
--- a/web_ui/src/islands/plugin-picker/bridgeTypes.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * The plugin-picker island's state contract.
- *
- * Categories are static app-lifetime data (see graphlink_plugin_picker_bridge.py's
- * own docstring) - there is no per-open reset token like composer-picker's
- * `openToken`, because there is nothing that goes stale between opens.
- * Icons are deliberately absent from the wire contract (the same
- * "icons dropped" precedent as About/Help - qtawesome icon-name strings
- * don't resolve to anything in a web island without a new dependency).
- */
-export type { PluginPickerState, PluginCategory, PluginEntry } from "../../lib/bridge-core/generated/plugin-picker-state";
-
-import type { PluginPickerState } from "../../lib/bridge-core/generated/plugin-picker-state";
-
-export const initialPluginPickerState: PluginPickerState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- categories: [],
-};
diff --git a/web_ui/src/islands/plugin-picker/index.html b/web_ui/src/islands/plugin-picker/index.html
deleted file mode 100644
index e2ef42aa..00000000
--- a/web_ui/src/islands/plugin-picker/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Plugin Picker
-
-
-
-
-
-
diff --git a/web_ui/src/islands/plugin-picker/main.tsx b/web_ui/src/islands/plugin-picker/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/plugin-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/plugin-picker/styles.css b/web_ui/src/islands/plugin-picker/styles.css
deleted file mode 100644
index 8860511e..00000000
--- a/web_ui/src/islands/plugin-picker/styles.css
+++ /dev/null
@@ -1,209 +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
- [PLUGIN_PICKER_MIN_HEIGHT, PLUGIN_PICKER_MAX_HEIGHT] - see App.tsx). Width
- is fixed by PluginPickerHost itself (PLUGIN_PICKER_WIDTH), matching the
- legacy PluginFlyoutPanel.BASE_WIDTH. */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.plugin-picker-shell {
- box-sizing: border-box;
- /* 100% of the host viewport, not a fixed 520px: the host is exactly
- 520 wide, and at fractional DPI scaling a fixed 520 can overflow
- the rounded-down CSS viewport by a sub-pixel, summoning a
- horizontal overlay scrollbar across the popup bottom. */
- width: 100%;
- max-width: 520px;
- display: flex;
- flex-direction: row;
- gap: 0;
- padding: 8px;
- 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;
-}
-
-.plugin-picker-rail {
- box-sizing: border-box;
- width: 190px;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 10px;
- border-right: 1px solid var(--gl-neutral-button-border);
-}
-
-.plugin-picker-rail-label {
- margin: 0 0 4px 4px;
- font-size: 10px;
- font-weight: 700;
- letter-spacing: 0.12em;
- text-transform: uppercase;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.plugin-picker-rail-buttons {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.plugin-picker-category-btn {
- box-sizing: border-box;
- width: 100%;
- padding: 8px 10px;
- min-height: 34px;
- text-align: left;
- font: inherit;
- font-size: 12px;
- font-weight: 650;
- color: var(--gl-neutral-button-muted-icon);
- background-color: transparent;
- border: 1px solid transparent;
- border-radius: 7px;
- cursor: pointer;
-}
-
-.plugin-picker-category-btn:hover {
- background-color: var(--gl-neutral-button-hover);
- color: var(--gl-neutral-button-icon);
-}
-
-.plugin-picker-category-btn.active {
- background-color: var(--gl-neutral-button-hover);
- border-color: var(--gl-neutral-button-border);
- color: var(--gl-neutral-button-icon);
-}
-
-.plugin-picker-content {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 10px 12px;
-}
-
-.plugin-picker-header {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 8px;
-}
-
-.plugin-picker-title {
- margin: 0;
- font-size: 13px;
- font-weight: 700;
-}
-
-.plugin-picker-meta {
- margin: 0;
- font-size: 10px;
- color: var(--gl-neutral-button-muted-icon);
- white-space: nowrap;
-}
-
-.plugin-picker-list {
- margin: 0;
- padding: 0;
- list-style: none;
- max-height: 300px;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.plugin-picker-empty {
- margin: 0;
- padding: 12px 8px;
- font-size: 11px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.plugin-picker-row {
- box-sizing: border-box;
- width: 100%;
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 8px 10px;
- min-height: 50px;
- text-align: left;
- background: transparent;
- border: 1px solid transparent;
- border-radius: 8px;
- color: inherit;
- font: inherit;
- cursor: pointer;
-}
-
-.plugin-picker-row:hover {
- background-color: var(--gl-neutral-button-hover);
- border-color: var(--gl-neutral-button-border);
-}
-
-.plugin-picker-row-copy {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 3px;
-}
-
-.plugin-picker-row-label {
- font-size: 12px;
- font-weight: 650;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.plugin-picker-row-description {
- font-size: 10px;
- color: var(--gl-neutral-button-muted-icon);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-.plugin-picker-row-chevron {
- flex-shrink: 0;
- font-size: 16px;
- 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/search-overlay/App.test.tsx b/web_ui/src/islands/search-overlay/App.test.tsx
deleted file mode 100644
index 64148792..00000000
--- a/web_ui/src/islands/search-overlay/App.test.tsx
+++ /dev/null
@@ -1,146 +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 { initialSearchOverlayState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-describe("App against the mock bridge", () => {
- afterEach(cleanup);
-
- it("renders 0 / 0 before any search", () => {
- render( );
-
- expect(screen.getByText("0 / 0")).toBeInTheDocument();
- });
-
- it("typing does not throw against the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.type(screen.getByRole("textbox", { name: "Search the canvas" }), "hi");
- });
-});
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- search: vi.fn(),
- next: vi.fn(),
- previous: vi.fn(),
- close: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { searchOverlayBridge: 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({ ...initialSearchOverlayState, revision: 1, ...overrides }));
-}
-
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- it("typing calls bridge.search with the raw text", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
-
- await user.type(screen.getByRole("textbox", { name: "Search the canvas" }), "hi");
-
- expect(remote.search).toHaveBeenLastCalledWith("hi");
- });
-
- it("renders '1 / 3' with the active tone once a match is focused", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote, { currentIndex: 0, totalMatches: 3 });
-
- const count = await screen.findByText("1 / 3");
- expect(count).toHaveAttribute("data-tone", "active");
- });
-
- it("renders '0 / 3' with the idle tone right after a fresh search, before navigating", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote, { currentIndex: -1, totalMatches: 3 });
-
- const count = await screen.findByText("0 / 3");
- expect(count).toHaveAttribute("data-tone", "idle");
- });
-
- it("renders '0 / 0' with the error tone when there are no matches", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- push(remote, { currentIndex: -1, totalMatches: 0 });
-
- const count = await screen.findByText("0 / 0");
- expect(count).toHaveAttribute("data-tone", "error");
- });
-
- it("Enter calls next(), Shift+Enter calls previous(), Escape calls close()", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- const input = screen.getByRole("textbox", { name: "Search the canvas" });
-
- await user.click(input);
- await user.keyboard("{Enter}");
- expect(remote.next).toHaveBeenCalledTimes(1);
-
- await user.keyboard("{Shift>}{Enter}{/Shift}");
- expect(remote.previous).toHaveBeenCalledTimes(1);
-
- await user.keyboard("{Escape}");
- expect(remote.close).toHaveBeenCalledTimes(1);
- });
-
- it("the prev/next/close buttons call through to the remote", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: "Previous match (Shift+Enter)" }));
- await user.click(screen.getByRole("button", { name: "Next match (Enter)" }));
- await user.click(screen.getByRole("button", { name: "Close (Esc)" }));
-
- expect(remote.previous).toHaveBeenCalledTimes(1);
- expect(remote.next).toHaveBeenCalledTimes(1);
- 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({ ...initialSearchOverlayState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("Search is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/search-overlay/App.tsx b/web_ui/src/islands/search-overlay/App.tsx
deleted file mode 100644
index 8cb40653..00000000
--- a/web_ui/src/islands/search-overlay/App.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { BridgeRejection, SearchOverlayBridge, createSearchOverlayBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-function App() {
- const [rejection, setRejection] = useState(null);
- const [currentIndex, setCurrentIndex] = useState(-1);
- const [totalMatches, setTotalMatches] = useState(0);
- const bridgeRef = useRef(null);
- const inputRef = useRef(null);
-
- useEffect(() => {
- const bridge = createSearchOverlayBridge(
- (state) => {
- setCurrentIndex(state.currentIndex);
- setTotalMatches(state.totalMatches);
- },
- setRejection,
- );
- bridgeRef.current = bridge;
- bridge.ready();
- inputRef.current?.focus();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- function onChange(event: React.ChangeEvent) {
- bridgeRef.current?.search(event.target.value);
- }
-
- function onKeyDown(event: React.KeyboardEvent) {
- if (event.key === "Enter" && event.shiftKey) {
- event.preventDefault();
- bridgeRef.current?.previous();
- } else if (event.key === "Enter") {
- event.preventDefault();
- bridgeRef.current?.next();
- } else if (event.key === "Escape") {
- event.preventDefault();
- bridgeRef.current?.close();
- }
- }
-
- // Matches the legacy SearchOverlay.update_results_label's exact 3-way
- // branch: "current" is 1-based once a match is focused, 0 right after a
- // fresh search - (currentIndex + 1) reproduces that with currentIndex's
- // own -1 "no current match" sentinel folding to 0 automatically.
- const current = currentIndex + 1;
- const tone = totalMatches === 0 ? "error" : current > 0 ? "active" : "idle";
-
- return (
-
-
-
- {current} / {totalMatches}
-
-
-
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/search-overlay/bridge.test.ts b/web_ui/src/islands/search-overlay/bridge.test.ts
deleted file mode 100644
index ed50fd17..00000000
--- a/web_ui/src/islands/search-overlay/bridge.test.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createSearchOverlayBridge } from "./bridge";
-import { initialSearchOverlayState, SearchOverlayState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialSearchOverlayState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- search: vi.fn(),
- next: vi.fn(),
- previous: vi.fn(),
- close: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { searchOverlayBridge: 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("createSearchOverlayBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createSearchOverlayBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialSearchOverlayState);
- });
-
- it("intents on the mock bridge do not throw", () => {
- const bridge = createSearchOverlayBridge(() => {});
- expect(() => {
- bridge.search("x");
- bridge.next();
- bridge.previous();
- bridge.close();
- bridge.dispose();
- }).not.toThrow();
- });
-});
-
-describe("createSearchOverlayBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createSearchOverlayBridge(() => {});
- expect(remote.ready).toHaveBeenCalledTimes(1);
- expect(remote.stateChanged.connect).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("forwards match counts pushed through stateChanged to the listener", () => {
- const remote = installFakeQWebChannel();
- try {
- const listener = vi.fn();
- createSearchOverlayBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ revision: 2, currentIndex: 1, totalMatches: 3 }));
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ currentIndex: 1, totalMatches: 3 }),
- );
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("each intent calls through to the matching remote method", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createSearchOverlayBridge(() => {});
-
- bridge.search("hello");
- bridge.next();
- bridge.previous();
- bridge.close();
-
- expect(remote.search).toHaveBeenCalledWith("hello");
- expect(remote.next).toHaveBeenCalledTimes(1);
- expect(remote.previous).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();
- createSearchOverlayBridge(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 = createSearchOverlayBridge(() => {});
- 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/search-overlay/bridge.ts b/web_ui/src/islands/search-overlay/bridge.ts
deleted file mode 100644
index 9b7fdb56..00000000
--- a/web_ui/src/islands/search-overlay/bridge.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { SearchOverlayState, initialSearchOverlayState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { installTextFocusReporting } from "../../lib/bridge-core/textFocus";
-import { validateSearchOverlayState } from "../../lib/bridge-core/generated/search-overlay-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: SearchOverlayState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtSearchOverlayObject {
- stateChanged: QtSignal;
- ready: () => void;
- search: (text: string) => void;
- next: () => void;
- previous: () => void;
- close: () => void;
-}
-
-export interface SearchOverlayBridge {
- ready(): void;
- search(text: string): void;
- next(): void;
- previous(): void;
- close(): void;
- dispose(): void;
-}
-
-function parseState(payload: string) {
- return parseIslandState(payload, validateSearchOverlayState);
-}
-
-class MockSearchOverlayBridge implements SearchOverlayBridge {
- private readonly state: SearchOverlayState = structuredClone(initialSearchOverlayState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- search(): void {}
- next(): void {}
- previous(): void {}
- close(): void {}
- dispose(): void {}
-}
-
-export function createSearchOverlayBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): SearchOverlayBridge {
- const fallback = new MockSearchOverlayBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtSearchOverlayObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[search-overlay bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.searchOverlayBridge as QtSearchOverlayObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- // A real text input (the search box) means this island must
- // participate in the keyboard-arbitration protocol, like command-palette.
- installTextFocusReporting(objects);
- });
-
- const call = (
- method: K,
- ...args: QtSearchOverlayObject[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"),
- search: (text) => call("search", text),
- next: () => call("next"),
- previous: () => call("previous"),
- close: () => call("close"),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/search-overlay/bridgeTypes.ts b/web_ui/src/islands/search-overlay/bridgeTypes.ts
deleted file mode 100644
index 61cc4751..00000000
--- a/web_ui/src/islands/search-overlay/bridgeTypes.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * The search-overlay island's state contract.
- *
- * See composer/bridgeTypes.ts for the fuller rationale (re-export from the
- * generated file, not a hand mirror). The query text itself is NOT part of
- * this state - it's pure client-side React state (an uncontrolled input),
- * see App.tsx.
- */
-export type { SearchOverlayState } from "../../lib/bridge-core/generated/search-overlay-state";
-
-import type { SearchOverlayState } from "../../lib/bridge-core/generated/search-overlay-state";
-
-export const initialSearchOverlayState: SearchOverlayState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- currentIndex: -1,
- totalMatches: 0,
-};
diff --git a/web_ui/src/islands/search-overlay/index.html b/web_ui/src/islands/search-overlay/index.html
deleted file mode 100644
index 63216043..00000000
--- a/web_ui/src/islands/search-overlay/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Search
-
-
-
-
-
-
diff --git a/web_ui/src/islands/search-overlay/main.tsx b/web_ui/src/islands/search-overlay/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/search-overlay/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/search-overlay/styles.css b/web_ui/src/islands/search-overlay/styles.css
deleted file mode 100644
index b91fcc75..00000000
--- a/web_ui/src/islands/search-overlay/styles.css
+++ /dev/null
@@ -1,108 +0,0 @@
-/* Colors reuse EXISTING app-wide --gl-* tokens, same convention every
- island since token-counter follows. Compact toolbar-like bar (300x44,
- matching the legacy SearchOverlay's fixed size exactly) - a corner
- widget, not a floating card, hence the smaller corner radius than
- every other island's default. */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- height: 100%;
- background: transparent;
-}
-
-.search-overlay-shell {
- box-sizing: border-box;
- width: 300px;
- height: 44px;
- display: flex;
- align-items: center;
- gap: 5px;
- padding: 5px;
- 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: 5px;
- overflow: hidden;
-}
-
-.search-overlay-input {
- box-sizing: border-box;
- flex: 1;
- min-width: 0;
- height: 24px;
- padding: 0 6px;
- font-size: 12px;
- 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: 3px;
- outline: none;
-}
-
-.search-overlay-input:focus {
- border-color: var(--gl-palette-selection);
-}
-
-.search-overlay-count {
- flex-shrink: 0;
- font-size: 11px;
- white-space: nowrap;
-}
-
-.search-overlay-count[data-tone="active"] {
- color: var(--gl-neutral-button-icon);
-}
-
-.search-overlay-count[data-tone="idle"] {
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.search-overlay-count[data-tone="error"] {
- color: var(--gl-semantic-status-error);
-}
-
-.search-overlay-icon-btn {
- flex-shrink: 0;
- width: 24px;
- height: 24px;
- padding: 0;
- font-size: 11px;
- line-height: 1;
- color: var(--gl-neutral-button-icon);
- background-color: transparent;
- border: none;
- border-radius: 3px;
- cursor: pointer;
-}
-
-.search-overlay-icon-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/settings/App.test.tsx b/web_ui/src/islands/settings/App.test.tsx
deleted file mode 100644
index 2b7351cf..00000000
--- a/web_ui/src/islands/settings/App.test.tsx
+++ /dev/null
@@ -1,596 +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 { initialSettingsState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-// jsdom has no window.QWebChannel, so createSettingsBridge() 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 all 5 rail sections with General active", () => {
- render( );
-
- expect(screen.getByRole("button", { name: "General" })).toHaveAttribute("aria-current", "page");
- expect(screen.getByRole("button", { name: "Ollama (Local)" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Llama.cpp (Local)" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "API Endpoint" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Integrations" })).toBeInTheDocument();
- });
-
- it("clicking a rail button navigates to that section", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: "Integrations" }));
-
- expect(screen.getByRole("button", { name: "Integrations" })).toHaveAttribute("aria-current", "page");
- expect(screen.getByRole("button", { name: "General" })).not.toHaveAttribute("aria-current");
- expect(screen.getByRole("region", { name: "Integrations" })).toBeInTheDocument();
- });
-
- it("General renders the theme select and all 4 notification checkboxes, all reflecting initial state", () => {
- render( );
-
- expect(screen.getByLabelText("Theme")).toHaveValue("dark");
- expect(screen.getByRole("checkbox", { name: "Show Token Counter Overlay" })).toBeChecked();
- expect(screen.getByRole("checkbox", { name: "Enable Assistant System Prompt" })).toBeChecked();
- expect(screen.getByRole("checkbox", { name: "Info" })).toBeChecked();
- expect(screen.getByRole("checkbox", { name: "Success" })).toBeChecked();
- expect(screen.getByRole("checkbox", { name: "Warning" })).toBeChecked();
- expect(screen.getByRole("checkbox", { name: "Error" })).toBeChecked();
- expect(screen.getByRole("checkbox", { name: "Enable Update Notifications on Startup" })).not.toBeChecked();
- expect(screen.getByText("Automatic update checks are off.")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Check for Updates" })).toBeEnabled();
- });
-
- it("Check for Updates reports a finished check via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: "Check for Updates" }));
-
- await waitFor(() => expect(screen.getByText(/You're up to date\./)).toBeInTheDocument());
- });
-
- it("Open Repository does not throw via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: "Open Repository" }));
- });
-
- it("changing the theme select updates state via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.selectOptions(screen.getByLabelText("Theme"), "muted");
-
- expect(screen.getByLabelText("Theme")).toHaveValue("muted");
- });
-
- it("unchecking a notification type updates only that type", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("checkbox", { name: "Warning" }));
-
- expect(screen.getByRole("checkbox", { name: "Warning" })).not.toBeChecked();
- expect(screen.getByRole("checkbox", { name: "Info" })).toBeChecked();
- });
-
- it("Integrations renders an empty, write-only token field and the not-configured status", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Integrations" }));
-
- expect(screen.getByLabelText("GitHub Personal Access Token")).toHaveValue("");
- expect(screen.getByLabelText("GitHub Personal Access Token")).toHaveAttribute("type", "password");
- expect(screen.getByText("No GitHub token configured.")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Save Integrations" })).toBeDisabled();
- });
-
- it("typing a token enables Save, and saving clears the draft field via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Integrations" }));
-
- await user.type(screen.getByLabelText("GitHub Personal Access Token"), "ghp_typed");
- expect(screen.getByRole("button", { name: "Save Integrations" })).toBeEnabled();
-
- await user.click(screen.getByRole("button", { name: "Save Integrations" }));
-
- await waitFor(() => expect(screen.getByText("A GitHub token is currently configured.")).toBeInTheDocument());
- expect(screen.getByLabelText("GitHub Personal Access Token")).toHaveValue("");
- });
-
- it("Clear Token resets the status to not-configured via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Integrations" }));
- await user.type(screen.getByLabelText("GitHub Personal Access Token"), "ghp_typed");
- await user.click(screen.getByRole("button", { name: "Save Integrations" }));
- await waitFor(() => expect(screen.getByText("A GitHub token is currently configured.")).toBeInTheDocument());
-
- await user.click(screen.getByRole("button", { name: "Clear Token" }));
-
- await waitFor(() => expect(screen.getByText("No GitHub token configured.")).toBeInTheDocument());
- });
-
- it("API Endpoint defaults to OpenAI-Compatible with the Base URL field visible", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "API Endpoint" }));
-
- expect(screen.getByLabelText("API Provider")).toHaveValue("OpenAI-Compatible");
- expect(screen.getByLabelText("Base URL")).toBeInTheDocument();
- expect(screen.getByText("No key configured for this provider.")).toBeInTheDocument();
- expect(screen.getByLabelText("Image Generation")).toBeInTheDocument();
- });
-
- it("switching provider to Anthropic hides Base URL, keeps the Load button (live catalog fetch), and excludes image gen", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "API Endpoint" }));
-
- await user.selectOptions(screen.getByLabelText("API Provider"), "Anthropic Claude");
-
- expect(screen.queryByLabelText("Base URL")).not.toBeInTheDocument();
- // Anthropic performs a real live catalog fetch, so the Load button must
- // be present (matches legacy load_btn visible for OpenAI or Anthropic).
- expect(screen.getByRole("button", { name: "Load Available Models" })).toBeInTheDocument();
- expect(screen.queryByLabelText("Image Generation")).not.toBeInTheDocument();
- expect(screen.getByText("Anthropic Claude does not support image generation in Graphlink yet.")).toBeInTheDocument();
- });
-
- it("switching provider to Gemini hides both Base URL and the Load button (Gemini uses static lists)", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "API Endpoint" }));
-
- await user.selectOptions(screen.getByLabelText("API Provider"), "Google Gemini");
-
- expect(screen.queryByLabelText("Base URL")).not.toBeInTheDocument();
- // Gemini has no live catalog to fetch, so no Load button (matches legacy).
- expect(screen.queryByRole("button", { name: "Load Available Models" })).not.toBeInTheDocument();
- });
-
- it("Load Available Models is disabled until a key is typed", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "API Endpoint" }));
-
- expect(screen.getByRole("button", { name: "Load Available Models" })).toBeDisabled();
-
- await user.type(screen.getByLabelText("API Key"), "sk-typed");
-
- expect(screen.getByRole("button", { name: "Load Available Models" })).toBeEnabled();
- });
-
- it("Save Configuration reports configured and clears the key field via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "API Endpoint" }));
-
- await user.type(screen.getByLabelText("API Key"), "sk-typed");
- await user.click(screen.getByRole("button", { name: "Save Configuration" }));
-
- await waitFor(() =>
- expect(screen.getByText("A key is currently configured for this provider.")).toBeInTheDocument(),
- );
- expect(screen.getByLabelText("API Key")).toHaveValue("");
- });
-
- it("Reset API Settings requires a confirmation step before clearing", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "API Endpoint" }));
- await user.type(screen.getByLabelText("API Key"), "sk-typed");
- await user.click(screen.getByRole("button", { name: "Save Configuration" }));
- await waitFor(() =>
- expect(screen.getByText("A key is currently configured for this provider.")).toBeInTheDocument(),
- );
-
- // First click only arms the confirmation - nothing is cleared yet.
- await user.click(screen.getByRole("button", { name: "Reset API Settings" }));
- expect(screen.getByText(/cannot be undone/)).toBeInTheDocument();
- expect(screen.getByText("A key is currently configured for this provider.")).toBeInTheDocument();
-
- // Confirm actually clears.
- await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
- await waitFor(() =>
- expect(screen.getByText("No key configured for this provider.")).toBeInTheDocument(),
- );
- });
-
- it("Cancelling the reset confirmation leaves the configuration intact", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "API Endpoint" }));
- await user.type(screen.getByLabelText("API Key"), "sk-typed");
- await user.click(screen.getByRole("button", { name: "Save Configuration" }));
- await waitFor(() =>
- expect(screen.getByText("A key is currently configured for this provider.")).toBeInTheDocument(),
- );
-
- await user.click(screen.getByRole("button", { name: "Reset API Settings" }));
- await user.click(screen.getByRole("button", { name: "Cancel" }));
-
- expect(screen.queryByText(/cannot be undone/)).not.toBeInTheDocument();
- expect(screen.getByText("A key is currently configured for this provider.")).toBeInTheDocument();
- });
-
- it("Gemini's Image Generation field points at the curated image-model datalist", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "API Endpoint" }));
-
- // OpenAI (default): image field uses the shared datalist.
- expect(screen.getByLabelText("Image Generation")).toHaveAttribute("list", "settings-api-available-models");
-
- await user.selectOptions(screen.getByLabelText("API Provider"), "Google Gemini");
-
- // Gemini: image field switches to the separate curated image datalist,
- // and that datalist carries the Gemini image models (not chat models).
- expect(screen.getByLabelText("Image Generation")).toHaveAttribute("list", "settings-api-image-models");
- const imageDatalist = document.getElementById("settings-api-image-models");
- expect(imageDatalist?.querySelector('option[value="gemini-2.5-flash-image"]')).not.toBeNull();
- expect(imageDatalist?.querySelector('option[value="gemini-2.5-flash"]')).toBeNull();
- });
-
- it("LlamaCpp scanned-model dropdown appears after a scan and staging a pick updates the model path", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" }));
-
- // No scan yet -> no scanned-model dropdown.
- expect(screen.queryByLabelText("Scanned Chat Model")).not.toBeInTheDocument();
-
- await user.click(screen.getByRole("button", { name: "System Scan" }));
-
- // The scan published models, so the dropdown now exists and is selectable.
- const scannedSelect = await screen.findByLabelText("Scanned Chat Model");
- await user.selectOptions(scannedSelect, "/models/chat.gguf");
-
- await waitFor(() => expect(screen.getByText("/models/chat.gguf")).toBeInTheDocument());
- });
-
- it("Ollama renders the reasoning mode radios and all 5 task fields defaulting to auto/inherit", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Ollama (Local)" }));
-
- expect(screen.getByRole("radio", { name: "Thinking Mode (Enable CoT)" })).toBeChecked();
- expect(screen.getByLabelText("Chat Model")).toHaveValue("auto");
- expect(screen.getByLabelText("Chat Naming Model")).toHaveValue("inherit");
- });
-
- it("switching reasoning mode calls through to the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Ollama (Local)" }));
-
- await user.click(screen.getByRole("radio", { name: "Quick Mode (No CoT)" }));
-
- expect(screen.getByRole("radio", { name: "Quick Mode (No CoT)" })).toBeChecked();
- });
-
- it("choosing Custom for a task field reveals a text input, and typing sets an explicit assignment", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Ollama (Local)" }));
-
- await user.selectOptions(screen.getByLabelText("Chart Generation Model"), "explicit");
- const chartInput = screen.getByLabelText("Chart Generation Model (custom model ID)");
-
- await user.type(chartInput, "llama3:8b");
-
- expect(chartInput).toHaveValue("llama3:8b");
- });
-
- it("System Scan updates the scan summary and scanned models via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Ollama (Local)" }));
-
- await user.click(screen.getByRole("button", { name: "System Scan" }));
-
- await waitFor(() =>
- expect(screen.getByText("Using saved system scan results from local Ollama locations.")).toBeInTheDocument(),
- );
- });
-
- it("Validate and Pull Model is disabled until a model name is typed", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Ollama (Local)" }));
-
- expect(screen.getByRole("button", { name: "Validate and Pull Model" })).toBeDisabled();
-
- await user.type(screen.getByLabelText("Validate and Pull Model"), "llama3:8b");
-
- expect(screen.getByRole("button", { name: "Validate and Pull Model" })).toBeEnabled();
- });
-
- it("LlamaCpp renders the reasoning mode radios and the staged-path placeholders", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" }));
-
- expect(screen.getByRole("radio", { name: "Thinking Mode (Enable CoT)" })).toBeChecked();
- expect(screen.getByText("No file selected")).toBeInTheDocument();
- expect(screen.getByText("Reusing the main chat model")).toBeInTheDocument();
- expect(screen.getByText("No model selected")).toBeInTheDocument();
- });
-
- it("switching LlamaCpp reasoning mode updates the checked radio via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" }));
-
- await user.click(screen.getByRole("radio", { name: "Quick Mode (No CoT)" }));
-
- expect(screen.getByRole("radio", { name: "Quick Mode (No CoT)" })).toBeChecked();
- });
-
- it("typing a Chat Format Override updates the field via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" }));
-
- await user.type(screen.getByLabelText("Chat Format Override"), "chatml");
-
- expect(screen.getByLabelText("Chat Format Override")).toHaveValue("chatml");
- });
-
- it("LlamaCpp System Scan updates the scan summary via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" }));
-
- await user.click(screen.getByRole("button", { name: "System Scan" }));
-
- await waitFor(() =>
- expect(screen.getByText("Using saved system scan results from common local model folders.")).toBeInTheDocument(),
- );
- });
-
- it("Save Settings on LlamaCpp with no staged path reports the empty-path notice via the mock bridge", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByRole("button", { name: "Llama.cpp (Local)" }));
-
- await user.click(screen.getByRole("button", { name: "Save Settings" }));
-
- await waitFor(() => expect(screen.getByText("Chat Model File cannot be empty.")).toBeInTheDocument());
- });
-});
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- setActiveSection: vi.fn(),
- setTheme: vi.fn(),
- setShowTokenCounter: vi.fn(),
- setEnableSystemPrompt: vi.fn(),
- setNotificationPreference: vi.fn(),
- setUpdateNotificationsEnabled: vi.fn(),
- checkForUpdates: vi.fn(),
- openRepository: vi.fn(),
- setGithubToken: vi.fn(),
- clearGithubToken: vi.fn(),
- setApiProvider: vi.fn(),
- saveApiConfiguration: vi.fn(),
- loadAvailableModels: vi.fn(),
- resetApiSettings: vi.fn(),
- setOllamaReasoningMode: vi.fn(),
- setOllamaModelAssignment: vi.fn(),
- scanOllamaSystem: vi.fn(),
- pickOllamaScanFolder: vi.fn(),
- pullOllamaModel: vi.fn(),
- setLlamaCppReasoningMode: vi.fn(),
- setLlamaCppChatFormat: vi.fn(),
- setLlamaCppNCtx: vi.fn(),
- setLlamaCppNGpuLayers: vi.fn(),
- setLlamaCppNThreads: vi.fn(),
- pickLlamaCppChatModelFile: vi.fn(),
- pickLlamaCppTitleModelFile: vi.fn(),
- setLlamaCppChatModelPath: vi.fn(),
- setLlamaCppTitleModelPath: vi.fn(),
- scanLlamaCppSystem: vi.fn(),
- pickLlamaCppScanFolder: vi.fn(),
- saveLlamaCppSettings: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { settingsBridge: 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("renders the section Python publishes as active", async () => {
- const remote = installFakeQWebChannel();
- render( );
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(JSON.stringify({ ...initialSettingsState, activeSection: "API Endpoint", revision: 1 }));
-
- await waitFor(() =>
- expect(screen.getByRole("button", { name: "API Endpoint" })).toHaveAttribute("aria-current", "page"),
- );
- });
-
- it("clicking a rail button calls through to setActiveSection", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: "Ollama (Local)" }));
-
- expect(remote.setActiveSection).toHaveBeenCalledWith("Ollama (Local)");
- });
-
- it("toggling a General/Appearance checkbox calls through to the remote", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("checkbox", { name: "Show Token Counter Overlay" }));
-
- expect(remote.setShowTokenCounter).toHaveBeenCalledWith(false);
- });
-
- it("Check for Updates on General calls through to checkForUpdates", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: "Check for Updates" }));
-
- expect(remote.checkForUpdates).toHaveBeenCalledTimes(1);
- });
-
- it("Open Repository on General calls through to openRepository", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: "Open Repository" }));
-
- expect(remote.openRepository).toHaveBeenCalledTimes(1);
- });
-
- it("saving on Integrations calls through to setGithubToken with the typed value", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- // The fake remote's setActiveSection is a bare vi.fn() - it doesn't push
- // a new state back the way real Python would, so the rendered page only
- // actually changes once Python's stateChanged is simulated explicitly.
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
- push(JSON.stringify({ ...initialSettingsState, activeSection: "Integrations", revision: 1 }));
- await waitFor(() => expect(screen.getByLabelText("GitHub Personal Access Token")).toBeInTheDocument());
-
- await user.type(screen.getByLabelText("GitHub Personal Access Token"), "ghp_typed");
- await user.click(screen.getByRole("button", { name: "Save Integrations" }));
-
- expect(remote.setGithubToken).toHaveBeenCalledWith("ghp_typed");
- });
-
- it("Clear Token on Integrations calls through to clearGithubToken", 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({ ...initialSettingsState, activeSection: "Integrations", revision: 1 }));
- await waitFor(() => expect(screen.getByRole("button", { name: "Clear Token" })).toBeInTheDocument());
-
- await user.click(screen.getByRole("button", { name: "Clear Token" }));
-
- expect(remote.clearGithubToken).toHaveBeenCalledTimes(1);
- });
-
- it("changing the API provider select calls through to setApiProvider", 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({ ...initialSettingsState, activeSection: "API Endpoint", revision: 1 }));
- await waitFor(() => expect(screen.getByLabelText("API Provider")).toBeInTheDocument());
-
- await user.selectOptions(screen.getByLabelText("API Provider"), "Google Gemini");
-
- expect(remote.setApiProvider).toHaveBeenCalledWith("Google Gemini");
- });
-
- it("Save Configuration calls through to saveApiConfiguration with the typed values as JSON", 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({ ...initialSettingsState, activeSection: "API Endpoint", revision: 1 }));
- await waitFor(() => expect(screen.getByLabelText("API Key")).toBeInTheDocument());
-
- await user.type(screen.getByLabelText("API Key"), "sk-typed");
- await user.click(screen.getByRole("button", { name: "Save Configuration" }));
-
- expect(remote.saveApiConfiguration).toHaveBeenCalledWith(
- expect.stringContaining("sk-typed"),
- );
- });
-
- it("System Scan on Ollama calls through to scanOllamaSystem", 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({ ...initialSettingsState, activeSection: "Ollama (Local)", revision: 1 }));
- await waitFor(() => expect(screen.getByRole("button", { name: "System Scan" })).toBeInTheDocument());
-
- await user.click(screen.getByRole("button", { name: "System Scan" }));
-
- expect(remote.scanOllamaSystem).toHaveBeenCalledTimes(1);
- });
-
- it("Browse for Chat Model File on LlamaCpp calls through to pickLlamaCppChatModelFile", 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({ ...initialSettingsState, activeSection: "Llama.cpp (Local)", revision: 1 }));
- await waitFor(() => expect(screen.getByText("Chat Model File")).toBeInTheDocument());
-
- await user.click(screen.getAllByRole("button", { name: "Browse..." })[0]);
-
- expect(remote.pickLlamaCppChatModelFile).toHaveBeenCalledTimes(1);
- });
-
- it("Save Settings on LlamaCpp calls through to saveLlamaCppSettings", 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({ ...initialSettingsState, activeSection: "Llama.cpp (Local)", revision: 1 }));
- await waitFor(() => expect(screen.getByRole("button", { name: "Save Settings" })).toBeInTheDocument());
-
- await user.click(screen.getByRole("button", { name: "Save Settings" }));
-
- expect(remote.saveLlamaCppSettings).toHaveBeenCalledTimes(1);
- });
-
- // 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({ ...initialSettingsState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("Settings unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/settings/App.tsx b/web_ui/src/islands/settings/App.tsx
deleted file mode 100644
index 1ead6e3e..00000000
--- a/web_ui/src/islands/settings/App.tsx
+++ /dev/null
@@ -1,812 +0,0 @@
-import { RefObject, useEffect, useRef, useState } from "react";
-import {
- API_PROVIDERS,
- API_TASKS,
- API_TASK_LABELS,
- NOTIFICATION_TYPES,
- OLLAMA_TASKS,
- OLLAMA_TASK_LABELS,
- SECTION_NAMES,
- SettingsState,
- initialSettingsState,
-} from "./bridgeTypes";
-import { BridgeRejection, SettingsBridge, createSettingsBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-const THEME_OPTIONS = [
- { value: "dark", label: "Dark" },
- { value: "muted", label: "Muted" },
- { value: "mono", label: "Monochromatic" },
-];
-
-const NOTIFICATION_TYPE_LABELS: Record<(typeof NOTIFICATION_TYPES)[number], string> = {
- info: "Info",
- success: "Success",
- warning: "Warning",
- error: "Error",
-};
-
-interface GeneralPageProps {
- state: SettingsState;
- bridgeRef: RefObject;
-}
-
-// Check for Updates / Open Repository are deliberately not here yet - both
-// need a real window reference (increment 8's job), see
-// graphlink_settings_bridge.py's module docstring. bridgeRef is passed as
-// the ref object itself (not bridgeRef.current) - every intent call below
-// happens inside an event handler, never during render, so reading
-// .current there is the sanctioned pattern (react-hooks/refs only flags
-// dereferencing a ref while rendering).
-function GeneralPage({ state, bridgeRef }: GeneralPageProps) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
- {state.updateStatusMessage}
- {state.updateLastCheckedAt && ` (last checked: ${state.updateLastCheckedAt})`}
- {state.updateLatestVersion && ` (GitHub signal: ${state.updateLatestVersion})`}
-
-
-
-
-
-
-
- );
-}
-
-interface IntegrationsPageProps {
- state: SettingsState;
- bridgeRef: RefObject;
-}
-
-// Write-only, by design (see graphlink_settings_bridge.py's module
-// docstring): the token input always starts empty and is never pre-filled
-// from state, since the bridge structurally cannot send the current value
-// back. draftToken is local-only UI state for what the user is currently
-// typing, cleared immediately after Save/Clear - it never becomes part of
-// the published SettingsState.
-function IntegrationsPage({ state, bridgeRef }: IntegrationsPageProps) {
- const [draftToken, setDraftToken] = useState("");
-
- return (
-
-
- Store optional external-service tokens used by specialized plugins. The Code Review plugin uses a
- GitHub personal access token to load your private repositories - if you leave this empty, it still
- works with public repositories.
-
-
-
-
-
- {state.githubTokenConfigured ? "A GitHub token is currently configured." : "No GitHub token configured."}
-
-
-
-
-
-
-
- );
-}
-
-interface ApiPageProps {
- state: SettingsState;
- bridgeRef: RefObject;
-}
-
-const API_MODELS_DATALIST_ID = "settings-api-available-models";
-const API_IMAGE_MODELS_DATALIST_ID = "settings-api-image-models";
-
-// Save Configuration is deliberately batched (unlike every other intent on
-// this island) - draftBaseUrl/draftApiKey/draftTaskModels are local-only
-// until Save, matching saveApiConfiguration()'s own atomic, all-or-nothing
-// contract on the Python side (provider init must succeed before anything
-// persists). setApiProvider is the one live intent here, same as the
-// original provider_combo - it only changes which provider's fields
-// display, so switching resets the drafts to that provider's own saved
-// values via the "adjust state during render" pattern used elsewhere on
-// this island.
-function ApiPage({ state, bridgeRef }: ApiPageProps) {
- const [prevProvider, setPrevProvider] = useState(state.apiProvider);
- const [draftBaseUrl, setDraftBaseUrl] = useState(state.apiBaseUrl);
- const [draftApiKey, setDraftApiKey] = useState("");
- const [draftTaskModels, setDraftTaskModels] = useState>(state.apiTaskModels);
- // Reset is destructive and irreversible (write-only secrets: cleared keys
- // can never be re-displayed), so it gets a two-step in-UI confirmation -
- // the web-island equivalent of the legacy widget's "This cannot be undone"
- // QMessageBox, kept entirely inside React so it doesn't depend on a native
- // JS-dialog handler in the hardened QWebEngineView.
- const [confirmingReset, setConfirmingReset] = useState(false);
-
- if (state.apiProvider !== prevProvider) {
- setPrevProvider(state.apiProvider);
- setDraftBaseUrl(state.apiBaseUrl);
- setDraftApiKey("");
- setDraftTaskModels(state.apiTaskModels);
- setConfirmingReset(false);
- }
-
- const isOpenAi = state.apiProvider === "OpenAI-Compatible";
- const isAnthropic = state.apiProvider === "Anthropic Claude";
- const isGemini = state.apiProvider === "Google Gemini";
- const keyConfigured = isOpenAi
- ? state.openaiKeyConfigured
- : isAnthropic
- ? state.anthropicKeyConfigured
- : state.geminiKeyConfigured;
-
- return (
-
-
-
- {isOpenAi && (
-
- )}
-
-
-
-
- {keyConfigured ? "A key is currently configured for this provider." : "No key configured for this provider."}
-
-
- {/* Load fetches a live catalog, which only means something where a
- live fetch happens: OpenAI + Anthropic. Gemini uses a fixed static
- list (chat + image), so no Load button - matching the legacy
- widget's own load_btn.setVisible(is_openai or is_anthropic). */}
- {!isGemini && (
-
-
-
- )}
-
-
- {/* Gemini's image task takes a distinct curated list, not its chat
- models - the bridge fills apiImageModels only for Gemini. */}
-
-
- {API_TASKS.map((task) => {
- if (task === "task_image_gen" && isAnthropic) {
- return (
-
- Anthropic Claude does not support image generation in Graphlink yet.
-
- );
- }
- // Gemini's image field points at the curated image-model datalist;
- // every other field (and every other provider) uses the general one.
- const datalistId =
- task === "task_image_gen" && state.apiImageModels.length > 0
- ? API_IMAGE_MODELS_DATALIST_ID
- : API_MODELS_DATALIST_ID;
- return (
-
- );
- })}
-
- {state.notice && (
-
- {state.notice}
-
- )}
-
- {confirmingReset && (
-
- This clears all saved API keys and model configurations and cannot be undone.
-
- )}
-
-
- {confirmingReset ? (
- <>
-
-
- >
- ) : (
-
- )}
-
-
-
- );
-}
-
-const OLLAMA_MODELS_DATALIST_ID = "settings-ollama-scanned-models";
-
-interface OllamaTaskFieldProps {
- task: (typeof OLLAMA_TASKS)[number];
- value: string;
- bridgeRef: RefObject;
-}
-
-// The flat wire value ("inherit"|"auto"|"") maps onto
-// two UI controls: a mode select (mirrors the original's 3 special combo
-// entries) and a conditionally-shown text input for the explicit case
-// (mirrors the original editable combo's free-text entry, including
-// unavailable-model preservation - the input shows whatever string is
-// persisted, matched against ollamaScannedModels or not). This is a
-// deliberate 2-control simplification of the original's single editable
-// combo, recorded in the plan doc rather than silently done.
-function OllamaTaskField({ task, value, bridgeRef }: OllamaTaskFieldProps) {
- const isSpecial = value === "inherit" || value === "auto";
- const [uiMode, setUiMode] = useState(isSpecial ? value : "explicit");
- const [prevValue, setPrevValue] = useState(value);
-
- if (value !== prevValue) {
- setPrevValue(value);
- setUiMode(isSpecial ? value : "explicit");
- }
-
- return (
- <>
-
- {uiMode === "explicit" && (
-
- )}
- >
- );
-}
-
-interface OllamaPageProps {
- state: SettingsState;
- bridgeRef: RefObject;
-}
-
-function OllamaPage({ state, bridgeRef }: OllamaPageProps) {
- const [draftPullModel, setDraftPullModel] = useState("");
-
- return (
-
-
-
-
- Current Active Model: {state.ollamaCurrentModel || "Auto - no compatible installed model found"}
-
-
-
-
-
-
- {state.ollamaScanSummary}
-
-
-
- {OLLAMA_TASKS.map((task) => (
-
- ))}
-
-
-
-
-
-
- {state.notice && (
-
- {state.notice}
-
- )}
-
- );
-}
-
-interface LlamaCppPageProps {
- state: SettingsState;
- bridgeRef: RefObject;
-}
-
-// Chat/title model paths are staged bridge-side, set only via the native
-// file picker (pickLlamaCppChatModelFile/pickLlamaCppTitleModelFile) - see
-// graphlink_settings_bridge.py's saveLlamaCppSettings docstring. Save
-// Settings is the one action on this page that isn't a live, per-field
-// apply - it mirrors the original widget's own Browse-fills /
-// Save-persists-and-validates split, the same kind of deliberate exception
-// saveApiConfiguration is elsewhere on this island.
-function LlamaCppPage({ state, bridgeRef }: LlamaCppPageProps) {
- return (
-
-
-
-
- Current Active GGUF:{" "}
- {state.llamaCppChatModelPath ? state.llamaCppChatModelPath.split(/[\\/]/).pop() : "No model selected"}
-
-
-
-
-
-
- {state.llamaCppScanSummary}
-
- {/* Scanned-model dropdowns surface the System Scan / Scan Folder
- results for selection - the whole payoff of scanning. Mirrors the
- legacy widget's "Scanned Chat Model" / "Scanned Naming Model"
- combos: picking one stages its path (bridge-side, like Browse),
- Save persists+validates. Only shown once a scan has found models;
- the summary above guides the user to scan otherwise. */}
- {state.llamaCppScannedModels.length > 0 && (
-
- )}
-
-
- Chat Model File
- {state.llamaCppChatModelPath || "No file selected"}
-
-
-
-
-
- {state.llamaCppScannedModels.length > 0 && (
-
- )}
-
-
- Chat Naming File (optional)
- {state.llamaCppTitleModelPath || "Reusing the main chat model"}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {state.notice && (
-
- {state.notice}
-
- )}
-
-
-
-
-
- );
-}
-
-function App() {
- const [state, setState] = useState(initialSettingsState);
- const [rejection, setRejection] = useState(null);
- const bridgeRef = useRef(null);
-
- useEffect(() => {
- const bridge = createSettingsBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- return (
-
-
-
- {state.activeSection === "General" ? (
-
- ) : state.activeSection === "Integrations" ? (
-
- ) : state.activeSection === "API Endpoint" ? (
-
- ) : state.activeSection === "Ollama (Local)" ? (
-
- ) : state.activeSection === "Llama.cpp (Local)" ? (
-
- ) : (
- state.activeSection
- )}
-
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/settings/bridge.test.ts b/web_ui/src/islands/settings/bridge.test.ts
deleted file mode 100644
index a7bebdd4..00000000
--- a/web_ui/src/islands/settings/bridge.test.ts
+++ /dev/null
@@ -1,522 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createSettingsBridge } from "./bridge";
-import { initialSettingsState, SettingsState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialSettingsState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- setActiveSection: vi.fn(),
- setTheme: vi.fn(),
- setShowTokenCounter: vi.fn(),
- setEnableSystemPrompt: vi.fn(),
- setNotificationPreference: vi.fn(),
- setUpdateNotificationsEnabled: vi.fn(),
- checkForUpdates: vi.fn(),
- openRepository: vi.fn(),
- setGithubToken: vi.fn(),
- clearGithubToken: vi.fn(),
- setApiProvider: vi.fn(),
- saveApiConfiguration: vi.fn(),
- loadAvailableModels: vi.fn(),
- resetApiSettings: vi.fn(),
- setOllamaReasoningMode: vi.fn(),
- setOllamaModelAssignment: vi.fn(),
- scanOllamaSystem: vi.fn(),
- pickOllamaScanFolder: vi.fn(),
- pullOllamaModel: vi.fn(),
- setLlamaCppReasoningMode: vi.fn(),
- setLlamaCppChatFormat: vi.fn(),
- setLlamaCppNCtx: vi.fn(),
- setLlamaCppNGpuLayers: vi.fn(),
- setLlamaCppNThreads: vi.fn(),
- pickLlamaCppChatModelFile: vi.fn(),
- pickLlamaCppTitleModelFile: vi.fn(),
- setLlamaCppChatModelPath: vi.fn(),
- setLlamaCppTitleModelPath: vi.fn(),
- scanLlamaCppSystem: vi.fn(),
- pickLlamaCppScanFolder: vi.fn(),
- saveLlamaCppSettings: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { settingsBridge: 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("createSettingsBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialSettingsState);
- });
-
- it("setActiveSection on the mock bridge updates state and republishes", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setActiveSection("Integrations");
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ activeSection: "Integrations" }),
- );
- });
-
- it("setTheme on the mock bridge updates state", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setTheme("muted");
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ theme: "muted" }));
- });
-
- it("setGithubToken on the mock bridge reports configured without exposing the value", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setGithubToken("ghp_secret");
-
- const [state] = listener.mock.calls[0];
- expect(state.githubTokenConfigured).toBe(true);
- expect(JSON.stringify(state)).not.toContain("ghp_secret");
- });
-
- it("clearGithubToken on the mock bridge reports not configured", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- bridge.setGithubToken("ghp_secret");
- listener.mockClear();
-
- bridge.clearGithubToken();
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ githubTokenConfigured: false }));
- });
-
- it("checkForUpdates on the mock bridge simulates a finished, successful check", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.checkForUpdates();
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ updateCheckInProgress: false, updateStatusLevel: "success" }),
- );
- });
-
- it("openRepository on the mock bridge does not throw", () => {
- const bridge = createSettingsBridge(() => {});
- expect(() => bridge.openRepository()).not.toThrow();
- });
-
- it("setNotificationPreference on the mock bridge is a partial update", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setNotificationPreference("warning", false);
-
- const [state] = listener.mock.calls[0];
- expect(state.notificationPreferences.warning).toBe(false);
- expect(state.notificationPreferences.info).toBe(true);
- });
-
- it("setApiProvider on the mock bridge switches provider and resets load state", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setApiProvider("Google Gemini");
-
- const [state] = listener.mock.calls[0];
- expect(state.apiProvider).toBe("Google Gemini");
- expect(state.notice).toBeNull();
- });
-
- it("saveApiConfiguration on the mock bridge reports configured without exposing the key", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.saveApiConfiguration({
- provider: "OpenAI-Compatible",
- baseUrl: "https://api.openai.com/v1",
- apiKey: "sk-secret",
- taskModels: { task_chat: "gpt-4o" },
- });
-
- const [state] = listener.mock.calls[0];
- expect(state.openaiKeyConfigured).toBe(true);
- expect(JSON.stringify(state)).not.toContain("sk-secret");
- });
-
- it("saveApiConfiguration on the mock bridge rejects a missing key with a notice", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.saveApiConfiguration({
- provider: "OpenAI-Compatible",
- baseUrl: "https://api.openai.com/v1",
- apiKey: "",
- taskModels: {},
- });
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ notice: "Please enter your API Key." }));
- });
-
- it("resetApiSettings on the mock bridge clears configured flags", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- bridge.saveApiConfiguration({
- provider: "OpenAI-Compatible",
- baseUrl: "https://api.openai.com/v1",
- apiKey: "sk-secret",
- taskModels: {},
- });
- listener.mockClear();
-
- bridge.resetApiSettings();
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ openaiKeyConfigured: false }));
- });
-
- it("setOllamaReasoningMode on the mock bridge updates state", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setOllamaReasoningMode("Quick");
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ ollamaReasoningMode: "Quick" }));
- });
-
- it("setOllamaModelAssignment on the mock bridge is a partial update", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setOllamaModelAssignment("task_chart", "llama3:8b");
-
- const [state] = listener.mock.calls[0];
- expect(state.ollamaModelAssignments.task_chart).toBe("llama3:8b");
- });
-
- it("pullOllamaModel on the mock bridge rejects an empty model name", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.pullOllamaModel("");
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ notice: "Model name cannot be empty." }));
- });
-
- it("dispose() on the mock bridge does not throw", () => {
- const bridge = createSettingsBridge(() => {});
- expect(() => bridge.dispose()).not.toThrow();
- });
-
- it("setLlamaCppReasoningMode on the mock bridge updates state", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setLlamaCppReasoningMode("Quick");
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ llamaCppReasoningMode: "Quick" }));
- });
-
- it("setLlamaCppNCtx on the mock bridge updates state", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setLlamaCppNCtx(8192);
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ llamaCppNCtx: 8192 }));
- });
-
- it("saveLlamaCppSettings on the mock bridge rejects an empty chat model path with a notice", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.saveLlamaCppSettings();
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ notice: "Chat Model File cannot be empty." }));
- });
-
- it("scanLlamaCppSystem on the mock bridge reports found models", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.scanLlamaCppSystem();
-
- const [state] = listener.mock.calls[0];
- expect(state.llamaCppScanStatus).toBe("done");
- expect(state.llamaCppScannedModels.length).toBeGreaterThan(0);
- });
-
- it("setLlamaCppChatModelPath on the mock bridge stages the chosen scanned path", () => {
- const listener = vi.fn();
- const bridge = createSettingsBridge(listener);
- bridge.ready();
- listener.mockClear();
-
- bridge.setLlamaCppChatModelPath("/models/chat.gguf");
-
- expect(listener).toHaveBeenCalledWith(expect.objectContaining({ llamaCppChatModelPath: "/models/chat.gguf" }));
- });
-});
-
-describe("createSettingsBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createSettingsBridge(() => {});
- 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();
- createSettingsBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ activeSection: "API Endpoint" }));
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ activeSection: "API Endpoint" }),
- );
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("setActiveSection calls through to the remote", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createSettingsBridge(() => {});
-
- bridge.setActiveSection("Ollama (Local)");
-
- expect(remote.setActiveSection).toHaveBeenCalledWith("Ollama (Local)");
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("each General/Appearance intent calls through to its own remote method", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createSettingsBridge(() => {});
-
- bridge.setTheme("mono");
- bridge.setShowTokenCounter(false);
- bridge.setEnableSystemPrompt(false);
- bridge.setNotificationPreference("error", false);
- bridge.setUpdateNotificationsEnabled(true);
- bridge.checkForUpdates();
- bridge.openRepository();
-
- expect(remote.setTheme).toHaveBeenCalledWith("mono");
- expect(remote.setShowTokenCounter).toHaveBeenCalledWith(false);
- expect(remote.setEnableSystemPrompt).toHaveBeenCalledWith(false);
- expect(remote.setNotificationPreference).toHaveBeenCalledWith("error", false);
- expect(remote.setUpdateNotificationsEnabled).toHaveBeenCalledWith(true);
- expect(remote.checkForUpdates).toHaveBeenCalledTimes(1);
- expect(remote.openRepository).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("setGithubToken and clearGithubToken call through to the remote", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createSettingsBridge(() => {});
-
- bridge.setGithubToken("ghp_secret");
- bridge.clearGithubToken();
-
- expect(remote.setGithubToken).toHaveBeenCalledWith("ghp_secret");
- expect(remote.clearGithubToken).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("setApiProvider and loadAvailableModels and resetApiSettings call through to the remote", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createSettingsBridge(() => {});
-
- bridge.setApiProvider("Anthropic Claude");
- bridge.loadAvailableModels("sk-secret");
- bridge.resetApiSettings();
-
- expect(remote.setApiProvider).toHaveBeenCalledWith("Anthropic Claude");
- expect(remote.loadAvailableModels).toHaveBeenCalledWith("sk-secret");
- expect(remote.resetApiSettings).toHaveBeenCalledTimes(1);
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("saveApiConfiguration serializes its args to JSON for the remote call", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createSettingsBridge(() => {});
- const args = {
- provider: "OpenAI-Compatible",
- baseUrl: "https://api.openai.com/v1",
- apiKey: "sk-secret",
- taskModels: { task_chat: "gpt-4o" },
- };
-
- bridge.saveApiConfiguration(args);
-
- expect(remote.saveApiConfiguration).toHaveBeenCalledWith(JSON.stringify(args));
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("Ollama intents call through to their own remote methods", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createSettingsBridge(() => {});
-
- bridge.setOllamaReasoningMode("Quick");
- bridge.setOllamaModelAssignment("task_chart", "llama3:8b");
- bridge.scanOllamaSystem();
- bridge.pickOllamaScanFolder();
- bridge.pullOllamaModel("llama3:8b");
-
- expect(remote.setOllamaReasoningMode).toHaveBeenCalledWith("Quick");
- expect(remote.setOllamaModelAssignment).toHaveBeenCalledWith("task_chart", "llama3:8b");
- expect(remote.scanOllamaSystem).toHaveBeenCalledTimes(1);
- expect(remote.pickOllamaScanFolder).toHaveBeenCalledTimes(1);
- expect(remote.pullOllamaModel).toHaveBeenCalledWith("llama3:8b");
- } finally {
- uninstallFakeQWebChannel();
- }
- });
-
- it("LlamaCpp intents call through to their own remote methods", () => {
- const remote = installFakeQWebChannel();
- try {
- const bridge = createSettingsBridge(() => {});
-
- bridge.setLlamaCppReasoningMode("Quick");
- bridge.setLlamaCppChatFormat("chatml");
- bridge.setLlamaCppNCtx(8192);
- bridge.setLlamaCppNGpuLayers(-1);
- bridge.setLlamaCppNThreads(8);
- bridge.pickLlamaCppChatModelFile();
- bridge.pickLlamaCppTitleModelFile();
- bridge.setLlamaCppChatModelPath("/models/chat.gguf");
- bridge.setLlamaCppTitleModelPath("/models/title.gguf");
- bridge.scanLlamaCppSystem();
- bridge.pickLlamaCppScanFolder();
- bridge.saveLlamaCppSettings();
-
- expect(remote.setLlamaCppReasoningMode).toHaveBeenCalledWith("Quick");
- expect(remote.setLlamaCppChatFormat).toHaveBeenCalledWith("chatml");
- expect(remote.setLlamaCppNCtx).toHaveBeenCalledWith(8192);
- expect(remote.setLlamaCppNGpuLayers).toHaveBeenCalledWith(-1);
- expect(remote.setLlamaCppNThreads).toHaveBeenCalledWith(8);
- expect(remote.pickLlamaCppChatModelFile).toHaveBeenCalledTimes(1);
- expect(remote.pickLlamaCppTitleModelFile).toHaveBeenCalledTimes(1);
- expect(remote.setLlamaCppChatModelPath).toHaveBeenCalledWith("/models/chat.gguf");
- expect(remote.setLlamaCppTitleModelPath).toHaveBeenCalledWith("/models/title.gguf");
- expect(remote.scanLlamaCppSystem).toHaveBeenCalledTimes(1);
- expect(remote.pickLlamaCppScanFolder).toHaveBeenCalledTimes(1);
- expect(remote.saveLlamaCppSettings).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();
- createSettingsBridge(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 = createSettingsBridge(() => {});
- 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/settings/bridge.ts b/web_ui/src/islands/settings/bridge.ts
deleted file mode 100644
index 6f9f5793..00000000
--- a/web_ui/src/islands/settings/bridge.ts
+++ /dev/null
@@ -1,422 +0,0 @@
-import { SettingsState, initialSettingsState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateSettingsState } from "../../lib/bridge-core/generated/settings-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: SettingsState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-export interface SaveApiConfigurationArgs {
- provider: string;
- baseUrl: string;
- apiKey: string;
- taskModels: Record;
-}
-
-interface QtSettingsObject {
- stateChanged: QtSignal;
- ready: () => void;
- setActiveSection: (section: string) => void;
- setTheme: (theme: string) => void;
- setShowTokenCounter: (enabled: boolean) => void;
- setEnableSystemPrompt: (enabled: boolean) => void;
- setNotificationPreference: (notificationType: string, enabled: boolean) => void;
- setUpdateNotificationsEnabled: (enabled: boolean) => void;
- checkForUpdates: () => void;
- openRepository: () => void;
- setGithubToken: (token: string) => void;
- clearGithubToken: () => void;
- setApiProvider: (provider: string) => void;
- saveApiConfiguration: (configJson: string) => void;
- loadAvailableModels: (apiKey: string) => void;
- resetApiSettings: () => void;
- setOllamaReasoningMode: (mode: string) => void;
- setOllamaModelAssignment: (task: string, value: string) => void;
- scanOllamaSystem: () => void;
- pickOllamaScanFolder: () => void;
- pullOllamaModel: (modelName: string) => void;
- setLlamaCppReasoningMode: (mode: string) => void;
- setLlamaCppChatFormat: (chatFormat: string) => void;
- setLlamaCppNCtx: (nCtx: number) => void;
- setLlamaCppNGpuLayers: (nGpuLayers: number) => void;
- setLlamaCppNThreads: (nThreads: number) => void;
- pickLlamaCppChatModelFile: () => void;
- pickLlamaCppTitleModelFile: () => void;
- setLlamaCppChatModelPath: (path: string) => void;
- setLlamaCppTitleModelPath: (path: string) => void;
- scanLlamaCppSystem: () => void;
- pickLlamaCppScanFolder: () => void;
- saveLlamaCppSettings: () => void;
-}
-
-export interface SettingsBridge {
- ready(): void;
- setActiveSection(section: string): void;
- setTheme(theme: string): void;
- setShowTokenCounter(enabled: boolean): void;
- setEnableSystemPrompt(enabled: boolean): void;
- setNotificationPreference(notificationType: string, enabled: boolean): void;
- setUpdateNotificationsEnabled(enabled: boolean): void;
- checkForUpdates(): void;
- openRepository(): void;
- setGithubToken(token: string): void;
- clearGithubToken(): void;
- setApiProvider(provider: string): void;
- saveApiConfiguration(args: SaveApiConfigurationArgs): void;
- loadAvailableModels(apiKey: string): void;
- resetApiSettings(): void;
- setOllamaReasoningMode(mode: string): void;
- setOllamaModelAssignment(task: string, value: string): void;
- scanOllamaSystem(): void;
- pickOllamaScanFolder(): void;
- pullOllamaModel(modelName: string): void;
- setLlamaCppReasoningMode(mode: string): void;
- setLlamaCppChatFormat(chatFormat: string): void;
- setLlamaCppNCtx(nCtx: number): void;
- setLlamaCppNGpuLayers(nGpuLayers: number): void;
- setLlamaCppNThreads(nThreads: number): void;
- pickLlamaCppChatModelFile(): void;
- pickLlamaCppTitleModelFile(): void;
- setLlamaCppChatModelPath(path: string): void;
- setLlamaCppTitleModelPath(path: string): void;
- scanLlamaCppSystem(): void;
- pickLlamaCppScanFolder(): void;
- saveLlamaCppSettings(): void;
- dispose(): void;
-}
-
-/**
- * Each intent applies and republishes immediately - see
- * graphlink_settings_bridge.py's module docstring for why this departs
- * from the original AppearanceSettingsWidget's single batched "Apply"
- * button.
- */
-function parseState(payload: string) {
- return parseIslandState(payload, validateSettingsState);
-}
-
-class MockSettingsBridge implements SettingsBridge {
- private state: SettingsState = structuredClone(initialSettingsState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- private publish(next: Partial) {
- this.state = { ...this.state, ...next, revision: this.state.revision + 1 };
- this.listener(this.state);
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- setActiveSection(section: string): void {
- this.publish({ activeSection: section });
- }
-
- setTheme(theme: string): void {
- this.publish({ theme });
- }
-
- setShowTokenCounter(enabled: boolean): void {
- this.publish({ showTokenCounter: enabled });
- }
-
- setEnableSystemPrompt(enabled: boolean): void {
- this.publish({ enableSystemPrompt: enabled });
- }
-
- setNotificationPreference(notificationType: string, enabled: boolean): void {
- this.publish({
- notificationPreferences: { ...this.state.notificationPreferences, [notificationType]: enabled },
- });
- }
-
- setUpdateNotificationsEnabled(enabled: boolean): void {
- this.publish({ updateNotificationsEnabled: enabled });
- }
-
- checkForUpdates(): void {
- // No real UpdateCheckWorker in the mock - simulates an immediate,
- // successful check so the dev-mode UI has something to show.
- this.publish({
- updateCheckInProgress: false,
- updateStatusMessage: "You're up to date.",
- updateStatusLevel: "success",
- updateLatestVersion: "0.0.0-dev",
- });
- }
-
- openRepository(): void {
- // No real browser to open in the mock/test environment - a no-op,
- // same treatment as pickOllamaScanFolder's native-dialog stand-in.
- }
-
- setGithubToken(token: string): void {
- // Mirrors the real bridge's write-only contract even in the mock: the
- // token value itself is never retained in state, only whether one was
- // set - so a dev-mode/test consumer can't come to rely on getting it
- // back, a behavior the real bridge structurally cannot provide.
- this.publish({ githubTokenConfigured: token.trim().length > 0 });
- }
-
- clearGithubToken(): void {
- this.publish({ githubTokenConfigured: false });
- }
-
- setApiProvider(provider: string): void {
- const isGemini = provider === "Google Gemini";
- this.publish({
- apiProvider: provider,
- apiLoadStatus: "idle",
- notice: null,
- apiAvailableModels: isGemini ? ["gemini-2.5-flash", "gemini-2.5-pro"] : [],
- // Faithful to the real bridge: Gemini's image task takes a distinct
- // curated list, empty for every other provider.
- apiImageModels: isGemini ? ["gemini-2.5-flash-image", "gemini-3.1-flash-image-preview"] : [],
- apiTaskModels: {},
- });
- }
-
- saveApiConfiguration(args: SaveApiConfigurationArgs): void {
- // A lightweight stand-in for saveApiConfiguration()'s real validation -
- // enough to exercise the UI meaningfully in npm run dev / jsdom smoke
- // tests, not a faithful reimplementation of the Python-side logic
- // (that's covered by tests/test_settings_bridge_api_page.py against
- // the real bridge).
- if (args.provider === "OpenAI-Compatible" && !args.baseUrl.trim()) {
- this.publish({ notice: "Please enter the Base URL for the OpenAI-compatible provider." });
- return;
- }
- if (!args.apiKey.trim()) {
- this.publish({ notice: "Please enter your API Key." });
- return;
- }
- const keyField =
- args.provider === "OpenAI-Compatible"
- ? "openaiKeyConfigured"
- : args.provider === "Anthropic Claude"
- ? "anthropicKeyConfigured"
- : "geminiKeyConfigured";
- this.publish({
- apiProvider: args.provider,
- apiBaseUrl: args.baseUrl,
- apiTaskModels: args.taskModels,
- notice: null,
- [keyField]: true,
- });
- }
-
- loadAvailableModels(apiKey: string): void {
- if (!apiKey.trim()) {
- this.publish({ notice: "Please enter the API Key." });
- return;
- }
- this.publish({ apiLoadStatus: "done", apiAvailableModels: ["gpt-4o", "gpt-4o-mini"], notice: null });
- }
-
- resetApiSettings(): void {
- this.publish({
- apiProvider: "OpenAI-Compatible",
- apiBaseUrl: "https://api.openai.com/v1",
- openaiKeyConfigured: false,
- anthropicKeyConfigured: false,
- geminiKeyConfigured: false,
- apiTaskModels: {},
- apiAvailableModels: [],
- apiImageModels: [],
- apiLoadStatus: "idle",
- notice: null,
- });
- }
-
- setOllamaReasoningMode(mode: string): void {
- this.publish({ ollamaReasoningMode: mode });
- }
-
- setOllamaModelAssignment(task: string, value: string): void {
- this.publish({
- ollamaModelAssignments: { ...this.state.ollamaModelAssignments, [task]: value || "auto" },
- ollamaCurrentModel: task === "task_chat" && value ? value : this.state.ollamaCurrentModel,
- });
- }
-
- scanOllamaSystem(): void {
- this.publish({
- ollamaScanStatus: "done",
- ollamaScannedModels: ["llama3:8b", "mistral:7b"],
- ollamaScanSummary: "Using saved system scan results from local Ollama locations.",
- });
- }
-
- pickOllamaScanFolder(): void {
- // The mock has no real native file dialog to show - treated the same
- // as a real "picker cancelled" outcome (a no-op), since there's no
- // meaningful dev-mode stand-in for a native OS folder picker.
- }
-
- pullOllamaModel(modelName: string): void {
- if (!modelName.trim()) {
- this.publish({ notice: "Model name cannot be empty." });
- return;
- }
- this.publish({ ollamaPullStatus: "done", ollamaCurrentModel: modelName, notice: null });
- }
-
- setLlamaCppReasoningMode(mode: string): void {
- this.publish({ llamaCppReasoningMode: mode });
- }
-
- setLlamaCppChatFormat(chatFormat: string): void {
- this.publish({ llamaCppChatFormat: chatFormat });
- }
-
- setLlamaCppNCtx(nCtx: number): void {
- this.publish({ llamaCppNCtx: nCtx });
- }
-
- setLlamaCppNGpuLayers(nGpuLayers: number): void {
- this.publish({ llamaCppNGpuLayers: nGpuLayers });
- }
-
- setLlamaCppNThreads(nThreads: number): void {
- this.publish({ llamaCppNThreads: nThreads });
- }
-
- pickLlamaCppChatModelFile(): void {
- // No real native file dialog to show in the mock - same "cancelled
- // picker" no-op treatment as pickOllamaScanFolder above.
- }
-
- pickLlamaCppTitleModelFile(): void {
- // See pickLlamaCppChatModelFile.
- }
-
- setLlamaCppChatModelPath(path: string): void {
- this.publish({ llamaCppChatModelPath: path.trim() });
- }
-
- setLlamaCppTitleModelPath(path: string): void {
- this.publish({ llamaCppTitleModelPath: path.trim() });
- }
-
- scanLlamaCppSystem(): void {
- this.publish({
- llamaCppScanStatus: "done",
- llamaCppScannedModels: ["/models/chat.gguf", "/models/title.gguf"],
- llamaCppScanSummary: "Using saved system scan results from common local model folders.",
- });
- }
-
- pickLlamaCppScanFolder(): void {
- // See pickLlamaCppChatModelFile.
- }
-
- saveLlamaCppSettings(): void {
- // Lightweight stand-in, same caveat as saveApiConfiguration above - the
- // real path-existence/.gguf validation lives in
- // tests/test_settings_bridge_llamacpp_page.py against the real bridge.
- if (!this.state.llamaCppChatModelPath.trim()) {
- this.publish({ notice: "Chat Model File cannot be empty." });
- return;
- }
- this.publish({ notice: null });
- }
-
- dispose(): void {}
-}
-
-export function createSettingsBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): SettingsBridge {
- const fallback = new MockSettingsBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtSettingsObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[settings bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.settingsBridge as QtSettingsObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- const call = (
- method: K,
- ...args: QtSettingsObject[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"),
- setActiveSection: (section) => call("setActiveSection", section),
- setTheme: (theme) => call("setTheme", theme),
- setShowTokenCounter: (enabled) => call("setShowTokenCounter", enabled),
- setEnableSystemPrompt: (enabled) => call("setEnableSystemPrompt", enabled),
- setNotificationPreference: (notificationType, enabled) =>
- call("setNotificationPreference", notificationType, enabled),
- setUpdateNotificationsEnabled: (enabled) => call("setUpdateNotificationsEnabled", enabled),
- checkForUpdates: () => call("checkForUpdates"),
- openRepository: () => call("openRepository"),
- setGithubToken: (token) => call("setGithubToken", token),
- clearGithubToken: () => call("clearGithubToken"),
- setApiProvider: (provider) => call("setApiProvider", provider),
- saveApiConfiguration: (args) => call("saveApiConfiguration", JSON.stringify(args)),
- loadAvailableModels: (apiKey) => call("loadAvailableModels", apiKey),
- resetApiSettings: () => call("resetApiSettings"),
- setOllamaReasoningMode: (mode) => call("setOllamaReasoningMode", mode),
- setOllamaModelAssignment: (task, value) => call("setOllamaModelAssignment", task, value),
- scanOllamaSystem: () => call("scanOllamaSystem"),
- pickOllamaScanFolder: () => call("pickOllamaScanFolder"),
- pullOllamaModel: (modelName) => call("pullOllamaModel", modelName),
- setLlamaCppReasoningMode: (mode) => call("setLlamaCppReasoningMode", mode),
- setLlamaCppChatFormat: (chatFormat) => call("setLlamaCppChatFormat", chatFormat),
- setLlamaCppNCtx: (nCtx) => call("setLlamaCppNCtx", nCtx),
- setLlamaCppNGpuLayers: (nGpuLayers) => call("setLlamaCppNGpuLayers", nGpuLayers),
- setLlamaCppNThreads: (nThreads) => call("setLlamaCppNThreads", nThreads),
- pickLlamaCppChatModelFile: () => call("pickLlamaCppChatModelFile"),
- pickLlamaCppTitleModelFile: () => call("pickLlamaCppTitleModelFile"),
- setLlamaCppChatModelPath: (path) => call("setLlamaCppChatModelPath", path),
- setLlamaCppTitleModelPath: (path) => call("setLlamaCppTitleModelPath", path),
- scanLlamaCppSystem: () => call("scanLlamaCppSystem"),
- pickLlamaCppScanFolder: () => call("pickLlamaCppScanFolder"),
- saveLlamaCppSettings: () => call("saveLlamaCppSettings"),
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/settings/bridgeTypes.ts b/web_ui/src/islands/settings/bridgeTypes.ts
deleted file mode 100644
index 55a78735..00000000
--- a/web_ui/src/islands/settings/bridgeTypes.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * The settings island's state contract.
- *
- * Grown one page at a time per the Phase 3 increment sequence recorded in
- * doc/FRONTEND_WEB_MIGRATION_MASTER_PLAN.md: increment 2 shipped
- * activeSection alone; increment 3 adds the General/Appearance page's
- * fields. Each remaining page's own fields land in its own later
- * increment.
- */
-export type { SettingsState } from "../../lib/bridge-core/generated/settings-state";
-
-import type { SettingsState } from "../../lib/bridge-core/generated/settings-state";
-
-export const SECTION_NAMES = [
- "General",
- "Ollama (Local)",
- "Llama.cpp (Local)",
- "API Endpoint",
- "Integrations",
-] as const;
-
-export const NOTIFICATION_TYPES = ["info", "success", "warning", "error"] as const;
-
-export const API_PROVIDERS = ["OpenAI-Compatible", "Anthropic Claude", "Google Gemini"] as const;
-
-export const API_TASKS = [
- "task_title",
- "task_chat",
- "task_chart",
- "task_image_gen",
- "task_web_validate",
- "task_web_summarize",
-] as const;
-
-export const API_TASK_LABELS: Record<(typeof API_TASKS)[number], string> = {
- task_title: "Chat Naming / Session Title",
- task_chat: "Chat, Explain, Takeaways (main model)",
- task_chart: "Chart Generation (code-capable model)",
- task_image_gen: "Image Generation",
- task_web_validate: "Web Content Validation",
- task_web_summarize: "Web Content Summarization",
-};
-
-export const OLLAMA_TASKS = ["task_chat", "task_title", "task_chart", "task_web_validate", "task_web_summarize"] as const;
-
-export const OLLAMA_TASK_LABELS: Record<(typeof OLLAMA_TASKS)[number], string> = {
- task_chat: "Chat Model",
- task_title: "Chat Naming Model",
- task_chart: "Chart Generation Model",
- task_web_validate: "Web Content Validation Model",
- task_web_summarize: "Web Content Summarization Model",
-};
-
-export const initialSettingsState: SettingsState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- activeSection: "General",
- theme: "dark",
- showTokenCounter: true,
- enableSystemPrompt: true,
- notificationPreferences: { info: true, success: true, warning: true, error: true },
- updateNotificationsEnabled: false,
- updateStatusMessage: "Automatic update checks are off.",
- updateStatusLevel: "info",
- updateLastCheckedAt: "",
- updateAvailable: false,
- updateLatestVersion: "",
- updateCheckInProgress: false,
- githubTokenConfigured: false,
- apiProvider: "OpenAI-Compatible",
- apiBaseUrl: "https://api.openai.com/v1",
- openaiKeyConfigured: false,
- anthropicKeyConfigured: false,
- geminiKeyConfigured: false,
- apiTaskModels: {},
- apiAvailableModels: [],
- apiImageModels: [],
- apiLoadStatus: "idle",
- ollamaReasoningMode: "Thinking",
- ollamaCurrentModel: "",
- // Matches SettingsManager's real defaults exactly (task_chat starts
- // "auto", every other task starts "inherit" - see
- // graphlink_licensing.py::_create_initial_state).
- ollamaModelAssignments: {
- task_chat: "auto",
- task_title: "inherit",
- task_chart: "inherit",
- task_web_validate: "inherit",
- task_web_summarize: "inherit",
- },
- ollamaScannedModels: [],
- ollamaScanSummary: "No saved scan yet. Run a system scan or choose a folder to build the local model list.",
- ollamaScanStatus: "idle",
- ollamaPullStatus: "idle",
- llamaCppReasoningMode: "Thinking",
- llamaCppChatModelPath: "",
- llamaCppTitleModelPath: "",
- llamaCppChatFormat: "",
- llamaCppNCtx: 4096,
- llamaCppNGpuLayers: 0,
- llamaCppNThreads: 0,
- llamaCppScannedModels: [],
- llamaCppScanSummary: "No saved GGUF scan yet. Run a system scan or choose a folder to build the local model list.",
- llamaCppScanStatus: "idle",
- notice: null,
-};
diff --git a/web_ui/src/islands/settings/index.html b/web_ui/src/islands/settings/index.html
deleted file mode 100644
index dfcbcc6e..00000000
--- a/web_ui/src/islands/settings/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Settings
-
-
-
-
-
-
diff --git a/web_ui/src/islands/settings/main.tsx b/web_ui/src/islands/settings/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/settings/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/settings/styles.css b/web_ui/src/islands/settings/styles.css
deleted file mode 100644
index 05fdca5d..00000000
--- a/web_ui/src/islands/settings/styles.css
+++ /dev/null
@@ -1,191 +0,0 @@
-/* Colors reuse EXISTING app-wide --gl-* tokens, same as every island since
- token-counter - shell-only content today needs no new tokens, and per the
- Phase 3 checklist scope note this island still isn't the "island #2"
- moment css_custom_properties()'s docstring is watching for.
-
- Sized to match SettingsDialog.show_for_anchor's resize(820, 560) -
- correct even though nothing constructs this island inside the real
- dialog yet (Phase 3 increment 8's job), since the size is a property of
- the content, not of who's hosting it. */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.settings-shell {
- box-sizing: border-box;
- width: 820px;
- height: 560px;
- display: flex;
- flex-direction: row;
- font-family: var(--gl-font-family, system-ui, sans-serif);
- background-color: var(--gl-graph-node-panel-fill);
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 8px;
- overflow: hidden;
-}
-
-.settings-rail {
- box-sizing: border-box;
- width: 200px;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- gap: 4px;
- padding: 12px;
- border-right: 1px solid var(--gl-neutral-button-border);
-}
-
-.settings-rail-button {
- box-sizing: border-box;
- width: 100%;
- padding: 10px 12px;
- text-align: left;
- font-size: 13px;
- font-family: inherit;
- color: var(--gl-neutral-button-icon);
- background-color: transparent;
- border: none;
- border-radius: 4px;
- cursor: pointer;
-}
-
-.settings-rail-button:hover {
- background-color: var(--gl-neutral-button-background);
-}
-
-.settings-rail-button[aria-current="page"] {
- background-color: var(--gl-neutral-button-background);
- color: var(--gl-semantic-status-success);
-}
-
-.settings-page {
- flex: 1;
- padding: 16px;
- overflow-y: auto;
- color: var(--gl-neutral-button-icon);
- font-size: 13px;
-}
-
-.settings-general-page {
- display: flex;
- flex-direction: column;
- gap: 16px;
-}
-
-.settings-field {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.settings-field-label {
- font-weight: 600;
-}
-
-.settings-select {
- box-sizing: border-box;
- padding: 8px;
- font-size: 13px;
- 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;
-}
-
-.settings-checkbox-row {
- display: flex;
- align-items: center;
- gap: 8px;
- cursor: pointer;
-}
-
-.settings-fieldset {
- display: flex;
- flex-direction: column;
- gap: 8px;
- padding: 12px;
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 4px;
-}
-
-.settings-fieldset legend {
- padding: 0 4px;
- font-size: 12px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.settings-update-status {
- margin: 0;
- font-size: 12px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.settings-update-status[data-level="warning"] {
- color: var(--gl-semantic-status-warning);
-}
-
-.settings-update-status[data-level="error"] {
- color: var(--gl-semantic-status-error);
-}
-
-.settings-integrations-intro {
- margin: 0;
- font-size: 12px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.settings-button-row {
- display: flex;
- gap: 8px;
-}
-
-.settings-button {
- padding: 8px 14px;
- font-size: 13px;
- 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;
- cursor: pointer;
-}
-
-.settings-button:disabled {
- cursor: not-allowed;
- opacity: 0.5;
-}
-
-.settings-button-primary {
- margin-left: auto;
- border-color: var(--gl-semantic-status-success);
- color: var(--gl-semantic-status-success);
-}
-
-.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/token-counter/App.test.tsx b/web_ui/src/islands/token-counter/App.test.tsx
deleted file mode 100644
index cca06c8b..00000000
--- a/web_ui/src/islands/token-counter/App.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { cleanup, render, screen } from "@testing-library/react";
-import App from "./App";
-import { initialTokenCounterState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-// jsdom has no window.QWebChannel, so createTokenCounterBridge() falls
-// through to the mock bridge automatically - same pattern as
-// ComposerApp.test.tsx.
-
-describe("App against the mock bridge", () => {
- it("renders all four rows starting at zero", () => {
- render( );
-
- expect(screen.getByText("Input:")).toBeInTheDocument();
- expect(screen.getByText("Output:")).toBeInTheDocument();
- expect(screen.getByText("Context:")).toBeInTheDocument();
- expect(screen.getByText("Total:")).toBeInTheDocument();
- expect(screen.getAllByText("0")).toHaveLength(4);
- });
-});
-
-// 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.
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- const qtWindow = window as unknown as QtWindow;
- delete qtWindow.QWebChannel;
- delete qtWindow.qt;
- });
-
- it("renders the shared error state on a rejected payload", async () => {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { tokenCounterBridge: remote } });
- }
- }
- const qtWindow = window as unknown as QtWindow;
- qtWindow.QWebChannel = FakeQWebChannel as unknown as QtWindow["QWebChannel"];
- qtWindow.qt = { webChannelTransport: {} };
-
- render( );
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(JSON.stringify({ ...initialTokenCounterState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("Token counter unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/token-counter/App.tsx b/web_ui/src/islands/token-counter/App.tsx
deleted file mode 100644
index a85f4c55..00000000
--- a/web_ui/src/islands/token-counter/App.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { TokenCounterState, initialTokenCounterState } from "./bridgeTypes";
-import { BridgeRejection, TokenCounterBridge, createTokenCounterBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-type CountField = "inputTokens" | "outputTokens" | "contextTokens" | "totalTokens";
-
-const ROWS: Array<{ key: CountField; label: string }> = [
- { key: "inputTokens", label: "Input:" },
- { key: "outputTokens", label: "Output:" },
- { key: "contextTokens", label: "Context:" },
- { key: "totalTokens", label: "Total:" },
-];
-
-function App() {
- const [state, setState] = useState(initialTokenCounterState);
- const [rejection, setRejection] = useState(null);
- const bridgeRef = useRef(null);
-
- useEffect(() => {
- const bridge = createTokenCounterBridge(setState, setRejection);
- bridgeRef.current = bridge;
- bridge.ready();
- return () => {
- bridge.dispose();
- bridgeRef.current = null;
- };
- }, []);
-
- if (rejection) {
- return (
-
- );
- }
-
- return (
-
- {ROWS.map(({ key, label }) => (
-
- {label}
- {state[key].toLocaleString()}
-
- ))}
-
- );
-}
-
-export default App;
diff --git a/web_ui/src/islands/token-counter/bridge.test.ts b/web_ui/src/islands/token-counter/bridge.test.ts
deleted file mode 100644
index 1fd0414a..00000000
--- a/web_ui/src/islands/token-counter/bridge.test.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { createTokenCounterBridge } from "./bridge";
-import { initialTokenCounterState, TokenCounterState } from "./bridgeTypes";
-import { QtWindow } from "../../lib/bridge-core/transport";
-
-function stateJson(overrides: Partial = {}): string {
- return JSON.stringify({ ...initialTokenCounterState, ...overrides });
-}
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- };
-
- class FakeQWebChannel {
- constructor(_transport: unknown, callback: (channel: { objects: Record }) => void) {
- callback({ objects: { tokenCounterBridge: 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("createTokenCounterBridge with no QWebChannel available", () => {
- it("falls back to the mock bridge and publishes the initial state on ready()", () => {
- const listener = vi.fn();
- const bridge = createTokenCounterBridge(listener);
-
- bridge.ready();
-
- expect(listener).toHaveBeenCalledExactlyOnceWith(initialTokenCounterState);
- });
-
- it("dispose() on the mock bridge does not throw", () => {
- const bridge = createTokenCounterBridge(() => {});
- expect(() => bridge.dispose()).not.toThrow();
- });
-});
-
-describe("createTokenCounterBridge against a real QWebChannel connection", () => {
- it("connects synchronously and calls remote.ready()", () => {
- const remote = installFakeQWebChannel();
- try {
- createTokenCounterBridge(() => {});
- 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();
- createTokenCounterBridge(listener);
- const push = remote.stateChanged.connect.mock.calls[0][0] as (payload: string) => void;
-
- push(stateJson({ inputTokens: 42, totalTokens: 42 }));
-
- expect(listener).toHaveBeenCalledWith(
- expect.objectContaining({ inputTokens: 42, totalTokens: 42 }),
- );
- } 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();
- createTokenCounterBridge(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 = createTokenCounterBridge(() => {});
- 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/token-counter/bridge.ts b/web_ui/src/islands/token-counter/bridge.ts
deleted file mode 100644
index 616d28b6..00000000
--- a/web_ui/src/islands/token-counter/bridge.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { TokenCounterState, initialTokenCounterState } from "./bridgeTypes";
-import { isQWebChannelAvailable, connectQWebChannel } from "../../lib/bridge-core/transport";
-import { validateTokenCounterState } from "../../lib/bridge-core/generated/token-counter-state";
-import {
- BridgeRejection,
- RejectionListener,
- parseIslandState,
-} from "../../lib/bridge-core/islandState";
-
-export type { BridgeRejection, RejectionListener };
-
-type StateListener = (state: TokenCounterState) => void;
-
-interface QtSignal {
- connect(listener: (value: T) => void): void;
- disconnect?: (listener: (value: T) => void) => void;
-}
-
-interface QtTokenCounterObject {
- stateChanged: QtSignal;
- ready: () => void;
-}
-
-export interface TokenCounterBridge {
- ready(): void;
- dispose(): void;
-}
-
-/**
- * Read-only display, the simplest of the three interaction shapes Phase 2's
- * pilot islands cover: no intents flow back to Python, so this bridge is
- * just parse-state-in + ready()/dispose() - no `call()` dispatcher, unlike
- * composer's bridge.ts, since there is nothing here to dispatch.
- */
-function parseState(payload: string) {
- return parseIslandState(payload, validateTokenCounterState);
-}
-
-class MockTokenCounterBridge implements TokenCounterBridge {
- private state: TokenCounterState = structuredClone(initialTokenCounterState);
- private readonly listener: StateListener;
-
- constructor(listener: StateListener) {
- this.listener = listener;
- }
-
- ready(): void {
- this.listener(this.state);
- }
-
- dispose(): void {}
-}
-
-export function createTokenCounterBridge(
- listener: StateListener,
- onRejection?: RejectionListener,
-): TokenCounterBridge {
- const fallback = new MockTokenCounterBridge(listener);
-
- if (!isQWebChannelAvailable()) {
- return fallback;
- }
-
- let remote: QtTokenCounterObject | null = null;
- let connected = false;
- const stateListener = (payload: string) => {
- const outcome = parseState(payload);
- if (outcome.ok) {
- listener(outcome.state);
- onRejection?.(null);
- return;
- }
- console.error(
- `[token-counter bridge] rejected payload (${outcome.rejection.kind}): ${outcome.rejection.reason}`,
- outcome.rejection.details,
- );
- onRejection?.(outcome.rejection);
- };
-
- connectQWebChannel((objects) => {
- remote = objects.tokenCounterBridge as QtTokenCounterObject;
- remote.stateChanged.connect(stateListener);
- connected = true;
- remote.ready();
- });
-
- return {
- ready: () => {
- if (connected && remote) remote.ready();
- },
- dispose: () => {
- remote?.stateChanged.disconnect?.(stateListener);
- remote = null;
- },
- };
-}
diff --git a/web_ui/src/islands/token-counter/bridgeTypes.ts b/web_ui/src/islands/token-counter/bridgeTypes.ts
deleted file mode 100644
index 44de3b06..00000000
--- a/web_ui/src/islands/token-counter/bridgeTypes.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * The token-counter 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:
- * initialTokenCounterState, the mock snapshot used for browser-preview/dev
- * and by jsdom tests.
- */
-export type { TokenCounterState } from "../../lib/bridge-core/generated/token-counter-state";
-
-import type { TokenCounterState } from "../../lib/bridge-core/generated/token-counter-state";
-
-export const initialTokenCounterState: TokenCounterState = {
- schemaVersion: 1,
- minCompatibleSchemaVersion: 1,
- revision: 0,
- inputTokens: 0,
- outputTokens: 0,
- contextTokens: 0,
- totalTokens: 0,
-};
diff --git a/web_ui/src/islands/token-counter/index.html b/web_ui/src/islands/token-counter/index.html
deleted file mode 100644
index 5ec6bf70..00000000
--- a/web_ui/src/islands/token-counter/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
- Graphlink Token Counter
-
-
-
-
-
-
diff --git a/web_ui/src/islands/token-counter/main.tsx b/web_ui/src/islands/token-counter/main.tsx
deleted file mode 100644
index bb58ae83..00000000
--- a/web_ui/src/islands/token-counter/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/token-counter/styles.css b/web_ui/src/islands/token-counter/styles.css
deleted file mode 100644
index a0dd90f6..00000000
--- a/web_ui/src/islands/token-counter/styles.css
+++ /dev/null
@@ -1,76 +0,0 @@
-/* Colors deliberately reuse EXISTING app-wide --gl-* tokens (graph-node
- panel fill / neutral-button border / neutral-button muted-icon) rather
- than capturing a new island-scoped token group, unlike composer's
- --gl-composer-*. This island's color needs (3 colors, a static HUD-style
- panel) are too small to be a meaningful second data point for the shared
- semantic vocabulary graphlink_styles.py's css_custom_properties()
- docstring defers until "island #2" - generalizing bg-0/1/2 from
- composer's 43 rich colors plus this panel's 3 utility colors would mostly
- just encode composer's shape. Deferred again; recorded here rather than
- silently. See graphlink_styles.py's _ISLAND_GROUPS docstring and
- tests/test_theme_tokens.py's two second-island tripwire tests, both left
- untouched by this island on purpose.
-
- One accepted visual difference from the old QWidget: the old panel had a
- semi-transparent rgba(45,45,45,0.7) background; solid --gl-graph-node-panel-fill
- is used instead, since expressing partial transparency through a CSS custom
- property would need a color-mix() (or similar) that's more machinery than
- this small a cosmetic difference is worth. */
-
-html,
-body,
-#root {
- margin: 0;
- padding: 0;
- background: transparent;
-}
-
-.token-counter-shell {
- width: 150px;
- box-sizing: border-box;
- padding: 6px 8px;
- background-color: var(--gl-graph-node-panel-fill);
- border: 1px solid var(--gl-neutral-button-border);
- border-radius: 6px;
- font-family: var(--gl-font-family, system-ui, sans-serif);
- font-size: 10px;
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.token-counter-row {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- line-height: 1.7;
-}
-
-.token-counter-label {
- color: var(--gl-neutral-button-muted-icon);
-}
-
-.token-counter-value {
- color: var(--gl-neutral-button-muted-icon);
- font-variant-numeric: tabular-nums;
-}
-
-.token-counter-shell.bridge-error {
- width: 220px;
-}
-
-.bridge-error-title {
- font-weight: 600;
- margin-bottom: 4px;
-}
-
-.bridge-error-reason {
- margin: 0 0 4px 0;
-}
-
-.bridge-error-details {
- margin: 0;
- padding-left: 16px;
-}
-
-.bridge-error-hint {
- margin: 4px 0 0 0;
-}
diff --git a/web_ui/src/islands/toolbar/App.test.tsx b/web_ui/src/islands/toolbar/App.test.tsx
deleted file mode 100644
index 258bfced..00000000
--- a/web_ui/src/islands/toolbar/App.test.tsx
+++ /dev/null
@@ -1,253 +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 { initialToolbarState } from "./bridgeTypes";
-import type { QtWindow } from "../../lib/bridge-core/transport";
-
-function installFakeQWebChannel() {
- const remote = {
- stateChanged: { connect: vi.fn(), disconnect: vi.fn() },
- ready: vi.fn(),
- reportAnchorRect: vi.fn(),
- openLibrary: vi.fn(),
- saveChat: vi.fn(),
- togglePins: vi.fn(),
- organizeNodes: vi.fn(),
- zoomIn: vi.fn(),
- zoomOut: vi.fn(),
- resetZoom: vi.fn(),
- fitAll: vi.fn(),
- toggleControls: vi.fn(),
- togglePlugins: vi.fn(),
- selectMode: vi.fn(),
- openSettings: vi.fn(),
- openAbout: vi.fn(),
- openHelp: vi.fn(),
- };
- class FakeQWebChannel {
- constructor(_t: unknown, cb: (channel: { objects: Record }) => void) {
- cb({ objects: { toolbarBridge: 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({
- ...initialToolbarState,
- revision: 1,
- modeOptions: ["Ollama (Local)", "llama.cpp (Local)", "API Endpoint"],
- currentMode: "Ollama (Local)",
- ...overrides,
- }),
- );
-}
-
-describe("App against the mock bridge", () => {
- afterEach(cleanup);
-
- it("renders every one of the 14 toolbar intents", () => {
- render( );
-
- for (const label of [
- "Library",
- "Save",
- "Pins",
- "Organize",
- "Zoom In",
- "Zoom Out",
- "Reset",
- "Fit All",
- "Controls",
- "About",
- "Settings",
- "Help",
- ]) {
- expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
- }
- expect(screen.getByRole("button", { name: /Plugins/ })).toBeInTheDocument();
- expect(screen.getByRole("combobox", { name: "Provider mode" })).toBeInTheDocument();
- });
-});
-
-describe("App against a real (faked) QWebChannel connection", () => {
- afterEach(() => {
- cleanup();
- uninstall();
- vi.restoreAllMocks();
- });
-
- it("reports all 4 anchor rects on mount", async () => {
- const remote = installFakeQWebChannel();
- render( );
-
- await screen.findByRole("button", { name: "Library" });
-
- const reportedNames = remote.reportAnchorRect.mock.calls.map((call) => call[0]);
- expect(reportedNames).toEqual(expect.arrayContaining(["pins", "plugins", "settings", "help"]));
- });
-
- it("pinsChecked reflects the server-published state, not local click state", async () => {
- const remote = installFakeQWebChannel();
- render( );
- push(remote, { pinsChecked: false });
-
- const pinsButton = await screen.findByRole("button", { name: "Pins" });
- expect(pinsButton).toHaveAttribute("aria-pressed", "false");
-
- push(remote, { pinsChecked: true });
- await waitFor(() => expect(pinsButton).toHaveAttribute("aria-pressed", "true"));
- });
-
- it("clicking Pins calls togglePins - the button never flips itself locally", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- const pinsButton = await screen.findByRole("button", { name: "Pins" });
-
- await user.click(pinsButton);
-
- expect(remote.togglePins).toHaveBeenCalledTimes(1);
- expect(pinsButton).toHaveAttribute("aria-pressed", "false");
- });
-
- it("clicking Controls fires toggleControls but never flips itself locally", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- const controlsButton = await screen.findByRole("button", { name: "Controls" });
- expect(controlsButton).toHaveAttribute("aria-pressed", "false");
-
- await user.click(controlsButton);
- expect(remote.toggleControls).toHaveBeenCalledWith(true);
- // Server-authoritative: no payload arrived, so the chip stays unpressed.
- expect(controlsButton).toHaveAttribute("aria-pressed", "false");
-
- push(remote, { activeSurface: "controls" });
- await waitFor(() => expect(controlsButton).toHaveAttribute("aria-pressed", "true"));
-
- await user.click(controlsButton);
- expect(remote.toggleControls).toHaveBeenLastCalledWith(false);
- // Still pressed until the server republishes the close.
- expect(controlsButton).toHaveAttribute("aria-pressed", "true");
- });
-
- it("a chip shows active when activeSurface matches and clears when it goes back to empty", async () => {
- const remote = installFakeQWebChannel();
- render( );
- push(remote, { activeSurface: "" });
-
- const chips: Array<[string, RegExp | string]> = [
- ["library", "Library"],
- ["controls", "Controls"],
- ["plugins", /Plugins/],
- ["settings", "Settings"],
- ["about", "About"],
- ["help", "Help"],
- ];
-
- for (const [surface, label] of chips) {
- const chip = await screen.findByRole("button", { name: label });
- expect(chip).toHaveAttribute("aria-pressed", "false");
-
- push(remote, { activeSurface: surface });
- await waitFor(() => expect(chip).toHaveAttribute("aria-pressed", "true"));
- expect(chip.className).toContain("checked");
-
- push(remote, { activeSurface: "" });
- await waitFor(() => expect(chip).toHaveAttribute("aria-pressed", "false"));
- expect(chip.className).not.toContain("checked");
- }
- });
-
- it("only the chip matching activeSurface is pressed, never its siblings", async () => {
- const remote = installFakeQWebChannel();
- render( );
- push(remote, { activeSurface: "settings" });
-
- const settings = await screen.findByRole("button", { name: "Settings" });
- await waitFor(() => expect(settings).toHaveAttribute("aria-pressed", "true"));
- for (const label of ["Library", "Controls", "About", "Help"]) {
- expect(screen.getByRole("button", { name: label })).toHaveAttribute("aria-pressed", "false");
- }
- expect(screen.getByRole("button", { name: /Plugins/ })).toHaveAttribute("aria-pressed", "false");
- });
-
- it("the mode select shows the real options and calls selectMode on change", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- const select = await screen.findByRole("combobox", { name: "Provider mode" });
- expect(select).toHaveValue("Ollama (Local)");
-
- await user.selectOptions(select, "API Endpoint");
-
- expect(remote.selectMode).toHaveBeenCalledWith("API Endpoint");
- });
-
- it("every simple action button calls its matching intent exactly once", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByRole("button", { name: "Library" });
-
- const cases: Array<[string, keyof Remote]> = [
- ["Library", "openLibrary"],
- ["Save", "saveChat"],
- ["Organize", "organizeNodes"],
- ["Zoom In", "zoomIn"],
- ["Zoom Out", "zoomOut"],
- ["Reset", "resetZoom"],
- ["Fit All", "fitAll"],
- ["About", "openAbout"],
- ["Settings", "openSettings"],
- ["Help", "openHelp"],
- ];
-
- for (const [label, method] of cases) {
- await user.click(screen.getByRole("button", { name: label }));
- expect(remote[method]).toHaveBeenCalledTimes(1);
- }
- });
-
- it("clicking Plugins calls togglePlugins", async () => {
- const remote = installFakeQWebChannel();
- const user = userEvent.setup();
- render( );
- push(remote);
- await screen.findByRole("button", { name: /Plugins/ });
-
- await user.click(screen.getByRole("button", { name: /Plugins/ }));
-
- expect(remote.togglePlugins).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({ ...initialToolbarState, minCompatibleSchemaVersion: 999 }));
-
- expect(await screen.findByRole("alert")).toBeInTheDocument();
- expect(screen.getByText("The toolbar is unavailable")).toBeInTheDocument();
- });
-});
diff --git a/web_ui/src/islands/toolbar/App.tsx b/web_ui/src/islands/toolbar/App.tsx
deleted file mode 100644
index fbae46a6..00000000
--- a/web_ui/src/islands/toolbar/App.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import { useEffect, useRef, useState } from "react";
-import { ToolbarState, initialToolbarState } from "./bridgeTypes";
-import { BridgeRejection, ToolbarBridge, createToolbarBridge } from "./bridge";
-import { BridgeErrorState } from "../../lib/ui/BridgeErrorState";
-
-// Named anchor points the 4 flyout-opening buttons report their own screen
-// rect for, replacing the native QToolButton reference show_for_anchor()
-// used to depend on directly - see graphlink_toolbar_bridge.py's own
-// AnchorRect docstring for the full rationale.
-const ANCHOR_NAMES = ["pins", "plugins", "settings", "help"] as const;
-type AnchorName = (typeof ANCHOR_NAMES)[number];
-
-function App() {
- const [state, setState] = useState(initialToolbarState);
- const [rejection, setRejection] = useState