Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a22a516
feat(desktop): add browser pane Connect flow for LLM sessions (Phase …
MocA-Love Apr 19, 2026
f017f92
feat(desktop): wire browser-automation to real sessions & MCP detection
MocA-Love Apr 19, 2026
73b6364
fix(desktop): address codex review on BrowserAutomationList
MocA-Love Apr 19, 2026
b75711b
fix(desktop): handle more session terminal states and stale bindings
MocA-Love Apr 19, 2026
7376ba5
fix(desktop): restrict session list to live states and wire subscription
MocA-Love Apr 19, 2026
3a2605a
fix(desktop): handle stale selection and EventEmitter listener cap
MocA-Love Apr 19, 2026
88547a9
fix(desktop): stop polling sessions for every browser pane
MocA-Love Apr 20, 2026
b85c59e
feat(desktop): persist bindings in local-db and detect terminal claud…
MocA-Love Apr 20, 2026
0765ed9
fix(desktop): gate session polling on dialog visibility
MocA-Love Apr 20, 2026
bfcd8a3
fix(desktop): derive Connect badge from liveness, centralize binding …
MocA-Love Apr 20, 2026
549c227
Merge remote-tracking branch 'origin/main' into feature/browser-pane-…
MocA-Love Apr 20, 2026
51f0936
chore: merge main and apply lint:fix formatting
MocA-Love Apr 20, 2026
509e391
fix(desktop): separate todo-agent kind and count only live bindings
MocA-Love Apr 20, 2026
ca55400
fix(desktop): skip terminal probe when no terminal bindings exist
MocA-Love Apr 20, 2026
be0f062
fix(desktop): avoid modal re-render loops and BrowserPane selector churn
MocA-Love Apr 20, 2026
e6a7cfc
fix(desktop): activate target pane before opening connect modal
MocA-Love Apr 20, 2026
c967c80
fix(desktop): structured MCP detection and graceful terminal-host fai…
MocA-Love Apr 20, 2026
ea21b75
Merge remote-tracking branch 'origin/main' into feature/browser-pane-…
MocA-Love Apr 20, 2026
c7c41b1
chore: merge main and apply lint:fix formatting
MocA-Love Apr 20, 2026
941983c
fix(desktop): check ~/.claude.json for Claude MCP server entry
MocA-Love Apr 20, 2026
31c5d94
fix(desktop): project .mcp.json + quoted Codex TOML keys
MocA-Love Apr 20, 2026
a6db356
Merge remote-tracking branch 'origin/main' into feature/browser-pane-…
MocA-Love Apr 20, 2026
0257cf0
fix(desktop): scope Claude MCP readiness per session workspace
MocA-Love Apr 20, 2026
ef6bd02
fix(desktop): recognize Claude local-scope MCP entries in ~/.claude.json
MocA-Love Apr 20, 2026
8a3f4cb
fix(desktop): correct MCP setup command and instructions
MocA-Love Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
486 changes: 486 additions & 0 deletions apps/desktop/src/lib/trpc/routers/browser-automation/index.ts

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createAnalyticsRouter } from "./analytics";
import { createAuthRouter } from "./auth";
import { createAutoUpdateRouter } from "./auto-update";
import { createBrowserRouter } from "./browser/browser";
import { createBrowserAutomationRouter } from "./browser-automation";
import { createBrowserHistoryRouter } from "./browser-history";
import { createCacheRouter } from "./cache";
import { createChangesRouter } from "./changes";
Expand Down Expand Up @@ -53,6 +54,7 @@ export const createAppRouter = (
aivis: createAivisRouter(),
analytics: createAnalyticsRouter(),
browser: createBrowserRouter(),
browserAutomation: createBrowserAutomationRouter(),
browserHistory: createBrowserHistoryRouter(),
auth: createAuthRouter(),
autoUpdate: createAutoUpdateRouter(),
Expand Down
140 changes: 140 additions & 0 deletions apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useMemo } from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";
import {
type AutomationSession,
formatRelativeTime,
type McpStatus,
} from "renderer/stores/browser-automation";
import { useTabsStore } from "renderer/stores/tabs/store";

/**
* Aggregates browser-automation session and binding data from the
* main-process tRPC routers. Sessions come from two sources:
*
* 1. TODO-Agent sessions (Claude Code workers the supervisor is running).
* 2. Terminal panes that currently have a `claude` or `codex` child
* process. The binding key for this kind is the terminal's paneId so
* that it self-heals across shell re-spawns inside the same pane.
*
* MCP readiness is resolved against the user's Claude/Codex config files.
*
* `enabled` controls the expensive queries. The binding query and its
* subscription always run because `ConnectButton` is mounted for every
* browser pane and needs to reflect the binding state without polling.
*/
export function useBrowserAutomationData({
enabled = true,
}: {
enabled?: boolean;
} = {}) {
const panes = useTabsStore((s) => s.panes);

const { data: todoSessions = [], refetch: refetchSessions } =
electronTrpc.todoAgent.listAll.useQuery(undefined, {
enabled,
refetchOnWindowFocus: enabled,
refetchInterval: enabled ? 15000 : false,
});
const { data: terminalAgents = [] } =
electronTrpc.browserAutomation.listTerminalAgentSessions.useQuery(
undefined,
{
enabled,
refetchOnWindowFocus: enabled,
refetchInterval: enabled ? 10000 : false,
},
);
const { data: mcpStatus } =
electronTrpc.browserAutomation.getMcpStatus.useQuery(undefined, {
enabled,
refetchOnWindowFocus: enabled,
refetchInterval: enabled ? 30000 : false,
});
const { data: bindings = [] } =
electronTrpc.browserAutomation.listBindings.useQuery(undefined, {
// Binding changes are pushed via onBindingsChanged, so no polling.
refetchOnWindowFocus: false,
});
// The binding subscription is centralized in `useBrowserBindingsSync`
// (mounted once in ContentView), so this hook does not open one per
// consumer.

const sessions: AutomationSession[] = useMemo(() => {
// Only sessions that have a live worker (or are actively scheduled to
// wake up) should be connectable. Queued/paused/aborted/done/failed/
// escalated sessions either never started or are terminal.
const liveStatuses = new Set([
"running",
"preparing",
"verifying",
"waiting",
]);
const claudeReadyForWorkspace = (workspaceId: string | null): McpStatus => {
if (!mcpStatus) return "unknown";
if (mcpStatus.claudeHomeReady) return "ready";
if (workspaceId && mcpStatus.claudeReadyByWorkspaceId[workspaceId])
return "ready";
return "missing";
};
const todo: AutomationSession[] = todoSessions
.filter((s) => liveStatuses.has(s.status))
.map((s) => {
// Todo-agent rows always represent Claude Code workers (see
// todo-daemon/claude-code-runner.ts).
const provider = "Claude" as const;
const mcp: McpStatus = claudeReadyForWorkspace(s.workspaceId);
const displayName = s.title || `Session ${s.id.slice(0, 6)}`;
const branchOrContext =
s.workspaceBranch ??
s.workspaceName ??
(s.projectName ? s.projectName : "workspace");
return {
id: s.id,
displayName,
provider,
kind: "TODO-Agent",
branchOrContextLabel: branchOrContext,
lastActiveAt: formatRelativeTime(s.updatedAt ?? s.createdAt),
mcpStatus: mcp,
};
});

const terminal: AutomationSession[] = terminalAgents.map((t) => {
const pane = panes[t.paneId];
const mcp: McpStatus =
t.provider === "Codex"
? mcpStatus
? mcpStatus.codexReady
? "ready"
: "missing"
: "unknown"
: claudeReadyForWorkspace(t.workspaceId);
return {
id: `terminal:${t.paneId}`,
displayName: pane?.userTitle || pane?.name || `Terminal ${t.command}`,
provider: t.provider,
kind: "Terminal",
branchOrContextLabel: t.command,
lastActiveAt: t.lastAttachedAt
? formatRelativeTime(Date.parse(t.lastAttachedAt))
: "active",
mcpStatus: mcp,
};
});

return [...todo, ...terminal];
}, [todoSessions, terminalAgents, mcpStatus, panes]);

const bindingsByPane = useMemo(() => {
const map: Record<string, string> = {};
for (const b of bindings) map[b.paneId] = b.sessionId;
return map;
}, [bindings]);

return {
sessions,
bindingsByPane,
mcpStatus,
refetchSessions,
};
}
17 changes: 17 additions & 0 deletions apps/desktop/src/renderer/hooks/useBrowserBindingsSync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { electronTrpc } from "renderer/lib/electron-trpc";

/**
* Centralized binding subscription — mount ONCE per window (from
* ContentView). Without this, every `useBrowserAutomationData` consumer
* (one per browser pane) would open its own subscription to the main
* process emitter and fan out an invalidation per binding mutation.
*/
export function useBrowserBindingsSync() {
const utils = electronTrpc.useUtils();
electronTrpc.browserAutomation.onBindingsChanged.useSubscription(undefined, {
onData: () => {
utils.browserAutomation.listBindings.invalidate();
utils.browserAutomation.listBindingLiveness.invalidate();
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LuMinus, LuPlus } from "react-icons/lu";
import { TbDeviceDesktop } from "react-icons/tb";
import type { MosaicBranch } from "react-mosaic-component";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useBrowserAutomationStore } from "renderer/stores/browser-automation";
import {
findBookmarkByUrl,
useBrowserBookmarksStore,
Expand All @@ -21,7 +22,9 @@ import {
} from "./components/BrowserFindOverlay";
import { BrowserToolbar } from "./components/BrowserToolbar";
import { BrowserOverflowMenu } from "./components/BrowserToolbar/components/BrowserOverflowMenu";
import { ConnectButton } from "./components/ConnectButton";
import { ExtensionToolbar } from "./components/ExtensionToolbar";
import { SessionConnectModal } from "./components/SessionConnectModal";
import { DEFAULT_BROWSER_URL } from "./constants";
import { usePersistentWebview } from "./hooks/usePersistentWebview";

Expand Down Expand Up @@ -74,6 +77,14 @@ export function BrowserPane({
const isFullscreen = useBrowserFullscreenStore(
(s) => s.fullscreenPaneId === paneId,
);
// Narrow the subscription so BrowserPane (and its webview tree) does not
// re-render every time the modal's selectedSessionId changes.
const isConnectOpenForThisPane = useBrowserAutomationStore(
(s) => s.connectModal.isOpen && s.connectModal.paneId === paneId,
);
const closeConnectModal = useBrowserAutomationStore(
(s) => s.closeConnectModal,
);
const { mutate: openDevTools } =
electronTrpc.browser.openDevTools.useMutation();
const { mutate: setZoomLevel } =
Expand Down Expand Up @@ -294,6 +305,8 @@ export function BrowserPane({
onPopOut={handlers.onPopOut}
leadingActions={
<>
<ConnectButton paneId={paneId} />
<div className="mx-1 h-3.5 w-px bg-muted-foreground/60" />
<div className="flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
Expand Down Expand Up @@ -423,6 +436,12 @@ export function BrowserPane({
)}
</div>
</div>
<SessionConnectModal
open={isConnectOpenForThisPane}
onOpenChange={(open) => {
if (!open) closeConnectModal();
}}
Comment thread
MocA-Love marked this conversation as resolved.
/>
</BasePaneWindow>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Button } from "@superset/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@superset/ui/dialog";
import { cn } from "@superset/ui/utils";
import { useMemo } from "react";
import { useBrowserAutomationData } from "renderer/hooks/useBrowserAutomationData";
import { useBrowserAutomationStore } from "renderer/stores/browser-automation";
import { useTabsStore } from "renderer/stores/tabs/store";

interface BrowserAutomationListProps {
workspaceId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}

export function BrowserAutomationList({
workspaceId,
open,
onOpenChange,
}: BrowserAutomationListProps) {
const panes = useTabsStore((s) => s.panes);
const tabs = useTabsStore((s) => s.tabs);
const setFocusedPane = useTabsStore((s) => s.setFocusedPane);
const setActiveTab = useTabsStore((s) => s.setActiveTab);
const { sessions, bindingsByPane } = useBrowserAutomationData({
enabled: open,
});
const openConnectModal = useBrowserAutomationStore((s) => s.openConnectModal);

const browserPanes = useMemo(() => {
const tabById = new Map(tabs.map((t) => [t.id, t]));
return Object.values(panes).filter(
(p) =>
p.type === "webview" &&
tabById.get(p.tabId)?.workspaceId === workspaceId,
);
}, [panes, tabs, workspaceId]);

// A binding only counts as "connected" if the bound session is still in
// the live session list. Stale bindings render as `Unassigned` in the
// row below, so summing raw bindings would show a misleading higher
// count.
const liveSessionIds = new Set(sessions.map((s) => s.id));
const connectedCount = browserPanes.filter((p) => {
const sid = bindingsByPane[p.id];
return sid && liveSessionIds.has(sid);
}).length;
const needsSetupCount = sessions.filter(
(s) => s.mcpStatus === "missing",
).length;

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!max-w-[640px] sm:!max-w-[640px] p-0 gap-0 overflow-hidden">
<DialogHeader className="px-5 py-4 border-b">
<DialogTitle className="text-sm">Browser Automation</DialogTitle>
<DialogDescription className="text-xs">
All browser panes in this workspace and their bound sessions.
</DialogDescription>
</DialogHeader>

<div className="p-4 grid grid-cols-3 gap-2 border-b">
<Metric label="Browser panes" value={browserPanes.length} />
<Metric label="Connected" value={connectedCount} />
<Metric label="Needs setup" value={needsSetupCount} />
</div>

<div className="max-h-[420px] overflow-y-auto p-3 flex flex-col gap-2">
{browserPanes.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-8">
No browser panes in this workspace.
</div>
)}
{browserPanes.map((pane) => {
const sessionId = bindingsByPane[pane.id];
const session = sessionId
? (sessions.find((s) => s.id === sessionId) ?? null)
: null;
const url = pane.browser?.currentUrl ?? pane.url ?? "about:blank";
return (
<div
key={pane.id}
className={cn(
"rounded-xl border p-3 bg-card/60",
session && "border-brand/30 bg-brand/5",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold truncate">
{pane.userTitle || pane.name}
</div>
<div className="text-[11px] text-muted-foreground truncate">
{url}
</div>
</div>
<span
className={cn(
"shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider",
session
? "bg-emerald-500/15 text-emerald-300"
: "bg-muted text-muted-foreground",
)}
>
{session ? "Connected" : "Unassigned"}
</span>
</div>
<div className="mt-2 text-[11px] text-muted-foreground">
{session
? `${session.displayName} · ${session.provider} · ${session.mcpStatus === "ready" ? "MCP ready" : "MCP missing"}`
: "Pick any running LLM session"}
</div>
<div className="mt-3 flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
setActiveTab(workspaceId, pane.tabId);
setFocusedPane(pane.tabId, pane.id);
onOpenChange(false);
Comment thread
MocA-Love marked this conversation as resolved.
}}
>
Focus
</Button>
<Button
size="sm"
onClick={() => {
// SessionConnectModal is rendered inside each
// BrowserPane (gated by isConnectOpenForThisPane), so
// it only mounts when the owning tab/pane is active.
// Activate the target before opening the modal, or the
// dialog never appears if the pane lives on another
// tab.
setActiveTab(workspaceId, pane.tabId);
setFocusedPane(pane.tabId, pane.id);
openConnectModal(pane.id, sessionId);
onOpenChange(false);
}}
Comment thread
MocA-Love marked this conversation as resolved.
>
{session ? "Change" : "Connect"}
</Button>
</div>
</div>
);
})}
</div>
</DialogContent>
</Dialog>
);
}

function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg bg-muted/40 p-3">
<div className="text-xl font-bold tabular-nums">{value}</div>
<div className="mt-1 text-[10px] uppercase tracking-wider text-muted-foreground">
{label}
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BrowserAutomationList } from "./BrowserAutomationList";
Loading
Loading