From 2238db1e71c30d1d939437c64e6340307e794c64 Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Tue, 21 Jul 2026 16:09:13 -0400 Subject: [PATCH] feat(ui): integration setup guidance and settings mesh background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give every Integrations tile a clear next-step checklist, clarify auto-discovered “Detected” labels, and fill Settings whitespace with a subtle mouse-reactive mesh that never blocks clicks. --- .../src/components/IntegrationCard.tsx | 22 +- .../src/components/MeshBackground.tsx | 167 +++++++++++ apps/microbridge-ui/src/lib/hosts.test.ts | 17 ++ apps/microbridge-ui/src/lib/hosts.ts | 14 + .../src/lib/integrationSetup.test.ts | 49 +++- .../src/lib/integrationSetup.ts | 267 ++++++++++++++++++ apps/microbridge-ui/src/surfaces/Settings.tsx | 71 +++-- 7 files changed, 567 insertions(+), 40 deletions(-) create mode 100644 apps/microbridge-ui/src/components/MeshBackground.tsx diff --git a/apps/microbridge-ui/src/components/IntegrationCard.tsx b/apps/microbridge-ui/src/components/IntegrationCard.tsx index f8e07de..5b97530 100644 --- a/apps/microbridge-ui/src/components/IntegrationCard.tsx +++ b/apps/microbridge-ui/src/components/IntegrationCard.tsx @@ -147,9 +147,13 @@ export const IntegrationDetail = forwardRef< iconSrc?: string; diagnostic: string; theme: ThemeTokens; + guidance?: { title: string; steps: string[] } | null; children?: ReactNode; } ->(function IntegrationDetail({ name, iconSrc, diagnostic, theme, children }, ref) { +>(function IntegrationDetail( + { name, iconSrc, diagnostic, theme, guidance, children }, + ref, +) { return (
{diagnostic}
+ {guidance && guidance.steps.length > 0 && ( +
+
{guidance.title}
+
    + {guidance.steps.map((step) => ( +
  1. {step}
  2. + ))} +
+
+ )} {children} ); diff --git a/apps/microbridge-ui/src/components/MeshBackground.tsx b/apps/microbridge-ui/src/components/MeshBackground.tsx new file mode 100644 index 0000000..4fef10f --- /dev/null +++ b/apps/microbridge-ui/src/components/MeshBackground.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef } from "react"; + +type Node = { x: number; y: number }; + +/** Stable low-density lattice in normalized 0–1 space. */ +const NODES: Node[] = (() => { + const nodes: Node[] = []; + const cols = 6; + const rows = 5; + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + const jitterX = ((row * 17 + col * 31) % 7) / 100 - 0.03; + const jitterY = ((col * 13 + row * 23) % 7) / 100 - 0.03; + nodes.push({ + x: (col + 0.5) / cols + jitterX, + y: (row + 0.5) / rows + jitterY, + }); + } + } + return nodes; +})(); + +const EDGE_DIST = 0.22; +const INFLUENCE_PX = 120; + +/** + * Subtle mouse-reactive mesh behind Settings chrome. + * pointer-events-none — never blocks sidebar or tile clicks. + */ +export function MeshBackground({ + dark, + active = true, +}: { + dark: boolean; + active?: boolean; +}) { + const canvasRef = useRef(null); + const mouseRef = useRef<{ x: number; y: number } | null>(null); + const rafRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !active) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const parent = canvas.parentElement; + if (!parent) return; + + const baseAlpha = dark ? 0.055 : 0.07; + const hotAlpha = dark ? 0.18 : 0.22; + const stroke = dark ? "245,245,244" : "13,13,13"; + + const resize = () => { + const rect = parent.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.max(1, Math.floor(rect.width * dpr)); + canvas.height = Math.max(1, Math.floor(rect.height * dpr)); + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + }; + + resize(); + const observer = new ResizeObserver(resize); + observer.observe(parent); + + const onMove = (event: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + mouseRef.current = { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + }; + }; + const onLeave = () => { + mouseRef.current = null; + }; + + parent.addEventListener("mousemove", onMove); + parent.addEventListener("mouseleave", onLeave); + + const draw = () => { + if (document.hidden) { + rafRef.current = requestAnimationFrame(draw); + return; + } + + const width = canvas.clientWidth; + const height = canvas.clientHeight; + ctx.clearRect(0, 0, width, height); + + const points = NODES.map((node) => ({ + x: node.x * width, + y: node.y * height, + })); + const mouse = mouseRef.current; + + for (let i = 0; i < points.length; i += 1) { + for (let j = i + 1; j < points.length; j += 1) { + const a = points[i]!; + const b = points[j]!; + const dx = a.x - b.x; + const dy = a.y - b.y; + const distNorm = Math.hypot(dx / width, dy / height); + if (distNorm > EDGE_DIST) continue; + + let alpha = baseAlpha * (1 - distNorm / EDGE_DIST); + if (mouse) { + const midX = (a.x + b.x) / 2; + const midY = (a.y + b.y) / 2; + const near = Math.hypot(midX - mouse.x, midY - mouse.y); + if (near < INFLUENCE_PX) { + const t = 1 - near / INFLUENCE_PX; + alpha = Math.min(hotAlpha, alpha + t * (hotAlpha - baseAlpha)); + } + } + + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.strokeStyle = `rgba(${stroke},${alpha.toFixed(3)})`; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + + for (const point of points) { + let alpha = baseAlpha * 1.4; + let radius = 1.15; + if (mouse) { + const near = Math.hypot(point.x - mouse.x, point.y - mouse.y); + if (near < INFLUENCE_PX) { + const t = 1 - near / INFLUENCE_PX; + alpha = Math.min(hotAlpha + 0.05, alpha + t * 0.14); + radius = 1.15 + t * 0.9; + } + } + ctx.beginPath(); + ctx.arc(point.x, point.y, radius, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${stroke},${alpha.toFixed(3)})`; + ctx.fill(); + } + + rafRef.current = requestAnimationFrame(draw); + }; + + rafRef.current = requestAnimationFrame(draw); + + return () => { + cancelAnimationFrame(rafRef.current); + observer.disconnect(); + parent.removeEventListener("mousemove", onMove); + parent.removeEventListener("mouseleave", onLeave); + }; + }, [active, dark]); + + if (!active) return null; + + return ( + + ); +} diff --git a/apps/microbridge-ui/src/lib/hosts.test.ts b/apps/microbridge-ui/src/lib/hosts.test.ts index 85a0e2a..3d43c1b 100644 --- a/apps/microbridge-ui/src/lib/hosts.test.ts +++ b/apps/microbridge-ui/src/lib/hosts.test.ts @@ -113,11 +113,28 @@ describe("integrationView", () => { diagnostic: "The bundled OpenCode integration is installed.", }), [], + { enabled: true }, ); expect(view.label).toBe("Setup needed"); expect(view.connectedGroup).toBe(false); }); + it("labels auto-discovered needs_setup when disabled as Detected", () => { + const view = integrationView( + adapter({ + id: "cursor", + display_name: "Cursor", + kind: "community", + state: "needs_setup", + diagnostic: "Cursor detected on local machine.", + }), + [], + { enabled: false }, + ); + expect(view.label).toBe("Detected — click to install"); + expect(view.connectedGroup).toBe(false); + }); + it("maps adapter errors to red", () => { const view = integrationView( adapter({ diff --git a/apps/microbridge-ui/src/lib/hosts.ts b/apps/microbridge-ui/src/lib/hosts.ts index 32ce1d3..03e583e 100644 --- a/apps/microbridge-ui/src/lib/hosts.ts +++ b/apps/microbridge-ui/src/lib/hosts.ts @@ -92,10 +92,14 @@ function connectedGroupForState(state: AdapterConnectionState): boolean { /** * Derive the card's traffic light, label, and diagnostic from daemon adapter * state plus live session attribution. + * + * Pass `enabled` from config when known so auto-discovered (needs_setup + not + * enabled) tiles read as “Detected — click to install” instead of “Setup needed”. */ export function integrationView( adapter: AdapterStatus, sessions: SessionStatus[], + options?: { enabled?: boolean }, ): IntegrationView { const journalApp = journalAppFor(adapter.id); const presence = journalApp @@ -157,6 +161,16 @@ export function integrationView( }; } + // Auto-discovered on disk but not yet enabled/installed via first click. + if (adapter.state === "needs_setup" && options?.enabled === false) { + return { + light: "yellow", + label: "Detected — click to install", + diagnostic: adapter.diagnostic, + connectedGroup: false, + }; + } + const light = lightForState(adapter.state); return { light, diff --git a/apps/microbridge-ui/src/lib/integrationSetup.test.ts b/apps/microbridge-ui/src/lib/integrationSetup.test.ts index 403b6af..1ffc5ac 100644 --- a/apps/microbridge-ui/src/lib/integrationSetup.test.ts +++ b/apps/microbridge-ui/src/lib/integrationSetup.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { setupNextStep } from "./integrationSetup"; +import { + integrationGuidance, + setupNextStep, +} from "./integrationSetup"; import { openableHostApp } from "./openHostApp"; describe("setupNextStep", () => { @@ -13,6 +16,50 @@ describe("setupNextStep", () => { }); }); +describe("integrationGuidance", () => { + it("guides Cursor through enable → reload → events", () => { + const disabled = integrationGuidance("cursor", "disabled"); + expect(disabled?.primaryAction).toBe("enable"); + expect(disabled?.steps[0]).toContain("install"); + + const setup = integrationGuidance("cursor", "needs_setup", { + enabled: true, + }); + expect(setup?.title).toContain("Cursor"); + expect(setup?.steps.some((step) => step.includes("Reload"))).toBe(true); + expect(setup?.primaryAction).toBe("open_app"); + }); + + it("treats auto-discovered needs_setup + disabled as install CTA", () => { + const detected = integrationGuidance("cursor", "needs_setup", { + enabled: false, + }); + expect(detected?.title).toContain("Detected"); + expect(detected?.primaryAction).toBe("enable"); + }); + + it("covers T3 pairing, CNVS start, and idle Synara", () => { + const t3 = integrationGuidance("t3code", "needs_setup", { enabled: true }); + expect(t3?.primaryAction).toBe("pair"); + expect(t3?.steps.some((step) => step.includes("Network access"))).toBe( + true, + ); + + const cnvs = integrationGuidance("cnvs", "needs_setup"); + expect(cnvs?.steps[0]).toContain("CNVS"); + + const synara = integrationGuidance("synara", "connected", { + label: "Idle", + }); + expect(synara?.steps[0]).toContain("Idle is normal"); + }); + + it("explains always-on Codex/Claude watchers", () => { + const codex = integrationGuidance("codex", "connected"); + expect(codex?.steps[0]).toContain("always on"); + }); +}); + describe("openableHostApp", () => { it("names apps we can open from Integrations", () => { expect(openableHostApp("cursor")).toBe("Cursor"); diff --git a/apps/microbridge-ui/src/lib/integrationSetup.ts b/apps/microbridge-ui/src/lib/integrationSetup.ts index 8ce77b3..d34367b 100644 --- a/apps/microbridge-ui/src/lib/integrationSetup.ts +++ b/apps/microbridge-ui/src/lib/integrationSetup.ts @@ -1,3 +1,5 @@ +import type { AdapterConnectionState } from "./types"; + /** Short host checklist after install / while needs_setup. */ export function setupNextStep(adapterId: string): string | null { switch (adapterId) { @@ -13,3 +15,268 @@ export function setupNextStep(adapterId: string): string | null { return null; } } + +export type GuidancePrimaryAction = + | "open_app" + | "enable" + | "pair" + | "none"; + +export interface IntegrationGuidance { + title: string; + steps: string[]; + primaryAction: GuidancePrimaryAction; +} + +/** + * Structured next-step guidance for Integrations detail panels. + * Covers setup, idle hosts, limited community adapters, and always-on watchers. + */ +export function integrationGuidance( + adapterId: string, + state: AdapterConnectionState, + options?: { enabled?: boolean; label?: string }, +): IntegrationGuidance | null { + const enabled = options?.enabled; + const label = options?.label; + + // Auto-discovered on disk but not yet installed via first click. + if ( + state === "needs_setup" && + enabled === false && + (adapterId === "cursor" || + adapterId === "factory" || + adapterId === "opencode" || + adapterId === "t3code") + ) { + return { + title: "Detected on this Mac", + steps: [ + "Click this tile (or Enable) to install the Microbridge integration.", + adapterId === "t3code" + ? "Then enable Network access in T3 Code → Settings → Connections and paste a pairing link." + : adapterId === "cursor" + ? "Reload Cursor’s window so the bundled hooks load, then use Cursor normally." + : adapterId === "opencode" + ? "Restart OpenCode so the Microbridge plugin loads." + : "Start or continue a Factory Droid session — lifecycle events connect automatically.", + ], + primaryAction: "enable", + }; + } + + switch (adapterId) { + case "cursor": + if (state === "disabled") { + return { + title: "Enable Cursor", + steps: [ + "Click this tile to install the bundled Cursor plugin.", + "Reload Cursor’s window (or quit and reopen) so hooks load.", + "Use Cursor — Microbridge turns Limited/Connected after lifecycle events arrive.", + ], + primaryAction: "enable", + }; + } + if (state === "needs_setup" || state === "connecting") { + return { + title: "Finish Cursor setup", + steps: [ + "Reload Cursor’s window (or quit and reopen Cursor) so the bundled hooks load.", + "Open or continue a Cursor agent thread so Microbridge receives lifecycle events.", + "Expect Limited (lifecycle) or Connected once hooks are talking to the daemon.", + ], + primaryAction: "open_app", + }; + } + if (state === "limited") { + return { + title: "Cursor is partially connected", + steps: [ + "Lifecycle is live — approve/reject and interrupt may be limited by Cursor’s capabilities.", + "Keep using Cursor; Microbridge will show threads as they appear.", + ], + primaryAction: "open_app", + }; + } + return null; + + case "factory": + if (state === "disabled") { + return { + title: "Enable Factory", + steps: [ + "Click this tile to install Factory hooks.", + "Start or continue a Factory Droid session — lifecycle events connect automatically.", + ], + primaryAction: "enable", + }; + } + if (state === "needs_setup" || state === "connecting") { + return { + title: "Finish Factory setup", + steps: [ + "Start or continue a Factory Droid session — lifecycle events connect automatically.", + "No separate pairing step; the status turns Limited/Connected after the first events.", + ], + primaryAction: "none", + }; + } + if (state === "limited") { + return { + title: "Factory is partially connected", + steps: [ + "Lifecycle is live. Keep Droid sessions running to see threads in Microbridge.", + ], + primaryAction: "none", + }; + } + return null; + + case "opencode": + if (state === "disabled") { + return { + title: "Enable OpenCode", + steps: [ + "Click this tile to install the Microbridge OpenCode plugin.", + "Restart OpenCode (CLI or app) so the plugin loads.", + ], + primaryAction: "enable", + }; + } + if (state === "needs_setup" || state === "connecting") { + return { + title: "Finish OpenCode setup", + steps: [ + "Restart OpenCode (CLI or app) so the Microbridge plugin loads.", + "Run an OpenCode session — status turns Connected when the plugin says hello.", + ], + primaryAction: "open_app", + }; + } + return null; + + case "t3code": + if (state === "disabled") { + return { + title: "Enable T3 Code", + steps: [ + "Click this tile to enable the T3 Code integration.", + "In T3 Code → Settings → Connections, enable Network access.", + "Paste a one-time pairing link below and Pair.", + ], + primaryAction: "enable", + }; + } + if (state === "needs_setup" || state === "connecting") { + return { + title: "Pair T3 Code", + steps: [ + "In T3 Code → Settings → Connections, enable Network access.", + "Paste a one-time pairing link below and click Pair.", + "Microbridge stores the credential and connects to your approved environment.", + ], + primaryAction: "pair", + }; + } + if (state === "incompatible") { + return { + title: "Update required", + steps: [ + "This T3 Code server version is not supported.", + "Update Microbridge and/or T3 Code, then pair again with a fresh link.", + ], + primaryAction: "pair", + }; + } + return null; + + case "cnvs": + if (state === "needs_setup" || state === "disabled") { + return { + title: "Start CNVS", + steps: [ + "Launch CNVS on this Mac — Microbridge connects automatically via the local loopback API.", + "No pairing or plugin install is required.", + ], + primaryAction: "none", + }; + } + if (state === "limited") { + return { + title: "CNVS is partially connected", + steps: [ + "CNVS is reachable but some canvases could not be refreshed.", + "Check CNVS is running and try focusing a canvas from Microbridge.", + ], + primaryAction: "none", + }; + } + return null; + + case "codex": + case "claude": + if (state === "connected" || state === "limited") { + return { + title: + adapterId === "codex" + ? "Codex CLI watcher" + : "Claude Code watcher", + steps: [ + "This integration is always on — Microbridge watches local session journals.", + "Run Codex or Claude Code to see threads appear; host apps (ChatGPT, Synara, etc.) share the same journals.", + ], + primaryAction: "none", + }; + } + if (state === "disabled" || state === "needs_setup") { + return { + title: "Re-enable in config", + steps: [ + "This built-in watcher is disabled in ~/.microbridge/config.toml.", + "Set the adapter enabled and restart the Microbridge service.", + ], + primaryAction: "none", + }; + } + return null; + + case "synara": + case "chatgpt": + case "claude_desktop": + case "conductor": { + const hostName = + adapterId === "synara" + ? "Synara" + : adapterId === "chatgpt" + ? "ChatGPT" + : adapterId === "claude_desktop" + ? "Claude Desktop" + : "Conductor"; + if (label === "Idle" || state === "connected" || state === "limited") { + return { + title: `${hostName} via journals`, + steps: [ + `Idle is normal — start ${hostName} (or use Claude/Codex through it).`, + "Sessions appear automatically from Claude & Codex journals; no separate adapter or pairing.", + ], + primaryAction: "none", + }; + } + if (state === "disabled") { + return { + title: "Disabled in config", + steps: [ + `${hostName} attribution is disabled in ~/.microbridge/config.toml.`, + "Re-enable it there and restart Microbridge if you want these sessions listed.", + ], + primaryAction: "none", + }; + } + return null; + } + + default: + return null; + } +} diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx index cb07c2c..72b71e3 100644 --- a/apps/microbridge-ui/src/surfaces/Settings.tsx +++ b/apps/microbridge-ui/src/surfaces/Settings.tsx @@ -37,7 +37,8 @@ import { } from "../lib/hosts"; import { integrationIcon } from "../lib/integrationIcons"; import { openHostApp, openableHostApp } from "../lib/openHostApp"; -import { setupNextStep } from "../lib/integrationSetup"; +import { integrationGuidance } from "../lib/integrationSetup"; +import { MeshBackground } from "../components/MeshBackground"; const LIGHTING_STATES: { id: keyof StateColors; label: string }[] = [ { id: "idle", label: "Idle" }, @@ -203,15 +204,16 @@ export function Settings({ return (
+ -
+
{tab === "general" && (

General

@@ -626,7 +628,9 @@ export function Settings({ ) .map((adapter) => ({ adapter, - view: integrationView(adapter, snapshot.sessions), + view: integrationView(adapter, snapshot.sessions, { + enabled: cfg.adapters[adapter.id]?.enabled, + }), optIn: adapter.kind === "community" && !isHostAttributed(adapter.id), })); @@ -646,8 +650,11 @@ export function Settings({ ]; const activeId = selectedIntegration; const selected = views.find((item) => item.adapter.id === activeId); - const nextStep = selected - ? setupNextStep(selected.adapter.id) + const guidance = selected + ? integrationGuidance(selected.adapter.id, selected.adapter.state, { + enabled: cfg.adapters[selected.adapter.id]?.enabled, + label: selected.view.label, + }) : null; const openAppLabel = selected ? openableHostApp(selected.adapter.id) @@ -656,15 +663,16 @@ export function Settings({

Integrations

- Click any tile for details. Cursor, Factory, T3 Code, and OpenCode - install on first click when they're off; they turn green or - Limited only after that host talks to Microbridge (reload / - restart / pair). + Click any tile for details and next steps. Cursor, Factory, T3 + Code, and OpenCode install on first click when they're off; + they turn green or Limited only after that host talks to + Microbridge (reload / restart / pair).

- Synara and the desktop apps share Claude/Codex journals — no - separate adapter. For T3 controls, enable Network access in T3 Code - Settings → Connections, then paste a pairing link below. + Synara and the desktop apps share Claude/Codex journals — Idle + means waiting for sessions, not a broken setup. For T3 controls, + enable Network access in T3 Code Settings → Connections, then + paste a pairing link below.

{adapterMessage && (

@@ -702,7 +710,12 @@ export function Settings({ busy={adapterBusy.has(adapter.id)} onSelect={() => { selectIntegration(adapter.id); - if (optIn && adapter.state === "disabled") { + if ( + optIn && + (adapter.state === "disabled" || + (adapter.state === "needs_setup" && + cfg.adapters[adapter.id]?.enabled === false)) + ) { requestAnimationFrame(() => { void runAdapterOperation(adapter.id, () => setAdapterEnabled(adapter.id, true), @@ -736,30 +749,12 @@ export function Settings({ iconSrc={integrationIcon(selected.adapter.id)} diagnostic={selected.view.diagnostic} theme={t} + guidance={ + guidance + ? { title: guidance.title, steps: guidance.steps } + : null + } > - {(selected.adapter.state === "needs_setup" || - selected.adapter.state === "disabled") && - nextStep && ( -

-
- {selected.adapter.state === "disabled" - ? "Do this next (after install)" - : "Do this next"} -
-

{nextStep}

- {selected.adapter.state === "needs_setup" && ( -

- {selected.view.diagnostic} -

- )} -
- )}
{CAPABILITIES.map((capability) => (