diff --git a/.gitignore b/.gitignore index 59d0d64..818014b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ apps/microbridge-ui/src-tauri/binaries/ # local tooling / debug artifacts (not part of the public project) .playwright-mcp/ /settings-keys-twin.png + +# Cursor IDE hook runtime state (machine-local paths; do not commit) +.cursor/hooks/state/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bda3221 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +## Learned User Preferences + +- Aim for Claude-parity Cursor integration (approve/reject/interrupt/focus/new session), not lifecycle-only Limited status. +- When shipping a Microbridge release, confirm version bumps and that DMG (and other release) assets were published—not only that the PR merged. +- Do not admin-bypass failing required CI on Microbridge merges; CodeRabbit rate-limit failures alone are non-blocking. + +## Learned Workspace Facts + +- Local checkout `t3PRHelp` is the GitHub repo `DevVig/microbridge` (Tauri UI in `apps/microbridge-ui`, daemon in `crates/microbridged`). +- Integrations split into community adapters (Cursor, Factory, T3 Code, OpenCode) and host-attributed/native hosts (Claude Code, Codex, Synara, CNVS); host-attributed names appear on sessions and may not get separate Integrations tiles. +- Claude reaches full control via FSEvents on `~/.claude/projects` journals; Cursor historically used hooks-only `ingest_lifecycle` (Limited)—ACP is the intended path toward Claude-parity. +- Community adapter “Setup needed” / “Waiting” usually means hooks or the host app are not yet delivering lifecycle events, not only a UI selection bug. +- Release pipeline is tag-driven: GitHub Release (DMGs/tarballs) → Homebrew formula bump PR → `finalize-release` smoke/promote; Intel finalize must use `brew services list --json` (not pipe-to-awk/grep) to avoid Broken pipe flakes. diff --git a/adapters/claude/README.md b/adapters/claude/README.md new file mode 100644 index 0000000..a0b22d3 --- /dev/null +++ b/adapters/claude/README.md @@ -0,0 +1,42 @@ +# Claude Code hooks for Microbridge + +Push-only PermissionRequest bridge. No daemon polling. + +## Install + +Microbridge Settings can merge hooks into `~/.claude/settings.json`, or add: + +```json +{ + "hooks": { + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"/path/to/microbridge/adapters/claude/hooks/microbridge-permission.mjs\" permission" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"/path/to/microbridge/adapters/claude/hooks/microbridge-permission.mjs\" pretool" + } + ] + } + ] + } +} +``` + +When enabling the Claude integration, Microbridge installs a copy under +`~/.microbridge/claude-hooks/` and points settings at that path. + +## Privacy + +Only session id + lifecycle state are sent to the local Microbridge socket. +Tool arguments and prompt text are not forwarded. diff --git a/adapters/claude/hooks/microbridge-permission.mjs b/adapters/claude/hooks/microbridge-permission.mjs new file mode 100644 index 0000000..73df43f --- /dev/null +++ b/adapters/claude/hooks/microbridge-permission.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +/** + * Claude Code PermissionRequest / PreToolUse bridge for Microbridge. + * Zero idle cost: only runs when Claude invokes the hook. + * + * Pending approvals: ~/.microbridge/claude-pending/.json + * Decisions: ~/.microbridge/claude-pending/.decision (or latest.decision) + */ + +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const PENDING = path.join(os.homedir(), ".microbridge", "claude-pending"); +const SOCKET = + process.env.MICROBRIDGE_SOCKET || + path.join(os.homedir(), ".microbridge", "microbridged.sock"); + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const text = Buffer.concat(chunks).toString("utf8").trim(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return {}; + } +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function sessionId(input) { + return ( + input.session_id || + input.sessionId || + input.conversation_id || + input.transcript_path || + "latest" + ); +} + +function ingestLifecycle(id, state) { + return new Promise((resolve) => { + const socket = net.createConnection(SOCKET); + const finish = () => { + socket.destroy(); + resolve(); + }; + socket.setTimeout(800, finish); + socket.on("error", finish); + socket.on("close", finish); + socket.on("connect", () => { + const messages = [ + { + type: "hello", + adapter: "claude-hook", + protocol_version: 0, + role: "ui", + adapter_version: "0.3.7", + capabilities: {}, + }, + { + type: "ingest_lifecycle", + adapter_id: "claude", + session: { + id: `claude:${id}`, + app: "Claude Code", + title: "Claude · awaiting approval", + state, + updated_at_ms: Date.now(), + focus_uri: null, + }, + ttl_ms: 10 * 60 * 1000, + }, + ]; + for (const message of messages) { + socket.write(`${JSON.stringify(message)}\n`); + } + // Fire-and-forget: daemon does not reply; close so we do not wait on timeout. + socket.end(); + }); + }); +} + +async function waitDecision(id, timeoutMs) { + ensureDir(PENDING); + const specific = path.join(PENDING, `${id}.decision`); + const latest = path.join(PENDING, "latest.decision"); + const interruptFlag = path.join(PENDING, "interrupt", `${id}.flag`); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + for (const file of [specific, latest]) { + try { + if (fs.existsSync(file)) { + const decision = fs.readFileSync(file, "utf8").trim(); + fs.unlinkSync(file); + return decision; + } + } catch { + /* ignore */ + } + } + try { + if (fs.existsSync(interruptFlag)) { + fs.unlinkSync(interruptFlag); + return "deny_interrupt"; + } + } catch { + /* ignore */ + } + await delay(50); + } + return null; +} + +function decisionOutput(hookEventName, decision) { + if (decision === "allow") { + return { + hookSpecificOutput: { + hookEventName, + decision: { behavior: "allow" }, + }, + }; + } + if (decision === "deny_interrupt") { + return { + hookSpecificOutput: { + hookEventName, + decision: { + behavior: "deny", + message: "Interrupted from Microbridge", + interrupt: true, + }, + }, + }; + } + return { + hookSpecificOutput: { + hookEventName, + decision: { + behavior: "deny", + message: "Rejected from Microbridge", + }, + }, + }; +} + +const mode = process.argv[2] || "permission"; +const input = await readStdin(); +const id = String(sessionId(input)).replace(/[^a-zA-Z0-9._-]/g, "_"); + +if (mode === "pretool") { + const interruptFlag = path.join(PENDING, "interrupt", `${id}.flag`); + if (fs.existsSync(interruptFlag)) { + try { + fs.unlinkSync(interruptFlag); + } catch { + /* ignore */ + } + process.stdout.write( + JSON.stringify(decisionOutput("PreToolUse", "deny_interrupt")), + ); + process.exit(0); + } + process.stdout.write("{}"); + process.exit(0); +} + +ensureDir(PENDING); +fs.writeFileSync( + path.join(PENDING, `${id}.json`), + JSON.stringify({ id, at: Date.now(), tool: input.tool_name || null }), +); +await ingestLifecycle(id, "awaiting_approval"); + +const decision = await waitDecision(id, 10 * 60 * 1000); +if (!decision) { + // Timeout: let Claude's normal UI handle it (do not silently deny). + process.stdout.write("{}"); + process.exit(0); +} + +process.stdout.write( + JSON.stringify(decisionOutput("PermissionRequest", decision)), +); diff --git a/adapters/cursor-acp/README.md b/adapters/cursor-acp/README.md new file mode 100644 index 0000000..362504f --- /dev/null +++ b/adapters/cursor-acp/README.md @@ -0,0 +1,29 @@ +# Cursor Agent (ACP) + +Separate from the **Cursor** IDE Composer tile. This integration drives +[Cursor ACP](https://cursor.com/docs/cli/acp) (`agent acp`) for agents that +Microbridge owns — not the already-open IDE chat panel. + +## Enable + +1. Install the Cursor CLI so `agent` or `cursor-agent` is on `PATH`. +2. Microbridge Settings → Integrations → **Cursor Agent (ACP)** → Enable. +3. Use **New Session** / Interrupt / Approve / Reject from Microbridge when a + session is bound to this adapter. + +## Capabilities (when CLI is present) + +| Action | ACP method | +|---|---| +| New session | `session/new` | +| Interrupt | `session/cancel` | +| Approve | `session/request_permission` → `allow-once` | +| Reject | `session/request_permission` → `reject-once` | + +IDE Composer open/focus by `conversation_id` remains unavailable from public +Cursor APIs; keep using the Cursor tile for lifecycle + workspace deep links. + +## Privacy + +ACP traffic stays on the local machine. Prompt content is not mirrored onto the +Microbridge status bus unless a future opt-in is added. diff --git a/adapters/cursor/README.md b/adapters/cursor/README.md index 5c017d2..7d82a79 100644 --- a/adapters/cursor/README.md +++ b/adapters/cursor/README.md @@ -24,11 +24,16 @@ Microbridge app. ## Capability boundary -Lifecycle observation is implemented. Cursor does not currently expose stable -public APIs for authoritative approval acceptance, session-scoped interrupt, -opening an existing thread, or reasoning-effort changes. Microbridge therefore -reports this adapter as **Limited** and never falls back to private storage or -Accessibility scripting. +Lifecycle observation is implemented (hooks + optional transcript watch). Cursor +IDE does not currently expose stable public APIs for authoritative approval +acceptance, session-scoped interrupt of the composer, opening an existing +thread by id, or reasoning-effort changes. Microbridge therefore reports the +IDE Composer tile as **Connected** for lifecycle (same ceiling as Claude Code) +and never falls back to private storage or Accessibility scripting. + +For hardware-driven approve / interrupt / new session against **Microbridge-owned** +Cursor agents, enable **Cursor Agent (ACP)** and install the Cursor CLI +(`agent` / `cursor-agent`). See [../cursor-acp/README.md](../cursor-acp/README.md). Run a hook locally: diff --git a/adapters/cursor/hooks/event.mjs b/adapters/cursor/hooks/event.mjs index 5f4216c..a729c30 100644 --- a/adapters/cursor/hooks/event.mjs +++ b/adapters/cursor/hooks/event.mjs @@ -4,6 +4,7 @@ const LIFECYCLE_STATES = { idle: "idle", stop: "idle", session_end: "idle", + session_start: "thinking", thinking: "thinking", before_submit_prompt: "thinking", after_agent_thought: "thinking", diff --git a/adapters/cursor/hooks/hooks.json b/adapters/cursor/hooks/hooks.json index 597b030..58e699c 100644 --- a/adapters/cursor/hooks/hooks.json +++ b/adapters/cursor/hooks/hooks.json @@ -1,6 +1,9 @@ { "version": 1, "hooks": { + "sessionStart": [ + { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" session_start" } + ], "beforeSubmitPrompt": [ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/microbridge-event.mjs\" thinking" } ], diff --git a/apps/microbridge-ui/src-tauri/Cargo.lock b/apps/microbridge-ui/src-tauri/Cargo.lock index 0f85993..21c414e 100644 --- a/apps/microbridge-ui/src-tauri/Cargo.lock +++ b/apps/microbridge-ui/src-tauri/Cargo.lock @@ -1853,7 +1853,7 @@ dependencies = [ [[package]] name = "microbridge-ui" -version = "0.3.6" +version = "0.3.7" dependencies = [ "mb-protocol", "serde", diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs index 4b3e544..cf4b1db 100644 --- a/apps/microbridge-ui/src-tauri/src/lib.rs +++ b/apps/microbridge-ui/src-tauri/src/lib.rs @@ -523,6 +523,106 @@ fn sync_factory_integration(synced: &AtomicBool, enabled: bool) { } } +/// Install Claude PermissionRequest hooks under ~/.microbridge/claude-hooks +/// and merge into ~/.claude/settings.json (idempotent, lean — only writes when needed). +fn claude_hook_source(app: &AppHandle) -> Result { + let bundled = app + .path() + .resource_dir() + .map_err(|e| e.to_string())? + .join("claude-hooks") + .join("microbridge-permission.mjs"); + if bundled.is_file() { + return Ok(bundled); + } + // `tauri dev` reads the repository copy; release bundles use Resources. + let repository = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../adapters/claude/hooks/microbridge-permission.mjs"); + if repository.is_file() { + return Ok(repository); + } + Err("Claude hook script not found in the Microbridge bundle.".into()) +} + +fn install_claude_hooks(app: &AppHandle) -> Result { + let home = std::env::var("HOME").map_err(|_| "HOME is unset".to_string())?; + let hook_dir = PathBuf::from(&home).join(".microbridge").join("claude-hooks"); + fs::create_dir_all(&hook_dir).map_err(|e| e.to_string())?; + let dest = hook_dir.join("microbridge-permission.mjs"); + let source = claude_hook_source(app)?; + let body = fs::read_to_string(&source).map_err(|e| e.to_string())?; + if fs::read_to_string(&dest).ok().as_deref() != Some(body.as_str()) { + fs::write(&dest, &body).map_err(|e| e.to_string())?; + } + let command = format!( + "node \"{}\" permission", + dest.to_string_lossy().replace('"', "\\\"") + ); + let pretool = format!( + "node \"{}\" pretool", + dest.to_string_lossy().replace('"', "\\\"") + ); + let settings_path = PathBuf::from(&home).join(".claude").join("settings.json"); + let mut settings = if settings_path.is_file() { + let text = fs::read_to_string(&settings_path).map_err(|e| e.to_string())?; + serde_json::from_str(&text) + .map_err(|e| format!("parse {}: {e}", settings_path.display()))? + } else { + serde_json::json!({}) + }; + let hooks = settings + .as_object_mut() + .ok_or_else(|| "Claude settings.json root must be an object".to_string())? + .entry("hooks") + .or_insert_with(|| serde_json::json!({})); + let hooks_obj = hooks + .as_object_mut() + .ok_or_else(|| "Claude settings hooks must be an object".to_string())?; + let permission_entry = serde_json::json!([{ + "hooks": [{ "type": "command", "command": command }] + }]); + let pretool_entry = serde_json::json!([{ + "hooks": [{ "type": "command", "command": pretool }] + }]); + let mut changed = false; + let mb_owned_or_empty = |existing: &serde_json::Value| { + let text = existing.to_string(); + text.contains("microbridge-permission") + || existing.as_array().is_some_and(|a| a.is_empty()) + }; + if hooks_obj.get("PermissionRequest") != Some(&permission_entry) { + let existing = hooks_obj + .get("PermissionRequest") + .cloned() + .unwrap_or(serde_json::json!([])); + if mb_owned_or_empty(&existing) { + hooks_obj.insert("PermissionRequest".into(), permission_entry); + changed = true; + } + } + if hooks_obj.get("PreToolUse") != Some(&pretool_entry) { + // Merge carefully: only replace if empty or already Microbridge-owned. + let existing = hooks_obj + .get("PreToolUse") + .cloned() + .unwrap_or(serde_json::json!([])); + if mb_owned_or_empty(&existing) { + hooks_obj.insert("PreToolUse".into(), pretool_entry); + changed = true; + } + } + if changed { + write_json_atomic(&settings_path, &settings)?; + } + Ok(dest) +} + +fn sync_claude_hooks(app: &AppHandle, synced: &AtomicBool) { + if !synced.swap(true, Ordering::Relaxed) && install_claude_hooks(app).is_err() { + synced.store(false, Ordering::Relaxed); + } +} + #[cfg(test)] mod factory_integration_tests { use super::*; @@ -1289,6 +1389,8 @@ pub fn run() { let cursor_sync_loop = Arc::clone(&cursor_integration_synced); let factory_integration_synced = Arc::new(AtomicBool::new(false)); let factory_sync_loop = Arc::clone(&factory_integration_synced); + let claude_hooks_synced = Arc::new(AtomicBool::new(false)); + let claude_sync_loop = Arc::clone(&claude_hooks_synced); let opencode_integration_synced = Arc::new(AtomicBool::new(false)); let opencode_sync_loop = Arc::clone(&opencode_integration_synced); @@ -1312,6 +1414,15 @@ pub fn run() { .map(|preference| preference.enabled) .unwrap_or(false); sync_factory_integration(&factory_sync_loop, factory_enabled); + let claude_enabled = s + .config + .adapters + .get("claude") + .map(|preference| preference.enabled) + .unwrap_or(true); + if claude_enabled { + sync_claude_hooks(&handle, &claude_sync_loop); + } let opencode_enabled = s .config .adapters diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json index 7653026..d5a7ee0 100644 --- a/apps/microbridge-ui/src-tauri/tauri.conf.json +++ b/apps/microbridge-ui/src-tauri/tauri.conf.json @@ -79,7 +79,8 @@ "../../../adapters/cursor/hooks/hooks.json": "cursor-plugin/hooks/hooks.json", "../../../adapters/cursor/hooks/event.mjs": "cursor-plugin/hooks/event.mjs", "../../../adapters/cursor/hooks/microbridge-event.mjs": "cursor-plugin/hooks/microbridge-event.mjs", - "../../../adapters/opencode/microbridge.mjs": "opencode-plugin/microbridge.mjs" + "../../../adapters/opencode/microbridge.mjs": "opencode-plugin/microbridge.mjs", + "../../../adapters/claude/hooks/microbridge-permission.mjs": "claude-hooks/microbridge-permission.mjs" }, "icon": [ "icons/32x32.png", diff --git a/apps/microbridge-ui/src/components/DeviceEcho.tsx b/apps/microbridge-ui/src/components/DeviceEcho.tsx index d8dc51f..2ff003a 100644 --- a/apps/microbridge-ui/src/components/DeviceEcho.tsx +++ b/apps/microbridge-ui/src/components/DeviceEcho.tsx @@ -39,7 +39,7 @@ function MiniAgentKey({ const pulse = session?.state === "awaiting_approval" ? "mb-led-pulse" - : session?.state === "thinking" + : session?.state === "thinking" || session?.state === "working" ? "mb-led-breathe" : ""; diff --git a/apps/microbridge-ui/src/components/DeviceTwin.tsx b/apps/microbridge-ui/src/components/DeviceTwin.tsx index 17ee2d2..dab24d5 100644 --- a/apps/microbridge-ui/src/components/DeviceTwin.tsx +++ b/apps/microbridge-ui/src/components/DeviceTwin.tsx @@ -137,7 +137,7 @@ function AgentKeycap({ const pulse = session?.state === "awaiting_approval" ? "mb-led-pulse" - : session?.state === "thinking" + : session?.state === "thinking" || session?.state === "working" ? "mb-led-breathe" : ""; diff --git a/apps/microbridge-ui/src/components/IntegrationCard.tsx b/apps/microbridge-ui/src/components/IntegrationCard.tsx index f8e07de..d1b86d0 100644 --- a/apps/microbridge-ui/src/components/IntegrationCard.tsx +++ b/apps/microbridge-ui/src/components/IntegrationCard.tsx @@ -45,7 +45,13 @@ function TileFace({ )} @@ -124,7 +130,7 @@ export function IntegrationCard({ "group relative flex h-[72px] w-full cursor-pointer flex-col items-stretch justify-between rounded-xl px-2 py-1.5 text-left transition-[background-color,border-color,box-shadow,opacity]"; return ( -
  • +
    -
  • + ); } @@ -147,9 +153,16 @@ export const IntegrationDetail = forwardRef< iconSrc?: string; diagnostic: string; theme: ThemeTokens; + guidance?: { title: string; steps: string[] } | null; + /** Healthy connected guidance uses green; setup uses yellow. */ + guidanceTone?: TrafficLight; children?: ReactNode; } ->(function IntegrationDetail({ name, iconSrc, diagnostic, theme, children }, ref) { +>(function IntegrationDetail( + { name, iconSrc, diagnostic, theme, guidance, guidanceTone = "yellow", children }, + ref, +) { + const tone = TRAFFIC_COLORS[guidanceTone]; 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/index.css b/apps/microbridge-ui/src/index.css index b97917d..0bf92eb 100644 --- a/apps/microbridge-ui/src/index.css +++ b/apps/microbridge-ui/src/index.css @@ -47,6 +47,31 @@ body, animation: mb-led-breathe 2.4s ease-in-out infinite; } +@keyframes mb-status-pulse { + 0%, + 100% { + opacity: 0.55; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.15); + } +} + +.mb-status-pulse { + animation: mb-status-pulse 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .mb-led-pulse, + .mb-led-breathe, + .mb-status-pulse, + .mb-drain { + animation: none !important; + } +} + @keyframes mb-drain { from { transform: scaleX(1); diff --git a/apps/microbridge-ui/src/lib/bus.ts b/apps/microbridge-ui/src/lib/bus.ts index 6ba3de0..a2de20a 100644 --- a/apps/microbridge-ui/src/lib/bus.ts +++ b/apps/microbridge-ui/src/lib/bus.ts @@ -198,6 +198,22 @@ const DEMO: Snapshot = { }, diagnostic: "Disabled until you explicitly enable this integration.", }, + { + id: "cursor_acp", + display_name: "Cursor Agent (ACP)", + kind: "community", + state: "disabled", + capabilities: { + lifecycle_observation: false, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Disabled until you explicitly enable this integration.", + }, { id: "t3code", display_name: "T3 Code", diff --git a/apps/microbridge-ui/src/lib/hosts.test.ts b/apps/microbridge-ui/src/lib/hosts.test.ts index 85a0e2a..fb1317a 100644 --- a/apps/microbridge-ui/src/lib/hosts.test.ts +++ b/apps/microbridge-ui/src/lib/hosts.test.ts @@ -54,14 +54,14 @@ describe("integrationView", () => { expect(view.diagnostic).toContain("no separate adapter"); }); - it("marks Synara idle when there are no sessions", () => { + it("marks Synara ready when there are no sessions", () => { const view = integrationView( adapter({ id: "synara", display_name: "Synara", state: "connected" }), [], ); - expect(view.light).toBe("yellow"); - expect(view.label).toBe("Idle"); - expect(view.connectedGroup).toBe(false); + expect(view.label).toBe("Ready · idle"); + expect(view.light).toBe("green"); + expect(view.connectedGroup).toBe(true); }); it("flags disabled Cursor when journal sessions already exist", () => { @@ -85,9 +85,57 @@ describe("integrationView", () => { [], ); expect(view.light).toBe("green"); + expect(view.label).toBe("Connected · lifecycle"); + expect(view.connectedGroup).toBe(true); + }); + + it("labels Claude with approval levers as Connected", () => { + const view = integrationView( + adapter({ + id: "claude", + display_name: "Claude Code", + state: "connected", + capabilities: { + lifecycle_observation: true, + approval_acceptance: true, + approval_rejection: true, + interrupt: true, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + }), + [], + ); + expect(view.light).toBe("green"); expect(view.label).toBe("Connected"); }); + it("labels Cursor connected lifecycle-only as Connected · lifecycle", () => { + const view = integrationView( + adapter({ + id: "cursor", + display_name: "Cursor", + kind: "community", + state: "connected", + diagnostic: "Lifecycle connected. Cursor IDE does not expose approve/interrupt APIs yet.", + capabilities: { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + }), + [], + ); + expect(view.label).toBe("Connected · lifecycle"); + expect(view.light).toBe("green"); + expect(view.connectedGroup).toBe(true); + }); + it("puts limited adapters in the Connected group", () => { const view = integrationView( adapter({ @@ -96,10 +144,20 @@ describe("integrationView", () => { kind: "community", state: "limited", diagnostic: "Lifecycle is connected; unsupported IDE commands remain disabled.", + capabilities: { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, }), [], ); - expect(view.label).toBe("Limited"); + expect(view.label).toBe("Connected · lifecycle"); + expect(view.light).toBe("green"); expect(view.connectedGroup).toBe(true); }); @@ -113,11 +171,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..f609b3d 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 @@ -131,12 +135,13 @@ export function integrationView( connectedGroup: true, }; } + // Healthy watcher with no sessions — Ready, not "Not connected". return { - light: "yellow", - label: "Idle", + light: "green", + label: "Ready · idle", diagnostic: - "via Claude & Codex journals — no separate adapter needed. Waiting for sessions.", - connectedGroup: false, + "via Claude & Codex journals — waiting for sessions (setup is fine).", + connectedGroup: true, }; } @@ -157,7 +162,31 @@ 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); + // Lifecycle-only adapters (Cursor Connected promotion, Limited, etc.) should not + // read as broken — show Connected · lifecycle when observation is the only lever. + if ( + adapter.capabilities.lifecycle_observation && + !adapter.capabilities.approval_acceptance && + (adapter.state === "limited" || adapter.state === "connected") + ) { + return { + light: "green", + label: "Connected · lifecycle", + diagnostic: adapter.diagnostic, + connectedGroup: true, + }; + } return { light, label: STATE_LABELS[adapter.state], diff --git a/apps/microbridge-ui/src/lib/integrationIcons.ts b/apps/microbridge-ui/src/lib/integrationIcons.ts index a0c4576..c805d86 100644 --- a/apps/microbridge-ui/src/lib/integrationIcons.ts +++ b/apps/microbridge-ui/src/lib/integrationIcons.ts @@ -18,6 +18,7 @@ export const INTEGRATION_ICONS: Record = { codex, conductor, cursor, + cursor_acp: cursor, factory, opencode, synara, diff --git a/apps/microbridge-ui/src/lib/integrationSetup.test.ts b/apps/microbridge-ui/src/lib/integrationSetup.test.ts index 403b6af..4e61b05 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,65 @@ 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 connected Cursor as lifecycle success, not broken Limited", () => { + const connected = integrationGuidance("cursor", "connected"); + expect(connected?.title).toContain("connected"); + expect(connected?.steps.some((step) => /ACP|lifecycle/i.test(step))).toBe( + true, + ); + }); + + it("guides Cursor ACP separately from IDE Composer", () => { + const acp = integrationGuidance("cursor_acp", "needs_setup", { + enabled: true, + }); + expect(acp?.steps.some((step) => step.includes("CLI"))).toBe(true); + }); + + 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("Ready/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..6d34ba6 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,312 @@ 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 Connected once 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 Connected once hooks are talking to the daemon (approve/interrupt need Cursor ACP).", + ], + primaryAction: "open_app", + }; + } + if (state === "limited" || state === "connected") { + return { + title: + state === "connected" + ? "Cursor lifecycle is connected" + : "Cursor lifecycle is live", + steps: [ + "Lifecycle observation matches Claude Code’s ceiling for the IDE composer.", + "Approve, interrupt, and open-conversation control need Cursor ACP/SDK (Microbridge-owned agents) — not available for the IDE composer yet.", + "Keep using Cursor; threads appear as hooks and transcript watches fire.", + ], + 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 "cursor_acp": + if (state === "disabled") { + return { + title: "Enable Cursor Agent (ACP)", + steps: [ + "Install the Cursor CLI so `agent` or `cursor-agent` is on PATH.", + "Click this tile to enable ACP control for Microbridge-owned agents.", + "This does not remote-control the IDE Composer — use the Cursor tile for that lifecycle.", + ], + primaryAction: "enable", + }; + } + if (state === "needs_setup" || state === "connecting") { + return { + title: "Install Cursor CLI", + steps: [ + "Install/authenticate the Cursor CLI (`agent` / `cursor-agent`).", + "Restart Microbridge after PATH updates, then use New Session / Interrupt from hardware or UI.", + ], + primaryAction: "none", + }; + } + if (state === "connected" || state === "limited") { + return { + title: "ACP control ready", + steps: [ + "New Session starts a Microbridge-owned ACP agent.", + "Interrupt / Approve / Reject map to ACP session methods.", + "IDE Composer chats stay on the Cursor tile (lifecycle only).", + ], + primaryAction: "none", + }; + } + 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" || + label === "Ready · idle" || + state === "connected" || + state === "limited" + ) { + return { + title: `${hostName} via journals`, + steps: [ + `Ready/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..aed2189 100644 --- a/apps/microbridge-ui/src/surfaces/Settings.tsx +++ b/apps/microbridge-ui/src/surfaces/Settings.tsx @@ -37,7 +37,9 @@ 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"; +import { agentKeyLedFrame } from "../lib/types"; const LIGHTING_STATES: { id: keyof StateColors; label: string }[] = [ { id: "idle", label: "Idle" }, @@ -67,6 +69,7 @@ const INTEGRATION_ORDER = [ "synara", "conductor", "cursor", + "cursor_acp", "t3code", "factory", "opencode", @@ -203,15 +206,16 @@ export function Settings({ return (
    + -
    +
    {tab === "general" && (

    General

    @@ -350,10 +354,21 @@ export function Settings({ thread.

    - {snapshot.agent_key_session_ids.map((id, i) => { + {(() => { + const frame = agentKeyLedFrame(snapshot); + return snapshot.agent_key_session_ids.map((id, i) => { const s = id ? snapshot.sessions.find((x) => x.id === id) : null; + const led = frame.keys[i]; + const color = frame.paused ? null : (led?.color ?? null); + const focused = Boolean(led?.focused); + const pulse = + s?.state === "awaiting_approval" + ? "mb-led-pulse" + : s?.state === "thinking" || s?.state === "working" + ? "mb-led-breathe" + : ""; return ( ); - })} + }); + })()}

    Key source

    @@ -626,7 +660,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 +682,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 +695,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 +742,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 +781,15 @@ export function Settings({ iconSrc={integrationIcon(selected.adapter.id)} diagnostic={selected.view.diagnostic} theme={t} + guidance={ + guidance + ? { title: guidance.title, steps: guidance.steps } + : null + } + guidanceTone={ + selected.view.light === "green" ? "green" : "yellow" + } > - {(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) => ( )}
    - {openAppLabel && ( + {guidance?.primaryAction === "enable" && + selected.adapter.kind === "community" && + !cfg.adapters[selected.adapter.id]?.enabled && ( + + )} + {guidance?.primaryAction === "open_app" && openAppLabel && ( + + )} + {openAppLabel && guidance?.primaryAction !== "open_app" && (