From a22a51676309566bdcbf45be2b33e8d7246c6150 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:18:44 +0900 Subject: [PATCH 01/22] feat(desktop): add browser pane Connect flow for LLM sessions (Phase 1 UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit browser pane toolbar に Connect ボタン・session 選択モーダル・一覧ビューを追加。 paneId -> sessionId のバインディングは renderer 側 zustand store で管理し、 ダミーデータで UX を一通り動作させる。Phase 2 以降で実セッション/MCP 連携に 置き換える想定。 - browser-automation store: sessions/bindings/connectModal/listView の状態管理 - ConnectButton: toolbar に配置、接続済みは "Session X · Provider" を表示 - SessionConnectModal: 2カラム(セッション一覧 + 詳細/MCP セットアップ案内) - BrowserAutomationList: workspace の全 browser pane とバインディング状況 - 再割当トースト / 切断導線 / MCP missing の inline ガイドを実装 - mock.html / todo.md を参考資料として追加 (biome では除外) --- .../TabView/BrowserPane/BrowserPane.tsx | 17 + .../BrowserAutomationList.tsx | 137 ++ .../components/BrowserAutomationList/index.ts | 1 + .../ConnectButton/ConnectButton.tsx | 45 + .../components/ConnectButton/index.ts | 1 + .../SessionConnectModal.tsx | 402 ++++ .../components/SessionConnectModal/index.ts | 1 + .../WorkspaceView/ContentView/index.tsx | 8 + .../src/renderer/stores/browser-automation.ts | 139 ++ biome.jsonc | 3 +- mock.html | 2031 +++++++++++++++++ todo.md | 341 +++ 12 files changed, 3125 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/index.ts create mode 100644 apps/desktop/src/renderer/stores/browser-automation.ts create mode 100644 mock.html create mode 100644 todo.md diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx index 27676aa52c8..9c56512a518 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx @@ -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, @@ -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"; @@ -74,6 +77,12 @@ export function BrowserPane({ const isFullscreen = useBrowserFullscreenStore( (s) => s.fullscreenPaneId === paneId, ); + const connectModal = useBrowserAutomationStore((s) => s.connectModal); + const closeConnectModal = useBrowserAutomationStore( + (s) => s.closeConnectModal, + ); + const isConnectOpenForThisPane = + connectModal.isOpen && connectModal.paneId === paneId; const { mutate: openDevTools } = electronTrpc.browser.openDevTools.useMutation(); const { mutate: setZoomLevel } = @@ -294,6 +303,8 @@ export function BrowserPane({ onPopOut={handlers.onPopOut} leadingActions={ <> + +
@@ -423,6 +434,12 @@ export function BrowserPane({ )}
+ { + if (!open) closeConnectModal(); + }} + /> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx new file mode 100644 index 00000000000..2214f9522fb --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx @@ -0,0 +1,137 @@ +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 { useBrowserAutomationStore } from "renderer/stores/browser-automation"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +interface BrowserAutomationListProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function BrowserAutomationList({ + open, + onOpenChange, +}: BrowserAutomationListProps) { + const panes = useTabsStore((s) => s.panes); + const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const bindings = useBrowserAutomationStore((s) => s.bindings); + const sessions = useBrowserAutomationStore((s) => s.sessions); + const openConnectModal = useBrowserAutomationStore((s) => s.openConnectModal); + + const browserPanes = useMemo( + () => Object.values(panes).filter((p) => p.type === "webview"), + [panes], + ); + + const connectedCount = browserPanes.filter((p) => bindings[p.id]).length; + const needsSetupCount = Object.values(sessions).filter( + (s) => s.mcpStatus === "missing", + ).length; + + return ( + + + + Browser Automation + + All browser panes in this workspace and their bound sessions. + + + +
+ + + +
+ +
+ {browserPanes.length === 0 && ( +
+ No browser panes in this workspace. +
+ )} + {browserPanes.map((pane) => { + const sessionId = bindings[pane.id]; + const session = sessionId ? sessions[sessionId] : null; + const url = pane.browser?.currentUrl ?? pane.url ?? "about:blank"; + return ( +
+
+
+
+ {pane.userTitle || pane.name} +
+
+ {url} +
+
+ + {session ? "Connected" : "Unassigned"} + +
+
+ {session + ? `${session.displayName} · ${session.provider} · ${session.mcpStatus === "ready" ? "MCP ready" : "MCP missing"}` + : "Pick any running Claude or Codex session"} +
+
+ + +
+
+ ); + })} +
+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
+ {label} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/index.ts new file mode 100644 index 00000000000..d1e4d30e065 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/index.ts @@ -0,0 +1 @@ +export { BrowserAutomationList } from "./BrowserAutomationList"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx new file mode 100644 index 00000000000..3f40964c43b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx @@ -0,0 +1,45 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuPlug } from "react-icons/lu"; +import { useBrowserAutomationStore } from "renderer/stores/browser-automation"; + +interface ConnectButtonProps { + paneId: string; +} + +export function ConnectButton({ paneId }: ConnectButtonProps) { + const sessionId = useBrowserAutomationStore((s) => s.bindings[paneId]); + const session = useBrowserAutomationStore((s) => + sessionId ? s.sessions[sessionId] : null, + ); + const openConnectModal = useBrowserAutomationStore((s) => s.openConnectModal); + + const connected = Boolean(session); + const label = connected + ? `${session?.displayName} · ${session?.provider}` + : "Connect"; + + return ( + + + + + + {connected + ? "Change or disconnect browser automation session" + : "Connect this browser pane to a running LLM session"} + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/index.ts new file mode 100644 index 00000000000..f0cc5bd41e3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/index.ts @@ -0,0 +1 @@ +export { ConnectButton } from "./ConnectButton"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx new file mode 100644 index 00000000000..32f6f26a63a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx @@ -0,0 +1,402 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { useMemo } from "react"; +import { LuCopy, LuList } from "react-icons/lu"; +import { + type AutomationSession, + getSnippetForSession, + useBrowserAutomationStore, +} from "renderer/stores/browser-automation"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +interface SessionConnectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SessionConnectModal({ + open, + onOpenChange, +}: SessionConnectModalProps) { + const paneId = useBrowserAutomationStore((s) => s.connectModal.paneId); + const selectedSessionId = useBrowserAutomationStore( + (s) => s.connectModal.selectedSessionId, + ); + const sessions = useBrowserAutomationStore((s) => s.sessions); + const bindings = useBrowserAutomationStore((s) => s.bindings); + const setSelectedSession = useBrowserAutomationStore( + (s) => s.setSelectedSession, + ); + const connect = useBrowserAutomationStore((s) => s.connect); + const disconnect = useBrowserAutomationStore((s) => s.disconnect); + const markSessionReady = useBrowserAutomationStore((s) => s.markSessionReady); + const setListViewOpen = useBrowserAutomationStore((s) => s.setListViewOpen); + + const pane = useTabsStore((s) => (paneId ? s.panes[paneId] : null)); + const panes = useTabsStore((s) => s.panes); + + const sessionList = useMemo(() => Object.values(sessions), [sessions]); + const session = selectedSessionId ? sessions[selectedSessionId] : null; + const currentBinding = paneId ? bindings[paneId] : null; + const currentSession = currentBinding ? sessions[currentBinding] : null; + + const assignedPaneIdForSelected = useMemo(() => { + if (!selectedSessionId) return null; + return ( + Object.entries(bindings).find( + ([pid, sid]) => sid === selectedSessionId && pid !== paneId, + )?.[0] ?? null + ); + }, [bindings, paneId, selectedSessionId]); + + const paneUrl = pane?.browser?.currentUrl ?? pane?.url ?? "about:blank"; + const paneName = pane?.userTitle || pane?.name || "Browser pane"; + + const handleConnect = () => { + if (!paneId || !session || session.mcpStatus !== "ready") return; + const { reassignedFromPaneId } = connect(paneId, session.id); + if (reassignedFromPaneId) { + const fromPane = panes[reassignedFromPaneId]; + toast.success( + `${session.displayName} moved from ${fromPane?.name ?? "another pane"} to ${paneName}`, + ); + } else { + toast.success(`${paneName} is now controlled by ${session.displayName}`); + } + onOpenChange(false); + }; + + const handleDisconnect = () => { + if (!paneId || !currentSession) return; + disconnect(paneId); + toast.info(`${currentSession.displayName} disconnected from ${paneName}`); + }; + + const handleCopySnippet = async () => { + if (!session) return; + try { + await navigator.clipboard.writeText(getSnippetForSession(session)); + toast.success("Configuration snippet copied"); + } catch { + toast.error("Failed to copy snippet"); + } + }; + + return ( + + + + + Connect browser automation + + + Choose which running LLM session should control this browser pane. + + + +
+
+
+
+ ◎ +
+
+
{paneName}
+
+ {paneUrl} +
+
+ +
+ +
+ Running sessions +
+ +
+ {sessionList.map((s) => ( + sid === s.id && pid !== paneId, + ) + ? (panes[ + Object.entries(bindings).find( + ([pid, sid]) => sid === s.id && pid !== paneId, + )?.[0] ?? "" + ]?.name ?? null) + : null + } + onSelect={() => setSelectedSession(s.id)} + /> + ))} +
+
+ +
+ {session ? ( + session.mcpStatus === "ready" ? ( + + ) : ( + markSessionReady(session.id)} + onCopy={handleCopySnippet} + /> + ) + ) : ( +
+ Select a session to see details. +
+ )} +
+
+ + +
+ {session?.mcpStatus === "ready" + ? assignedPaneIdForSelected + ? `Connecting will reassign ${session.displayName} from ${panes[assignedPaneIdForSelected]?.name ?? "another pane"} to ${paneName}.` + : "Connecting binds this browser pane to the selected session only." + : "Add the MCP entry first, then reopen or restart this session."} +
+
+ {currentSession && ( + + )} + + +
+
+
+
+ ); +} + +function SessionCard({ + session, + isSelected, + assignedElsewherePaneName, + onSelect, +}: { + session: AutomationSession; + isSelected: boolean; + assignedElsewherePaneName: string | null; + onSelect: () => void; +}) { + const pillClass = assignedElsewherePaneName + ? "bg-amber-500/15 text-amber-300" + : session.mcpStatus === "ready" + ? "bg-emerald-500/15 text-emerald-300" + : session.mcpStatus === "missing" + ? "bg-amber-500/15 text-amber-300" + : "bg-muted text-muted-foreground"; + const pillLabel = assignedElsewherePaneName + ? "Reassign" + : session.mcpStatus === "ready" + ? "Ready" + : session.mcpStatus === "missing" + ? "Needs MCP" + : "Unknown"; + const note = assignedElsewherePaneName + ? `${session.displayName} is currently controlling ${assignedElsewherePaneName}. Connecting here moves ownership.` + : session.mcpStatus === "ready" + ? "Browser MCP is configured. Connect will be immediate." + : "This session does not currently expose the required browser automation MCP entry."; + + return ( + + ); +} + +function Tag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function ReadyPanel({ + session, + paneName, + reassigning, + previousPaneName, +}: { + session: AutomationSession; + paneName: string; + reassigning: boolean; + previousPaneName: string | null; +}) { + return ( +
+
+
+ Selected session is ready to connect +
+
+ This session already has the browser automation MCP entry, so the + connect action will immediately bind the pane to this owner. +
+
+ + {session.displayName} / {session.provider} + + Exclusive control + {paneName} + + {reassigning + ? `Moves from ${previousPaneName ?? "another pane"}` + : "Toolbar badge updates instantly"} + +
+
+
+ ); +} + +function DetailItem({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + + + {children} + +
+ ); +} + +function SetupPanel({ + session, + onSimulate, + onCopy, +}: { + session: AutomationSession; + onSimulate: () => void; + onCopy: () => void; +}) { + const snippet = getSnippetForSession(session); + return ( +
+
+
+ This session needs browser MCP setup +
+
+ The connect action will not fail silently. Add the{" "} + superset-browser MCP + server to this session and reload. +
+
    +
  1. Open the agent config used by {session.displayName}.
  2. +
  3. + Add the managed superset-browser MCP server. +
  4. +
  5. Reload the session or start a new one from this workspace.
  6. +
+
+					{snippet}
+				
+
+ + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/index.ts new file mode 100644 index 00000000000..bebcf9e5909 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/index.ts @@ -0,0 +1 @@ +export { SessionConnectModal } from "./SessionConnectModal"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 75b0ed8cb4c..abffcd18cdc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,6 +1,7 @@ import type { ExternalApp } from "@superset/local-db"; import { isTearoffWindow } from "renderer/hooks/useTearoffInit"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useBrowserAutomationStore } from "renderer/stores/browser-automation"; import { useBrowserFullscreenStore } from "renderer/stores/browser-fullscreen"; import { useSidebarStore } from "renderer/stores/sidebar-state"; import { SidebarControl } from "../../SidebarControl"; @@ -9,6 +10,7 @@ import { ContentHeader } from "./ContentHeader"; import { PresetsBar } from "./components/PresetsBar"; import { TabsContent } from "./TabsContent"; import { GroupStrip } from "./TabsContent/GroupStrip"; +import { BrowserAutomationList } from "./TabsContent/TabView/BrowserPane/components/BrowserAutomationList"; interface ContentViewProps { workspaceId: string; @@ -31,6 +33,8 @@ export function ContentView({ ); const { data: showPresetsBar } = electronTrpc.settings.getShowPresetsBar.useQuery(); + const listViewOpen = useBrowserAutomationStore((s) => s.listViewOpen); + const setListViewOpen = useBrowserAutomationStore((s) => s.setListViewOpen); return (
@@ -54,6 +58,10 @@ export function ContentView({ onOpenInApp={onOpenInApp} onOpenQuickOpen={onOpenQuickOpen} /> +
); } diff --git a/apps/desktop/src/renderer/stores/browser-automation.ts b/apps/desktop/src/renderer/stores/browser-automation.ts new file mode 100644 index 00000000000..17e6aff7229 --- /dev/null +++ b/apps/desktop/src/renderer/stores/browser-automation.ts @@ -0,0 +1,139 @@ +import { create } from "zustand"; + +export type McpStatus = "ready" | "missing" | "unknown"; + +export interface AutomationSession { + id: string; + displayName: string; + provider: "Claude" | "Codex" | string; + kind: "Terminal" | "Chat" | string; + branchOrContextLabel: string; + lastActiveAt: string; + mcpStatus: McpStatus; +} + +interface BrowserAutomationState { + sessions: Record; + /** paneId -> sessionId */ + bindings: Record; + connectModal: { + isOpen: boolean; + paneId: string | null; + selectedSessionId: string | null; + }; + listViewOpen: boolean; + openConnectModal: (paneId: string, preselectSessionId?: string) => void; + closeConnectModal: () => void; + setSelectedSession: (sessionId: string) => void; + connect: ( + paneId: string, + sessionId: string, + ) => { reassignedFromPaneId: string | null }; + disconnect: (paneId: string) => void; + markSessionReady: (sessionId: string) => void; + setListViewOpen: (open: boolean) => void; +} + +const initialSessions: Record = { + "session-14": { + id: "session-14", + displayName: "Session 14", + provider: "Codex", + kind: "Terminal", + branchOrContextLabel: "feature/browser-cdp-map", + lastActiveAt: "20s ago", + mcpStatus: "ready", + }, + "session-11": { + id: "session-11", + displayName: "Session 11", + provider: "Claude", + kind: "Terminal", + branchOrContextLabel: "checkout-debug", + lastActiveAt: "2m ago", + mcpStatus: "missing", + }, + "session-09": { + id: "session-09", + displayName: "Session 09", + provider: "Codex", + kind: "Chat", + branchOrContextLabel: "release-notes-draft", + lastActiveAt: "6m ago", + mcpStatus: "ready", + }, +}; + +export const useBrowserAutomationStore = create( + (set, get) => ({ + sessions: initialSessions, + bindings: {}, + connectModal: { isOpen: false, paneId: null, selectedSessionId: null }, + listViewOpen: false, + openConnectModal: (paneId, preselectSessionId) => { + const state = get(); + const currentBinding = state.bindings[paneId]; + const fallback = Object.keys(state.sessions)[0] ?? null; + set({ + connectModal: { + isOpen: true, + paneId, + selectedSessionId: preselectSessionId ?? currentBinding ?? fallback, + }, + }); + }, + closeConnectModal: () => + set({ + connectModal: { + isOpen: false, + paneId: null, + selectedSessionId: null, + }, + }), + setSelectedSession: (sessionId) => + set((s) => ({ + connectModal: { ...s.connectModal, selectedSessionId: sessionId }, + })), + connect: (paneId, sessionId) => { + const state = get(); + const reassignedFromPaneId = + Object.entries(state.bindings).find( + ([pid, sid]) => sid === sessionId && pid !== paneId, + )?.[0] ?? null; + const next: Record = { ...state.bindings }; + if (reassignedFromPaneId) delete next[reassignedFromPaneId]; + next[paneId] = sessionId; + set({ bindings: next }); + return { reassignedFromPaneId }; + }, + disconnect: (paneId) => + set((s) => { + const next = { ...s.bindings }; + delete next[paneId]; + return { bindings: next }; + }), + markSessionReady: (sessionId) => + set((s) => ({ + sessions: { + ...s.sessions, + [sessionId]: { ...s.sessions[sessionId], mcpStatus: "ready" }, + }, + })), + setListViewOpen: (open) => set({ listViewOpen: open }), + }), +); + +export function getSnippetForSession(session: AutomationSession): string { + if (session.provider === "Codex") { + return `[mcp_servers.superset-browser] +command = "superset-browser-mcp" +args = []`; + } + return `{ + "mcpServers": { + "superset-browser": { + "command": "superset-browser-mcp" + } + } +}`; +} diff --git a/biome.jsonc b/biome.jsonc index 24994448e31..9b818293047 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -11,7 +11,8 @@ "!**/drizzle", "!**/*.template.js", "!**/*.template.sh", - "!apps/mobile/uniwind-types.d.ts" + "!apps/mobile/uniwind-types.d.ts", + "!mock.html" ] }, "formatter": { diff --git a/mock.html b/mock.html new file mode 100644 index 00000000000..c75b4538dd2 --- /dev/null +++ b/mock.html @@ -0,0 +1,2031 @@ + + + + + + Superset Browser Automation Mock + + + +
+ + +
+
+ +
+
Ready sessions 2
+
Connected panes 1
+
Browser panes 3
+
+ +
+
+ + + +
Agent Chat
+
+ +
+
+
+
+ + + +
+
+
+ app.superset.sh/pricing + / Pricing redesign preview +
+
+
+ + + +
+
+ +
+
+ + + + Pricing redesign preview +
+
+
+
+ Automation candidate selected +
+

+ Build internal tools that feel closer to native than web. +

+

+ This pane is the one currently targeted by the mock + automation binding. The connect action lives in the browser + toolbar, so switching ownership is a single click and does + not require leaving the page context. +

+ +
+
+
42%
+
Conversion lift
+
+
+
8.6m
+
Median setup time
+
+
+
3 tabs
+
Automation targets
+
+
+
+ + +
+
+
+
+
+
+ + +
+ +
+ +
+ +
+ + + + diff --git a/todo.md b/todo.md new file mode 100644 index 00000000000..3cf6e0edaf6 --- /dev/null +++ b/todo.md @@ -0,0 +1,341 @@ +# Browser Pane LLM Connect TODO + +## 何をしたいのか + +`apps/desktop` の `v1 workspace` にある browser pane 内ブラウザを、実行中の LLM セッションに明示的に接続できるようにしたい。 + +やりたい操作は次の通り。 + +- browser pane の toolbar から `Connect` を押す +- その場で実行中の LLM セッション一覧を開く +- Claude / Codex を最初の入口では分けず、後から任意の session を選ぶ +- 選んだ session に必要な browser MCP が入っていなければ、その場で導入方法を案内する +- MCP が入ったらその pane をその session に接続する +- すでに別 pane に接続済みの session を選んだ場合は、接続先を切り替えて再割当する +- 現在どの browser pane がどの session に繋がっているかを一覧ビューでも確認したい + +重要なのは、**自動化したい対象は Superset アプリ全体ではなく、browser pane 内の webview だけ** という点。 + +## 今回のスコープ + +今回の対応範囲は `v1 workspace` のみ。 + +- 対象: `apps/desktop/src/renderer/screens/main/components/WorkspaceView/...` +- 対象 UI: + - browser pane toolbar 内の `Connect` 導線 + - session 選択モーダル + - browser pane と session の割当一覧ビュー +- 対象データ: + - browser pane と running LLM session のバインディング状態 + - session ごとの MCP ready / missing 状態 + +今回やらないこと: + +- v2 workspace 対応 +- Superset 全体 UI の自動化 +- workspace shell 自体を CDP 対象にすること +- Playwright / chrome-devtools-mcp との完全統合実装 +- 複数 owner による同時共有制御 + +## 期待する UX + +### 1. Pane 側の主導導線 + +- browser pane toolbar に `Connect` ボタンを置く +- 未接続時は `Connect` +- 接続済み時は `Session 14 · Codex` のように表示 +- クリックで session 選択モーダルを開く + +### 2. セッション選択 + +- 実行中の session を一覧表示する +- provider 名で入口を分けない +- session ごとに最低限これを表示する + - session 名 + - provider or agent 名 + - branch / title のような識別情報 + - 最終アクティブ時刻 + - MCP ready / missing + - すでに別 pane に接続済みか + +### 3. MCP 未導入時のガイド + +- session を選んだ時点で MCP 未導入なら右カラムや同一モーダル内で案内を出す +- 接続ボタンは disabled にする +- どこに何を追加すればいいかを session 種別ごとに案内する +- 必要なら設定スニペットをコピーできるようにする + +### 4. 再割当 + +- すでに別 pane を持っている session を新しい pane に接続した場合: + - 旧 pane の接続を外す + - 新 pane に session を再割当する + - ユーザーに「移動した」ことが分かるフィードバックを出す + +### 5. 一覧ビュー + +- 右サイドまたは専用パネルに browser pane 一覧を出す +- 各 pane について次を表示する + - pane 名 + - URL + - 接続状態 + - 接続中 session 名 + - setup required 状態 +- 一覧から pane を選ぶとその pane にフォーカスできる + +## 画面モック + +現時点の操作感モックはルートの `mock.html`。 + +このモックで確認したいこと: + +- `Connect` の位置が自然か +- session 選択モーダルの情報量が適切か +- MCP missing ガイドを同じ導線内に置いて違和感がないか +- 一覧ビューが必要十分か +- 再割当の挙動が直感的か + +## 実装対象の起点 + +現時点で主な起点になりそうな場所: + +- browser pane 本体 + - `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx` +- content header / content shell + - `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx` + - `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx` +- workspace sidebar + - `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx` + +## 設計方針 + +### 1. 制御単位 + +制御単位は `browser pane`。 + +- `1 browser pane = 1 webview target` +- 接続の主キーは `paneId` +- 実際に自動化する対象はその pane の webview のみ + +### 2. バインディング単位 + +接続先は `LLM session`。 + +- Claude / Codex は session の属性であり、最初の UI 分岐には使わない +- バインディングは `paneId -> sessionId` +- 1 pane には最大 1 session +- 1 session も最大 1 pane + +### 3. セッション状態 + +session ごとに最低限この状態を持つ。 + +- `sessionId` +- `displayName` +- `provider` +- `kind` +- `branchOrContextLabel` +- `lastActiveAt` +- `mcpStatus` +- `connectedPaneId` + +`mcpStatus` はまず次の 3 値で十分。 + +- `ready` +- `missing` +- `unknown` + +### 4. pane 状態 + +pane ごとに最低限この状態を持つ。 + +- `paneId` +- `tabId` +- `workspaceId` +- `title` +- `url` +- `connectedSessionId` +- `suggestedSessionId?` + +## UI 詳細設計 + +### A. BrowserPane toolbar + +`BrowserPane.tsx` の toolbar に接続状態 UI を追加する。 + +- 未接続: + - ラベル `Connect` +- 接続済み: + - `Session 14 · Codex` +- クリック時: + - session connect modal を開く + +必要なら secondary action: + +- `Disconnect` +- `Change` + +ただし最初は toolbar ボタン 1 個にまとめてもよい。 + +### B. Session Connect Modal + +構成は 2 カラム。 + +左カラム: + +- 現在の pane 情報 +- running session 一覧 + +右カラム: + +- 選択 session の詳細 +- ready なら接続概要 +- missing なら MCP 導入ガイド + +footer actions: + +- `Connect` +- `Disconnect current` +- `Cancel` + +### C. 一覧ビュー + +実装候補は 2 つ。 + +1. 既存右サイド領域に `Browser Automation` セクションを追加 +2. browser pane 専用の軽量 list panel を追加 + +初手は 1 が現実的。 + +理由: + +- v1 workspace のレイアウト変更が小さい +- 状態確認 UI を集約しやすい +- `mock.html` の方向性と近い + +## 状態管理案 + +まずは renderer 側 store で十分。 + +候補: + +- 既存 store 拡張 +- 新規 `browser-automation` store 追加 + +保持する状態: + +- `selectedSessionIdByPaneId` +- `sessions` +- `bindings` +- `connectModal` + - `isOpen` + - `paneId` + - `selectedSessionId` + +最初は永続化しなくてよい。 + +ただし将来的に欲しくなりそうなもの: + +- 最後に選んだ session +- pane ごとの前回接続先 + +## 実装段階 + +### Phase 1: UI モックを実装に寄せる + +- `mock.html` を基準に、実アプリの v1 UI へ落とす +- toolbar の `Connect` 導線を本実装に置き換える +- session modal を renderer に実装する +- 一覧ビューを暫定で出す + +### Phase 2: セッション一覧の実データ化 + +- 実行中 LLM session の列挙元を決める +- session ごとの provider / title / branch / MCP 状態を収集する +- session 選択 UI をダミーデータから実データへ置換する + +### Phase 3: バインディング管理 + +- `paneId -> sessionId` バインディングを store で管理 +- 接続 +- 切断 +- 再割当 +- UI 反映 + +### Phase 4: MCP 状態の扱い + +- session ごとの MCP ready 判定を実装する +- missing の時は案内を表示する +- provider ごとに設定先や表示文言を分ける + +### Phase 5: 実 automation bridge 接続 + +- session 側が使う `superset-browser` MCP の仕様を決める +- `paneId` を session に渡す方法を決める +- 必要なら Desktop 側 API を追加する + +## 技術課題 + +### 1. Running session の取得元 + +未整理ポイント。 + +- どこから「今動いている Claude/Codex session 一覧」を取るか +- renderer で直接見えるのか +- main 側管理なのか +- 既存の agent session 周りの仕組みを再利用できるか + +ここは最初に確認が必要。 + +### 2. MCP ready 判定 + +未整理ポイント。 + +- session ごとに MCP が入っているかをどう判定するか +- config 実ファイルを見るのか +- 起動時の session metadata を見るのか +- handshake 結果で見るのか + +最初は `unknown` を許容してもよい。 + +### 3. 実際の pane 制御との接続 + +最終的には browser pane の webview を session に結びたい。 + +必要になりそうなもの: + +- `paneId` 解決 +- webview / browser target 取得 +- screenshot / evaluate / navigation などの基盤 + +ただし今回の `todo.md` 段階では、まず UX と binding 設計を優先する。 + +## 受け入れ条件 + +最低限これができれば第一段階として成立。 + +- v1 workspace の browser pane に `Connect` 導線がある +- Connect から running session を選べる +- Claude / Codex で最初に分岐しない +- MCP missing の session では設定案内が出る +- ready な session は接続できる +- 接続済み session は別 pane へ再割当できる +- 現在の割当状態を一覧で見られる + +## すぐ着手する順番 + +1. v1 workspace における一覧ビューの配置場所を決める +2. running session のデータ取得元を確認する +3. renderer store の形を決める +4. BrowserPane toolbar に `Connect` を仮実装する +5. session connect modal を組み込む +6. 一覧ビューを組み込む +7. 実データ配線に進む + +## メモ + +- 入口は browser pane 側に置く +- provider ではなく session を選ばせる +- missing MCP を「失敗」ではなく「案内」に変える +- 一覧ビューは必要 +- 対応範囲は v1 workspace 限定 From f017f92550dcd7d11d4de13bba13a580b305e8b8 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:34:55 +0900 Subject: [PATCH 02/22] feat(desktop): wire browser-automation to real sessions & MCP detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ダミーデータを削除し、実データを参照するよう配線: - main-process に browserAutomation tRPC router を追加 - listBindings / setBinding / removeBinding (paneId -> sessionId 管理を in-memory で保持) - getMcpStatus (~/.claude/settings.json と ~/.codex/config.toml を走査し superset-browser MCP entry の有無を検出) - onBindingsChanged subscription で renderer を即時同期 - renderer の store は UI-only state (connectModal, listView) のみを保持し、 session / binding / MCP status は useBrowserAutomationData フックから取得 - session 一覧は todoAgent.listAll (動いている Claude Code の TODO-Agent セッション) を変換して表示 - SessionConnectModal の Connect / Disconnect / 再割当は trpc mutation 経由で永続化 - 空状態 (セッション無し) と MCP 未設定 (実 configPath を案内) の文言を追加 - lint:fix による TodoModal.tsx 等の format 追従も含む --- .../trpc/routers/browser-automation/index.ts | 162 ++++++++++++++ apps/desktop/src/lib/trpc/routers/index.ts | 2 + .../src/main/lib/todo-daemon/client.ts | 15 +- .../src/main/todo-agent/daemon-bridge.ts | 36 +-- apps/desktop/src/main/todo-agent/debug.ts | 4 +- .../src/main/todo-agent/runtime-config.ts | 6 +- .../src/main/todo-agent/trpc-router.ts | 2 +- .../todo-agent/TodoModal/TodoModal.tsx | 12 +- .../hooks/useBrowserAutomationData.ts | 81 +++++++ .../BrowserAutomationList.tsx | 20 +- .../ConnectButton/ConnectButton.tsx | 12 +- .../SessionConnectModal.tsx | 208 ++++++++++++------ .../src/renderer/stores/browser-automation.ts | 108 +++------ 13 files changed, 476 insertions(+), 192 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/browser-automation/index.ts create mode 100644 apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts new file mode 100644 index 00000000000..141bb1c5e79 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts @@ -0,0 +1,162 @@ +import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { observable } from "@trpc/server/observable"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +/** + * Browser automation bindings router. + * + * Stores the paneId -> sessionId assignment for browser pane automation, + * and exposes MCP-readiness detection by reading the user's agent config + * files (Claude Code / Codex) for the `superset-browser` entry. + * + * State is in-memory and process-local. Persistence across app restarts + * is intentionally out of scope for Phase 1; the binding is re-established + * by the user next time they open the Connect dialog. + */ + +export interface BrowserAutomationBinding { + paneId: string; + sessionId: string; + connectedAt: number; +} + +class BindingStore { + private readonly byPane = new Map(); + private readonly emitter = new EventEmitter(); + + list(): BrowserAutomationBinding[] { + return Array.from(this.byPane.values()); + } + + get(paneId: string): BrowserAutomationBinding | null { + return this.byPane.get(paneId) ?? null; + } + + getBySessionId(sessionId: string): BrowserAutomationBinding | null { + for (const b of this.byPane.values()) { + if (b.sessionId === sessionId) return b; + } + return null; + } + + set(paneId: string, sessionId: string): { previousPaneId: string | null } { + let previousPaneId: string | null = null; + for (const [pid, binding] of this.byPane.entries()) { + if (binding.sessionId === sessionId && pid !== paneId) { + previousPaneId = pid; + this.byPane.delete(pid); + } + } + this.byPane.set(paneId, { + paneId, + sessionId, + connectedAt: Date.now(), + }); + this.emitChange(); + return { previousPaneId }; + } + + remove(paneId: string): boolean { + const existed = this.byPane.delete(paneId); + if (existed) this.emitChange(); + return existed; + } + + private emitChange() { + this.emitter.emit("change", this.list()); + } + + onChange(cb: (bindings: BrowserAutomationBinding[]) => void): () => void { + this.emitter.on("change", cb); + return () => { + this.emitter.off("change", cb); + }; + } +} + +export const bindingStore = new BindingStore(); + +/** + * Detect whether the given agent config file exposes the + * `superset-browser` MCP entry. We do a conservative string check so + * this works for both JSON (Claude) and TOML (Codex) without pulling + * in a TOML parser. + */ +function detectSupersetBrowserMcp(filePath: string): boolean { + try { + const contents = readFileSync(filePath, "utf8"); + return contents.includes("superset-browser"); + } catch { + return false; + } +} + +const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); +const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml"); + +export const createBrowserAutomationRouter = () => { + return router({ + getMcpStatus: publicProcedure + .input( + z.object({ + provider: z.enum(["Claude", "Codex"]).optional(), + }), + ) + .query(({ input }) => { + const claudeReady = detectSupersetBrowserMcp(CLAUDE_SETTINGS_PATH); + const codexReady = detectSupersetBrowserMcp(CODEX_CONFIG_PATH); + const resolved = + input.provider === "Claude" + ? claudeReady + : input.provider === "Codex" + ? codexReady + : claudeReady || codexReady; + return { + claudeReady, + codexReady, + ready: resolved, + claudeConfigPath: CLAUDE_SETTINGS_PATH, + codexConfigPath: CODEX_CONFIG_PATH, + }; + }), + + listBindings: publicProcedure.query(() => bindingStore.list()), + + getBindingByPane: publicProcedure + .input(z.object({ paneId: z.string() })) + .query(({ input }) => bindingStore.get(input.paneId)), + + getBindingBySession: publicProcedure + .input(z.object({ sessionId: z.string() })) + .query(({ input }) => bindingStore.getBySessionId(input.sessionId)), + + setBinding: publicProcedure + .input( + z.object({ + paneId: z.string(), + sessionId: z.string(), + }), + ) + .mutation(({ input }) => bindingStore.set(input.paneId, input.sessionId)), + + removeBinding: publicProcedure + .input(z.object({ paneId: z.string() })) + .mutation(({ input }) => ({ + removed: bindingStore.remove(input.paneId), + })), + + onBindingsChanged: publicProcedure.subscription(() => { + return observable((emit) => { + emit.next(bindingStore.list()); + const off = bindingStore.onChange((list) => emit.next(list)); + return () => { + off(); + }; + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index c561e873f8c..990d6b7b8ed 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -8,6 +8,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"; @@ -51,6 +52,7 @@ export const createAppRouter = ( aivis: createAivisRouter(), analytics: createAnalyticsRouter(), browser: createBrowserRouter(), + browserAutomation: createBrowserAutomationRouter(), browserHistory: createBrowserHistoryRouter(), auth: createAuthRouter(), autoUpdate: createAutoUpdateRouter(), diff --git a/apps/desktop/src/main/lib/todo-daemon/client.ts b/apps/desktop/src/main/lib/todo-daemon/client.ts index 756eecfc62e..bf0e9889fcd 100644 --- a/apps/desktop/src/main/lib/todo-daemon/client.ts +++ b/apps/desktop/src/main/lib/todo-daemon/client.ts @@ -230,7 +230,10 @@ export class TodoDaemonClient extends EventEmitter { }, { captureMessage: true, - fingerprint: ["todo.agent.main", "todo-daemon-client-authenticated"], + fingerprint: [ + "todo.agent.main", + "todo-daemon-client-authenticated", + ], }, ); return; @@ -733,7 +736,10 @@ export class TodoDaemonClient extends EventEmitter { }, { captureMessage: true, - fingerprint: ["todo.agent.main", "todo-daemon-client-rehydrate-success"], + fingerprint: [ + "todo.agent.main", + "todo-daemon-client-rehydrate-success", + ], }, ); return response; @@ -743,7 +749,10 @@ export class TodoDaemonClient extends EventEmitter { "todo-daemon-client-rehydrate-failed", undefined, { - fingerprint: ["todo.agent.main", "todo-daemon-client-rehydrate-failed"], + fingerprint: [ + "todo.agent.main", + "todo-daemon-client-rehydrate-failed", + ], }, ); throw error; diff --git a/apps/desktop/src/main/todo-agent/daemon-bridge.ts b/apps/desktop/src/main/todo-agent/daemon-bridge.ts index c4b976e6b86..735831a32b3 100644 --- a/apps/desktop/src/main/todo-agent/daemon-bridge.ts +++ b/apps/desktop/src/main/todo-agent/daemon-bridge.ts @@ -76,14 +76,10 @@ export function startTodoAgentDaemonBridge(): Promise { console.warn( "[todo-agent] daemon disconnected — will reconnect on next RPC", ); - todoAgentMainDebug.warn( - "todo-daemon-bridge-disconnected", - undefined, - { - captureMessage: true, - fingerprint: ["todo.agent.main", "todo-daemon-bridge-disconnected"], - }, - ); + todoAgentMainDebug.warn("todo-daemon-bridge-disconnected", undefined, { + captureMessage: true, + fingerprint: ["todo.agent.main", "todo-daemon-bridge-disconnected"], + }); }); client.on("error", (error) => { console.warn("[todo-agent] daemon client error", error); @@ -98,25 +94,17 @@ export function startTodoAgentDaemonBridge(): Promise { }); } connectPromise = (async () => { - todoAgentMainDebug.info( - "todo-daemon-bridge-init", - undefined, - { - captureMessage: true, - fingerprint: ["todo.agent.main", "todo-daemon-bridge-init"], - }, - ); + todoAgentMainDebug.info("todo-daemon-bridge-init", undefined, { + captureMessage: true, + fingerprint: ["todo.agent.main", "todo-daemon-bridge-init"], + }); try { await client.ensureConnected(); await client.rehydrate(); - todoAgentMainDebug.info( - "todo-daemon-bridge-init-success", - undefined, - { - captureMessage: true, - fingerprint: ["todo.agent.main", "todo-daemon-bridge-init-success"], - }, - ); + todoAgentMainDebug.info("todo-daemon-bridge-init-success", undefined, { + captureMessage: true, + fingerprint: ["todo.agent.main", "todo-daemon-bridge-init-success"], + }); } catch (error) { console.warn("[todo-agent] daemon bridge failed to initialize", error); todoAgentMainDebug.captureException( diff --git a/apps/desktop/src/main/todo-agent/debug.ts b/apps/desktop/src/main/todo-agent/debug.ts index 51e2f3f7e9b..abd542c8c9c 100644 --- a/apps/desktop/src/main/todo-agent/debug.ts +++ b/apps/desktop/src/main/todo-agent/debug.ts @@ -1,6 +1,6 @@ import type { SelectTodoSession } from "@superset/local-db"; -import type { TodoStreamEvent } from "./types"; import { createMainDebugChannel } from "../lib/debug-channel"; +import type { TodoStreamEvent } from "./types"; const DEBUG_TODO_AGENT = process.env.SUPERSET_TODO_DEBUG === "1"; @@ -32,7 +32,7 @@ export function getTodoSessionDebugData( | "claudeSessionId" | "verdictReason" | "waitingReason" - > + >, ) { return { sessionId: session.id, diff --git a/apps/desktop/src/main/todo-agent/runtime-config.ts b/apps/desktop/src/main/todo-agent/runtime-config.ts index 31033494a1a..26d83414625 100644 --- a/apps/desktop/src/main/todo-agent/runtime-config.ts +++ b/apps/desktop/src/main/todo-agent/runtime-config.ts @@ -152,11 +152,7 @@ export function writeTodoSessionRuntimeConfig( const normalized = normalizeConfig(config); const filePath = getRuntimeConfigPath(artifactPath); mkdirSync(artifactPath, { recursive: true }); - writeFileSync( - filePath, - `${JSON.stringify(normalized, null, 2)}\n`, - "utf8", - ); + writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8"); todoAgentMainDebug.info( "todo-runtime-config-write", { diff --git a/apps/desktop/src/main/todo-agent/trpc-router.ts b/apps/desktop/src/main/todo-agent/trpc-router.ts index 7d47d78ff2f..f13050ad4db 100644 --- a/apps/desktop/src/main/todo-agent/trpc-router.ts +++ b/apps/desktop/src/main/todo-agent/trpc-router.ts @@ -10,6 +10,7 @@ import { publicProcedure, router } from "lib/trpc"; import { localDb } from "main/lib/local-db"; import { workspaceInitManager } from "main/lib/workspace-init-manager"; import { z } from "zod"; +import { getTodoSessionDebugData, todoAgentMainDebug } from "./debug"; import { describeEnhanceFailure, enhanceTodoText } from "./enhance-text"; import { getSessionFileDiff, @@ -25,7 +26,6 @@ import { computeNextRunAt, getTodoScheduler } from "./scheduler"; import { getTodoSessionStore, resolveWorktreePath } from "./session-store"; import { getTodoSettings, updateTodoSettings } from "./settings"; import { getTodoSupervisor } from "./supervisor"; -import { getTodoSessionDebugData, todoAgentMainDebug } from "./debug"; import { TODO_ARTIFACT_SUBDIR, type TodoScheduleFireEvent, diff --git a/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx b/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx index 587ad5e0049..c9c52d418a9 100644 --- a/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx +++ b/apps/desktop/src/renderer/features/todo-agent/TodoModal/TodoModal.tsx @@ -216,7 +216,11 @@ export function TodoModal({ }, { captureMessage: true, - fingerprint: ["todo.agent.renderer", "todo-create-submit", "todo-modal"], + fingerprint: [ + "todo.agent.renderer", + "todo-create-submit", + "todo-modal", + ], }, ); const created = await create.mutateAsync({ @@ -277,7 +281,11 @@ export function TodoModal({ errorMessage: message, }, { - fingerprint: ["todo.agent.renderer", "todo-create-submit-failed", "todo-modal"], + fingerprint: [ + "todo.agent.renderer", + "todo-create-submit-failed", + "todo-modal", + ], }, ); toast.error(message); diff --git a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts new file mode 100644 index 00000000000..c77d9db3ea8 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts @@ -0,0 +1,81 @@ +import { useMemo } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + type AutomationSession, + formatRelativeTime, + type McpStatus, +} from "renderer/stores/browser-automation"; + +/** + * Aggregates browser-automation session and binding data from the + * main-process tRPC routers. Sessions are derived from the TODO-agent + * session list (each row is a running Claude/Codex worker), and their + * MCP readiness is resolved against the user's Claude/Codex config + * files. + */ +export function useBrowserAutomationData() { + const { data: todoSessions = [], refetch: refetchSessions } = + electronTrpc.todoAgent.listAll.useQuery(undefined, { + refetchOnWindowFocus: true, + refetchInterval: 5000, + }); + const { data: mcpStatus } = + electronTrpc.browserAutomation.getMcpStatus.useQuery( + {}, + { refetchOnWindowFocus: true, refetchInterval: 10000 }, + ); + const { data: bindings = [] } = + electronTrpc.browserAutomation.listBindings.useQuery(undefined, { + refetchInterval: 1000, + }); + electronTrpc.browserAutomation.onBindingsChanged.useSubscription(undefined, { + onData: () => { + // The query above drives rendering; the subscription exists so the + // query invalidates reactively when another window mutates state. + }, + }); + + const sessions: AutomationSession[] = useMemo(() => { + return todoSessions + .filter((s) => s.status !== "done" && s.status !== "failed") + .map((s) => { + // Todo-agent rows always represent Claude Code workers (see + // todo-daemon/claude-code-runner.ts). We label them as Claude + // here; Codex workers would be represented by a different row + // type if/when they land. + const provider = "Claude" as const; + const mcp: McpStatus = mcpStatus + ? mcpStatus.claudeReady + ? "ready" + : "missing" + : "unknown"; + 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: "Terminal", + branchOrContextLabel: branchOrContext, + lastActiveAt: formatRelativeTime(s.updatedAt ?? s.createdAt), + mcpStatus: mcp, + }; + }); + }, [todoSessions, mcpStatus]); + + const bindingsByPane = useMemo(() => { + const map: Record = {}; + for (const b of bindings) map[b.paneId] = b.sessionId; + return map; + }, [bindings]); + + return { + sessions, + bindingsByPane, + mcpStatus, + refetchSessions, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx index 2214f9522fb..b5f4591568b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx @@ -8,6 +8,7 @@ import { } 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"; @@ -22,8 +23,7 @@ export function BrowserAutomationList({ }: BrowserAutomationListProps) { const panes = useTabsStore((s) => s.panes); const setFocusedPane = useTabsStore((s) => s.setFocusedPane); - const bindings = useBrowserAutomationStore((s) => s.bindings); - const sessions = useBrowserAutomationStore((s) => s.sessions); + const { sessions, bindingsByPane } = useBrowserAutomationData(); const openConnectModal = useBrowserAutomationStore((s) => s.openConnectModal); const browserPanes = useMemo( @@ -31,8 +31,10 @@ export function BrowserAutomationList({ [panes], ); - const connectedCount = browserPanes.filter((p) => bindings[p.id]).length; - const needsSetupCount = Object.values(sessions).filter( + const connectedCount = browserPanes.filter( + (p) => bindingsByPane[p.id], + ).length; + const needsSetupCount = sessions.filter( (s) => s.mcpStatus === "missing", ).length; @@ -59,8 +61,10 @@ export function BrowserAutomationList({ )} {browserPanes.map((pane) => { - const sessionId = bindings[pane.id]; - const session = sessionId ? sessions[sessionId] : null; + 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 (
{session ? `${session.displayName} · ${session.provider} · ${session.mcpStatus === "ready" ? "MCP ready" : "MCP missing"}` - : "Pick any running Claude or Codex session"} + : "Pick any running LLM session"}
)} @@ -205,7 +261,11 @@ export function SessionConnectModal({ +
+ MCP readiness is detected by inspecting the config file for the string{" "} + superset-browser. If you prefer a managed location, the + desktop app also ships the server at packages/desktop-mcp + . +
); diff --git a/apps/desktop/src/renderer/stores/browser-automation.ts b/apps/desktop/src/renderer/stores/browser-automation.ts index 17e6aff7229..e55f6796050 100644 --- a/apps/desktop/src/renderer/stores/browser-automation.ts +++ b/apps/desktop/src/renderer/stores/browser-automation.ts @@ -12,10 +12,18 @@ export interface AutomationSession { mcpStatus: McpStatus; } -interface BrowserAutomationState { - sessions: Record; - /** paneId -> sessionId */ - bindings: Record; +/** + * UI-only state for the browser-automation feature. + * + * Real data (sessions, bindings, MCP status) lives in main-process tRPC + * routers and is consumed via `useBrowserAutomationData`. This store + * only tracks transient UI state that does not need to survive reloads + * or sync with the main process: + * - Which pane opened the Connect dialog, and which session is + * currently highlighted within it. + * - Whether the cross-pane list view dialog is open. + */ +interface BrowserAutomationUiState { connectModal: { isOpen: boolean; paneId: string | null; @@ -25,63 +33,21 @@ interface BrowserAutomationState { openConnectModal: (paneId: string, preselectSessionId?: string) => void; closeConnectModal: () => void; setSelectedSession: (sessionId: string) => void; - connect: ( - paneId: string, - sessionId: string, - ) => { reassignedFromPaneId: string | null }; - disconnect: (paneId: string) => void; - markSessionReady: (sessionId: string) => void; setListViewOpen: (open: boolean) => void; } -const initialSessions: Record = { - "session-14": { - id: "session-14", - displayName: "Session 14", - provider: "Codex", - kind: "Terminal", - branchOrContextLabel: "feature/browser-cdp-map", - lastActiveAt: "20s ago", - mcpStatus: "ready", - }, - "session-11": { - id: "session-11", - displayName: "Session 11", - provider: "Claude", - kind: "Terminal", - branchOrContextLabel: "checkout-debug", - lastActiveAt: "2m ago", - mcpStatus: "missing", - }, - "session-09": { - id: "session-09", - displayName: "Session 09", - provider: "Codex", - kind: "Chat", - branchOrContextLabel: "release-notes-draft", - lastActiveAt: "6m ago", - mcpStatus: "ready", - }, -}; - -export const useBrowserAutomationStore = create( - (set, get) => ({ - sessions: initialSessions, - bindings: {}, +export const useBrowserAutomationStore = create( + (set) => ({ connectModal: { isOpen: false, paneId: null, selectedSessionId: null }, listViewOpen: false, - openConnectModal: (paneId, preselectSessionId) => { - const state = get(); - const currentBinding = state.bindings[paneId]; - const fallback = Object.keys(state.sessions)[0] ?? null; + openConnectModal: (paneId, preselectSessionId) => set({ connectModal: { isOpen: true, paneId, - selectedSessionId: preselectSessionId ?? currentBinding ?? fallback, + selectedSessionId: preselectSessionId ?? null, }, - }); - }, + }), closeConnectModal: () => set({ connectModal: { @@ -94,31 +60,6 @@ export const useBrowserAutomationStore = create( set((s) => ({ connectModal: { ...s.connectModal, selectedSessionId: sessionId }, })), - connect: (paneId, sessionId) => { - const state = get(); - const reassignedFromPaneId = - Object.entries(state.bindings).find( - ([pid, sid]) => sid === sessionId && pid !== paneId, - )?.[0] ?? null; - const next: Record = { ...state.bindings }; - if (reassignedFromPaneId) delete next[reassignedFromPaneId]; - next[paneId] = sessionId; - set({ bindings: next }); - return { reassignedFromPaneId }; - }, - disconnect: (paneId) => - set((s) => { - const next = { ...s.bindings }; - delete next[paneId]; - return { bindings: next }; - }), - markSessionReady: (sessionId) => - set((s) => ({ - sessions: { - ...s.sessions, - [sessionId]: { ...s.sessions[sessionId], mcpStatus: "ready" }, - }, - })), setListViewOpen: (open) => set({ listViewOpen: open }), }), ); @@ -137,3 +78,18 @@ args = []`; } }`; } + +function formatRelativeTime(ts: number | null | undefined): string { + if (!ts) return "unknown"; + const diffSec = Math.round((Date.now() - ts) / 1000); + if (diffSec < 5) return "just now"; + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHour = Math.round(diffMin / 60); + if (diffHour < 24) return `${diffHour}h ago`; + const diffDay = Math.round(diffHour / 24); + return `${diffDay}d ago`; +} + +export { formatRelativeTime }; From 73b636498a929cbf17964bd937ad729a30b327f5 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Mon, 20 Apr 2026 03:36:33 +0900 Subject: [PATCH 03/22] fix(desktop): address codex review on BrowserAutomationList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workspace フィルタ: tabs.workspaceId で現在の workspace に属する browser pane だけを表示 - Focus ボタン: setFocusedPane の前に setActiveTab で pane が属する tab をアクティブ化し、 別 tab 上の pane を選んだ時もユーザーが遷移を体感できるようにする Refs: - apps/desktop/src/renderer/.../BrowserAutomationList.tsx:31 (codex P2) - apps/desktop/src/renderer/.../BrowserAutomationList.tsx:108 (codex P2) --- .../BrowserAutomationList.tsx | 17 +++++++++++++---- .../WorkspaceView/ContentView/index.tsx | 1 + 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx index b5f4591568b..d4b921ad50f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx @@ -13,23 +13,31 @@ 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(); const openConnectModal = useBrowserAutomationStore((s) => s.openConnectModal); - const browserPanes = useMemo( - () => Object.values(panes).filter((p) => p.type === "webview"), - [panes], - ); + 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]); const connectedCount = browserPanes.filter( (p) => bindingsByPane[p.id], @@ -104,6 +112,7 @@ export function BrowserAutomationList({ size="sm" variant="outline" onClick={() => { + setActiveTab(workspaceId, pane.tabId); setFocusedPane(pane.tabId, pane.id); onOpenChange(false); }} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index abffcd18cdc..998669a01eb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -59,6 +59,7 @@ export function ContentView({ onOpenQuickOpen={onOpenQuickOpen} /> From b75711bae2f80651a30f06f4a20b860ea00c2494 Mon Sep 17 00:00:00 2001 From: MocA-Love <64681295+MocA-Love@users.noreply.github.com> Date: Mon, 20 Apr 2026 04:06:43 +0900 Subject: [PATCH 04/22] fix(desktop): handle more session terminal states and stale bindings - useBrowserAutomationData: exclude aborted/escalated sessions too (P1) - SessionConnectModal: allow disconnect when pane has a binding even if the bound session is no longer in the running list (P2) --- .../src/renderer/hooks/useBrowserAutomationData.ts | 8 +++++++- .../SessionConnectModal/SessionConnectModal.tsx | 10 ++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts index c77d9db3ea8..5dd59172cef 100644 --- a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts +++ b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts @@ -36,8 +36,14 @@ export function useBrowserAutomationData() { }); const sessions: AutomationSession[] = useMemo(() => { + const terminalStatuses = new Set([ + "done", + "failed", + "aborted", + "escalated", + ]); return todoSessions - .filter((s) => s.status !== "done" && s.status !== "failed") + .filter((s) => !terminalStatuses.has(s.status)) .map((s) => { // Todo-agent rows always represent Claude Code workers (see // todo-daemon/claude-code-runner.ts). We label them as Claude diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx index a50b8242678..f3896e14375 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx @@ -109,11 +109,12 @@ export function SessionConnectModal({ }; const handleDisconnect = async () => { - if (!paneId || !currentSession) return; + if (!paneId || !currentBinding) return; try { await removeBinding.mutateAsync({ paneId }); await utils.browserAutomation.listBindings.invalidate(); - toast.info(`${currentSession.displayName} disconnected from ${paneName}`); + const label = currentSession?.displayName ?? "Previous session"; + toast.info(`${label} disconnected from ${paneName}`); } catch (error) { toast.error( `Failed to disconnect: ${error instanceof Error ? error.message : String(error)}`, @@ -242,14 +243,15 @@ export function SessionConnectModal({ : "Select a session from the left."}
- {currentSession && ( + {currentBinding && ( )}