diff --git a/apps/microbridge-ui/src/components/IntegrationCard.tsx b/apps/microbridge-ui/src/components/IntegrationCard.tsx new file mode 100644 index 0000000..3b92751 --- /dev/null +++ b/apps/microbridge-ui/src/components/IntegrationCard.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; +import type { ThemeTokens } from "../lib/theme"; +import { + TRAFFIC_COLORS, + type TrafficLight, +} from "../lib/hosts"; + +export function IntegrationCard({ + name, + badge, + diagnostic, + light, + label, + theme, + children, +}: { + name: string; + badge?: string; + diagnostic: string; + light: TrafficLight; + label: string; + theme: ThemeTokens; + children?: ReactNode; +}) { + const colors = TRAFFIC_COLORS[light]; + return ( +
  • +
    +
    +
    + + {name} + {badge ? ( + + {badge} + + ) : null} +
    +
    + {diagnostic} +
    +
    + + {label} + +
    + {children} +
  • + ); +} diff --git a/apps/microbridge-ui/src/lib/hosts.test.ts b/apps/microbridge-ui/src/lib/hosts.test.ts new file mode 100644 index 0000000..cf70ad5 --- /dev/null +++ b/apps/microbridge-ui/src/lib/hosts.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import type { AdapterStatus, SessionStatus } from "./types"; +import { hostPresence, integrationView } from "./hosts"; + +function session(app: string, id = "1"): SessionStatus { + return { + id, + app, + title: "Thread", + state: "working", + updated_at_ms: 100, + }; +} + +function adapter( + partial: Partial & Pick, +): AdapterStatus { + return { + kind: "native", + capabilities: { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Built-in lifecycle watcher is active.", + ...partial, + }; +} + +describe("hostPresence", () => { + it("counts sessions for one app", () => { + const presence = hostPresence( + [session("Synara", "a"), session("Codex CLI", "b"), session("Synara", "c")], + "Synara", + ); + expect(presence.count).toBe(2); + }); +}); + +describe("integrationView", () => { + it("marks Synara green when sessions are live", () => { + const view = integrationView( + adapter({ id: "synara", display_name: "Synara", state: "connected" }), + [session("Synara")], + ); + expect(view.light).toBe("green"); + expect(view.label).toContain("Active"); + expect(view.connectedGroup).toBe(true); + expect(view.diagnostic).toContain("no separate adapter"); + }); + + it("marks Synara yellow while waiting for sessions", () => { + const view = integrationView( + adapter({ id: "synara", display_name: "Synara", state: "connected" }), + [], + ); + expect(view.light).toBe("yellow"); + expect(view.label).toBe("Waiting"); + expect(view.connectedGroup).toBe(false); + }); + + it("flags disabled Cursor when journal sessions already exist", () => { + const view = integrationView( + adapter({ + id: "cursor", + display_name: "Cursor", + kind: "community", + state: "disabled", + diagnostic: "Disabled until you explicitly enable this integration.", + }), + [session("Cursor"), session("Cursor", "2")], + ); + expect(view.light).toBe("yellow"); + expect(view.diagnostic).toContain("2 threads auto-detected"); + }); + + it("keeps Claude Code green when the watcher is connected", () => { + const view = integrationView( + adapter({ id: "claude", display_name: "Claude Code", state: "connected" }), + [], + ); + expect(view.light).toBe("green"); + expect(view.label).toBe("Connected"); + }); + + it("maps adapter errors to red", () => { + const view = integrationView( + adapter({ + id: "t3code", + display_name: "T3 Code", + kind: "community", + state: "error", + diagnostic: "Pairing failed.", + }), + [], + ); + expect(view.light).toBe("red"); + expect(view.label).toBe("Error"); + }); +}); diff --git a/apps/microbridge-ui/src/lib/hosts.ts b/apps/microbridge-ui/src/lib/hosts.ts new file mode 100644 index 0000000..e46d29b --- /dev/null +++ b/apps/microbridge-ui/src/lib/hosts.ts @@ -0,0 +1,171 @@ +import type { + AdapterConnectionState, + AdapterStatus, + SessionStatus, +} from "./types"; + +/** Traffic-light glance for Integrations cards. */ +export type TrafficLight = "green" | "yellow" | "red"; + +/** + * Hosts that ride Claude/Codex journals — always listed as Integrations cards, + * but status is session-derived (no separate pairable adapter). + */ +export const HOST_ATTRIBUTED: ReadonlyArray<{ + id: string; + app: string; +}> = [ + { id: "synara", app: "Synara" }, + { id: "chatgpt", app: "ChatGPT" }, + { id: "claude_desktop", app: "Claude Desktop" }, + { id: "conductor", app: "Conductor" }, +]; + +/** Opt-in sources that can still receive journal-attributed sessions before enable. */ +const JOURNAL_APP_BY_ADAPTER: Record = { + cursor: "Cursor", + t3code: "T3 Code", + factory: "Factory", +}; + +export interface HostPresence { + count: number; + lastActivityMs: number | null; +} + +export function hostPresence( + sessions: SessionStatus[], + appName: string, +): HostPresence { + let count = 0; + let lastActivityMs: number | null = null; + for (const session of sessions) { + if (session.app !== appName) continue; + count += 1; + if (lastActivityMs === null || session.updated_at_ms > lastActivityMs) { + lastActivityMs = session.updated_at_ms; + } + } + return { count, lastActivityMs }; +} + +export function isHostAttributed(adapterId: string): boolean { + return HOST_ATTRIBUTED.some((host) => host.id === adapterId); +} + +function journalAppFor(adapterId: string): string | undefined { + return ( + HOST_ATTRIBUTED.find((host) => host.id === adapterId)?.app ?? + JOURNAL_APP_BY_ADAPTER[adapterId] + ); +} + +export interface IntegrationView { + light: TrafficLight; + label: string; + diagnostic: string; + /** True when the card should sit in the Connected group. */ + connectedGroup: boolean; +} + +const STATE_LABELS: Record = { + disabled: "Not connected", + needs_setup: "Waiting", + connecting: "Connecting", + connected: "Connected", + limited: "Limited", + incompatible: "Incompatible", + error: "Error", +}; + +function lightForState(state: AdapterConnectionState): TrafficLight { + if (state === "connected") return "green"; + if (state === "error" || state === "incompatible") return "red"; + return "yellow"; +} + +/** + * Derive the card's traffic light, label, and diagnostic from daemon adapter + * state plus live session attribution. + */ +export function integrationView( + adapter: AdapterStatus, + sessions: SessionStatus[], +): IntegrationView { + const journalApp = journalAppFor(adapter.id); + const presence = journalApp + ? hostPresence(sessions, journalApp) + : { count: 0, lastActivityMs: null }; + + if (isHostAttributed(adapter.id)) { + if (adapter.state === "disabled") { + return { + light: "yellow", + label: "Not connected", + diagnostic: "Disabled in Microbridge configuration.", + connectedGroup: false, + }; + } + if (adapter.state === "error" || adapter.state === "incompatible") { + return { + light: "red", + label: STATE_LABELS[adapter.state], + diagnostic: adapter.diagnostic, + connectedGroup: false, + }; + } + if (presence.count > 0) { + return { + light: "green", + label: + presence.count === 1 + ? "Active · 1 thread" + : `Active · ${presence.count} threads`, + diagnostic: + "via Claude & Codex journals — no separate adapter needed.", + connectedGroup: true, + }; + } + return { + light: "yellow", + label: "Waiting", + diagnostic: + "via Claude & Codex journals — no separate adapter needed. Waiting for sessions.", + connectedGroup: false, + }; + } + + // Opt-in sources: journal sessions already flowing while the card is disabled. + if ( + adapter.state === "disabled" && + presence.count > 0 && + JOURNAL_APP_BY_ADAPTER[adapter.id] + ) { + return { + light: "yellow", + label: "Not connected", + diagnostic: + presence.count === 1 + ? "1 thread auto-detected — enable for controls." + : `${presence.count} threads auto-detected — enable for controls.`, + connectedGroup: false, + }; + } + + const light = lightForState(adapter.state); + return { + light, + label: STATE_LABELS[adapter.state], + diagnostic: adapter.diagnostic, + connectedGroup: light === "green", + }; +} + +export const TRAFFIC_COLORS: Record< + TrafficLight, + { bg: string; fg: string; dot: string } +> = { + green: { bg: "#30C4631F", fg: "#30A653", dot: "#30C463" }, + yellow: { bg: "#FFB0001F", fg: "#C48400", dot: "#FFB000" }, + red: { bg: "#FF453A1F", fg: "#D93A32", dot: "#FF453A" }, +}; diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx index 41868ca..cb768ae 100644 --- a/apps/microbridge-ui/src/surfaces/Settings.tsx +++ b/apps/microbridge-ui/src/surfaces/Settings.tsx @@ -26,6 +26,8 @@ import { launchAtLoginEnabled, setLaunchAtLogin, } from "../lib/autostart"; +import { IntegrationCard } from "../components/IntegrationCard"; +import { TRAFFIC_COLORS, integrationView } from "../lib/hosts"; const LIGHTING_STATES: { id: keyof StateColors; label: string }[] = [ { id: "idle", label: "Idle" }, @@ -60,16 +62,6 @@ const INTEGRATION_ORDER = [ "opencode", ]; -const INTEGRATION_STATE_LABELS = { - disabled: "Not connected", - needs_setup: "Waiting", - connecting: "Connecting", - connected: "Connected", - limited: "Limited", - incompatible: "Incompatible", - error: "Error", -} as const; - const KEY_SOURCES: { id: DaemonConfig["key_source"]; label: string; @@ -600,81 +592,68 @@ export function Settings({ )} - {tab === "integrations" && ( + {tab === "integrations" && (() => { + const views = [...snapshot.adapters] + .sort( + (left, right) => + INTEGRATION_ORDER.indexOf(left.id) - + INTEGRATION_ORDER.indexOf(right.id), + ) + .map((adapter) => ({ + adapter, + view: integrationView(adapter, snapshot.sessions), + })); + const groups = [ + { + label: "Connected", + light: "green" as const, + items: views.filter((item) => item.view.connectedGroup), + }, + { + label: "Not connected", + light: "yellow" as const, + items: views.filter((item) => !item.view.connectedGroup), + }, + ]; + return (

    Integrations

    - Every supported app is listed here. Green integrations are ready; - everything else shows exactly what it needs. ChatGPT, Claude - Desktop, Synara, Conductor, and CNVS are built in. Cursor, Factory, - T3 Code, and OpenCode connect only after you choose to enable them. + Every supported app is its own card. Green means live threads or a + healthy watcher; yellow means waiting or setup; red means error. + ChatGPT, Claude Desktop, Synara, Conductor, and CNVS are built in. + Cursor, Factory, T3 Code, and OpenCode connect after you enable them.

    - CNVS-hosted Codex and Claude terminals replace matching raw journal - cards while CNVS owns them. OpenCode uses its official global plugin - API for lifecycle and interrupt. T3-hosted threads are identified automatically. For controls, enable - Network access in T3 Code Settings → Connections, create a link under - Authorized clients, then paste it below. Factory hooks are merged - without replacing your existing hooks. + Synara and the desktop apps share Claude/Codex journals — they do + not need a separate adapter. CNVS-hosted terminals replace matching + raw journal cards while CNVS owns them. OpenCode uses its official + global plugin API. For T3 controls, enable Network access in T3 Code + Settings → Connections, create a link under Authorized clients, then + paste it below. Factory hooks are merged without replacing yours.

    {adapterMessage && (

    {adapterMessage}

    )} - {[ - { - label: "Connected", - connected: true, - integrations: snapshot.adapters.filter((item) => item.state === "connected"), - }, - { - label: "Not connected", - connected: false, - integrations: snapshot.adapters.filter((item) => item.state !== "connected"), - }, - ].map((group) => ( + {groups.map((group) => (
    - - {group.label} · {group.integrations.length} + + {group.label} · {group.items.length}
      - {[...group.integrations] - .sort((left, right) => INTEGRATION_ORDER.indexOf(left.id) - INTEGRATION_ORDER.indexOf(right.id)) - .map((adapter) => ( -
    • ( + -
      -
      -
      - {adapter.display_name} -
      -
      - {adapter.diagnostic} -
      -
      - - {INTEGRATION_STATE_LABELS[adapter.state]} - -
      {CAPABILITIES.map((capability) => ( )}
      -
    • + ))}
    ))}
    - )} + ); + })()} {tab === "updates" && (
    diff --git a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx index 99a6776..9e6564c 100644 --- a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx +++ b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx @@ -45,6 +45,22 @@ function snapshot(sessions: SessionStatus[] = []): Snapshot { frontmost_app: null, }, adapters: [ + { + id: "synara", + display_name: "Synara", + kind: "native", + state: "connected", + capabilities: { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Built-in lifecycle watcher is active.", + }, { id: "cnvs", display_name: "CNVS", @@ -117,17 +133,46 @@ describe("Settings", () => { expect(html).toContain("Lifecycle is connected"); expect(html).toContain("Live state"); expect(html).toContain("Integrations"); + // Synara waits (yellow) with no sessions; CNVS stays connected. expect(html).toContain("Connected · 1"); - expect(html).toContain("Not connected · 1"); + expect(html).toContain("Not connected · 2"); + expect(html).toContain("Waiting"); + expect(html).toContain("Synara"); + expect(html).toContain("no separate adapter needed"); expect(html).toContain("Connected across 3 exact canvas terminal targets"); expect(html).toContain("✓ Open"); expect(html).toContain("Interrupt"); - expect(html).toContain("OpenCode uses its official global plugin"); + expect(html).toContain("OpenCode uses its official"); expect(html).toContain("Repair bundled integration"); + expect(html).toContain("Green means live"); expect(html).not.toContain("Install managed plugin"); expect(html).not.toContain("scaffold only"); expect(html).not.toContain("not production"); }); + + it("shows Synara as Active when sessions are attributed", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("Active · 1 thread"); + expect(html).toContain("Connected · 2"); + expect(html).toContain("Not connected · 1"); + }); }); describe("Popover", () => { diff --git a/docs/adapters.md b/docs/adapters.md index c033507..a84c7b1 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -58,3 +58,17 @@ host-managed so each host owns hook execution and Microbridge owns only the entries it installs. CNVS is daemon-owned because its canvas/node identity and short-lived local token must remain inside the same routing boundary. Synara and Conductor reuse the built-in journal watchers. + +## Hosts vs adapters (Settings → Integrations) + +**Adapters / session sources** publish or watch state (Claude Code, Codex CLI, +CNVS, Cursor, T3 Code, Factory, OpenCode). They may have Enable / Pair / +Disconnect actions. + +**Host-attributed apps** (Synara, ChatGPT, Claude Desktop, Conductor) are not +separate pairable adapters. They share `~/.claude/projects` and +`~/.codex/sessions`; the built-in watchers label sessions by `entrypoint` / +`originator` / cwd. Settings still shows each as its own Integrations card with +a green / yellow / red status derived from live threads — do **not** open a PR +that adds a Synara (or ChatGPT) pairing adapter unless the host publishes a +distinct control API. diff --git a/docs/design/README.md b/docs/design/README.md index a2475b3..03a4ad8 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -131,8 +131,10 @@ Keyboard setup. Four sections in a left rail: - **Agent Keys** — live "six keys, six threads" view, key source, deck focus mode (auto/pinned), app priority order, approvals-interrupt toggle (policy only — no live approve UI) -- **Integrations** — one complete catalog grouped by connected and not connected, - with enable, repair, disconnect, and remove actions only where applicable +- **Integrations** — one card per supported app, grouped by connected / not + connected, with green / yellow / red status. Host-attributed apps (Synara, + ChatGPT, Claude Desktop, Conductor) are status-only; enable / repair / + disconnect / remove apply only to session sources that need them - **Device** — Appearance (System/Light/Dark), Lighting (Codex defaults + Phosphor preset + reset), brightness, LED test, sleep timer (default 3 min), firmware, zero-network note