Skip to content

Commit 69d0469

Browse files
Give every chat its own browser tab so chats browse in parallel (GODM-28) (#31)
Chats that share a browser profile no longer take turns. Each chat works in its own tabs of the profile's Chromium (its first tab in a background window), with the same cookies and logins, without touching other chats' pages. browser-use now connects through a per-run, token-protected loopback DevTools endpoint that only shows the run's chat its own tabs: target lists and events are filtered, commands for other tabs or sessions are refused, and tabs and popups the chat opens become its own. Vault fills, the live view, navigate/takeover and missing-login detection use the chat's tab. Tabs close when a chat is deleted, archived or idle; the browser's last page is kept blank for the next chat. The chat's browser panel shows only its own tab and appears once the agent opens one; the Browser page lists the chats browsing in a profile as tabs.
1 parent 3101965 commit 69d0469

36 files changed

Lines changed: 1872 additions & 262 deletions

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ needs to be useful:
5858
| 🤝 **Delegation** | Agents hand tasks to peer agents or spawn short-lived subagents. The Godmode agent can list, check, create and configure all agents. |
5959
| ⌨️ **Slash commands** | Type `/` for every Claude Code command — `/compact`, `/model`, `/effort`, `/clear`… — with argument hints and tab completion. |
6060
| 🌐 **Real browser** | browser-use drives a managed Chromium over CDP. Watch it live right next to the chat and *take control* for CAPTCHAs. |
61+
| 🗂️ **Parallel chats, own tabs** | Every chat gets its own tab (in its own background window) in the shared browser profile, so several chats and agents browse at the same time — signed in with the same logins, without ever touching each other's pages. |
6162
| 🖥️ **Computer use** | Share a single window, a display, the entire desktop (every monitor) or a browser tab with an agent — like sharing your screen with ChatGPT. A shared window is controlled **in the background** with [Cua Driver](https://github.com/trycua/cua): your mouse and keyboard stay yours. Watch live and take over anytime. |
6263
| 💻 **macOS VMs** | Give an agent its own Mac: spin up isolated macOS virtual machines (Apple's Virtualization framework, via [Tart](https://tart.run)) with one click and assign them to an agent, a chat or a workspace. The agent works *entirely inside the VM* — commands, files, apps (computer use with Cua Driver) and the web (Google Chrome with browser-use) — and no browser opens on your Mac. Watch the VM's screen next to the chat and take control anytime. Allow it once and agents sign in inside the VM too: Godmode fills your saved logins and 2FA codes for them (best effort — the agent controls the VM, so it's closer to reveal than to fill-only). VMs live on your Mac, keep everything between tasks, suspend when you quit, and can be reset to a clean macOS or duplicated in seconds. |
6364
| 🍪 **Chrome session import** | Continue where Chrome left off — import cookies from your Chrome/Edge/Brave profile (profile-use technique), or sync via browser-use `profile-use`. |
@@ -115,6 +116,10 @@ needs to be useful:
115116
<td><img src="docs/screenshots/messaging.png" alt="Slack, Telegram and Teams bots connected to agents" /><br /><sub><b>Messaging</b> — talk to agents from Slack, Telegram and Microsoft Teams</sub></td>
116117
<td><img src="docs/screenshots/messaging-bot.png" alt="A bot's access requests, agents and who may use it" /><br /><sub><b>Bot settings</b> — approve people, pick agents, see every chat</sub></td>
117118
</tr>
119+
<tr>
120+
<td><img src="docs/screenshots/chat-own-tab.png" alt="A chat next to a live preview of its own browser tab" /><br /><sub><b>Own tab per chat</b> — the preview shows only this chat's page, while others browse alongside</sub></td>
121+
<td><img src="docs/screenshots/browser-chats.png" alt="Browser page with one tab per chat browsing in the profile" /><br /><sub><b>Parallel chats</b> — one browser profile, one tab per chat; switch between them live</sub></td>
122+
</tr>
118123
</table>
119124

120125
## 📦 Install

‎apps/desktop/src/components/browser/live-view.tsx‎

Lines changed: 93 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent, type Keyboard
22
import { useMutation, useQueryClient } from "@tanstack/react-query";
33
import { AnimatePresence, motion } from "motion/react";
44
import { Globe, Hand, Lock, Maximize2, Minimize2, MousePointerClick, Play, RotateCw } from "lucide-react";
5-
import type { BrowserProfile } from "@godmode/shared";
5+
import { browserView, type BrowserChat, type BrowserProfile } from "@godmode/shared";
66
import { Button } from "@/components/ui/button";
77
import { Spinner } from "@/components/ui/spinner";
88
import { Switch } from "@/components/ui/switch";
@@ -44,17 +44,23 @@ interface Ripple {
4444
/**
4545
* Live view of a Godmode browser profile (CDP screencast frames over the WebSocket) with optional human takeover:
4646
* clicks, scrolling and typing on the image are forwarded to the page.
47+
* With `conversationId` it shows the tab that chat works in; with `onConversationChange` too, the tab strip lists
48+
* the chats browsing in the profile to switch between them.
4749
* Pass `expanded` + `onExpandedChange` to control the focus view from outside (e.g. the chat's browser panel).
4850
*/
4951
export function LiveView({
5052
profile,
53+
conversationId = null,
54+
onConversationChange,
5155
onLaunch,
5256
launching,
5357
expanded: expandedProp,
5458
onExpandedChange,
5559
defaultTakeover = false,
5660
}: {
5761
profile: BrowserProfile;
62+
conversationId?: string | null;
63+
onConversationChange?: (conversationId: string) => void;
5864
onLaunch: () => void;
5965
launching: boolean;
6066
expanded?: boolean;
@@ -63,7 +69,10 @@ export function LiveView({
6369
}) {
6470
const qc = useQueryClient();
6571
const running = profile.running;
66-
const frame = useLive((s) => (running ? s.frames[profile.id] : undefined));
72+
const view = browserView(profile.id, conversationId);
73+
const frame = useLive((s) => (running ? s.frames[view] : undefined));
74+
const chat = conversationId ? profile.chats.find((c) => c.conversationId === conversationId) : undefined;
75+
const noTabYet = !!conversationId && running && !chat && !frame;
6776
const hasFrame = !!frame;
6877
const now = useNow(1000);
6978

@@ -95,18 +104,18 @@ export function LiveView({
95104
// Subscribe to frames while the profile is running.
96105
useEffect(() => {
97106
if (!running) return;
98-
return subscribeBrowser(profile.id);
99-
}, [profile.id, running]);
107+
return subscribeBrowser(profile.id, { conversationId });
108+
}, [profile.id, conversationId, running]);
100109

101-
// Reset per-profile UI state.
102-
const shownProfileId = useRef(profile.id);
110+
// Reset per-view UI state.
111+
const shownView = useRef(view);
103112
useEffect(() => {
104-
if (shownProfileId.current === profile.id) return;
105-
shownProfileId.current = profile.id;
113+
if (shownView.current === view) return;
114+
shownView.current = view;
106115
setTakeover(false);
107116
setEditingUrl(false);
108117
setRipples([]);
109-
}, [profile.id]);
118+
}, [view]);
110119

111120
useEffect(() => {
112121
if (!running) setTakeover(false);
@@ -118,7 +127,7 @@ export function LiveView({
118127
if (!running || hasFrame) return;
119128
const t = setTimeout(() => setWaitedLong(true), 5000);
120129
return () => clearTimeout(t);
121-
}, [running, hasFrame, profile.id]);
130+
}, [running, hasFrame, view]);
122131

123132
// Esc leaves the expanded view when not taking over (in takeover Esc goes to the page).
124133
useEffect(() => {
@@ -149,15 +158,15 @@ export function LiveView({
149158
const send = useCallback(
150159
(event: InputEvent) => {
151160
queue.current = queue.current
152-
.then(() => api.browser.input(profile.id, event).then(() => undefined))
161+
.then(() => api.browser.input(profile.id, event, conversationId).then(() => undefined))
153162
.catch((e) => {
154163
if (Date.now() - lastErrorAt.current > 3000) {
155164
lastErrorAt.current = Date.now();
156165
toastApiError(e, "Input could not be delivered", qc);
157166
}
158167
});
159168
},
160-
[profile.id, qc],
169+
[profile.id, conversationId, qc],
161170
);
162171

163172
const textBuf = useRef("");
@@ -252,7 +261,7 @@ export function LiveView({
252261
/* ------------------------------- navigation ------------------------------- */
253262

254263
const navigate = useMutation({
255-
mutationFn: (url: string) => api.browser.navigate(profile.id, url),
264+
mutationFn: (url: string) => api.browser.navigate(profile.id, url, conversationId),
256265
onError: (e) => toastApiError(e, "Navigation failed", qc),
257266
});
258267

@@ -314,10 +323,14 @@ export function LiveView({
314323
<span className="size-2.5 rounded-full bg-foreground/15" />
315324
<span className="size-2.5 rounded-full bg-foreground/15" />
316325
</div>
317-
<div className="-mb-px flex min-w-0 max-w-72 items-center gap-2 rounded-t-md border border-b-0 bg-card px-3 py-1.5 text-xs">
318-
{domain ? <Favicon domain={domain} name={frame?.title || domain} size="sm" className="size-4 rounded-sm" /> : <Globe className="size-3.5 text-muted-foreground" />}
319-
<span className="truncate font-medium">{running ? frame?.title || domain || "New tab" : profile.name}</span>
320-
</div>
326+
{running && onConversationChange && profile.chats.length > 0 ? (
327+
<ChatTabs chats={profile.chats} selected={conversationId} onSelect={onConversationChange} />
328+
) : (
329+
<div className="-mb-px flex min-w-0 max-w-72 items-center gap-2 rounded-t-md border border-b-0 bg-card px-3 py-1.5 text-xs">
330+
{domain ? <Favicon domain={domain} name={frame?.title || domain} size="sm" className="size-4 rounded-sm" /> : <Globe className="size-3.5 text-muted-foreground" />}
331+
<span className="truncate font-medium">{running ? frame?.title || chat?.pageTitle || domain || "New tab" : profile.name}</span>
332+
</div>
333+
)}
321334
<div className="ml-auto flex items-center gap-2 pb-2">
322335
{running && (
323336
<span
@@ -417,7 +430,9 @@ export function LiveView({
417430
<span className="min-w-0 flex-1">
418431
{takeover
419432
? "You're in control — clicks, scrolling and typing go to the page. Turn off Take over when you're done."
420-
: "Agents keep working in this browser — take over to solve CAPTCHAs or log in manually."}
433+
: conversationId
434+
? "This chat's own tab — other chats browse in theirs. Take over to solve CAPTCHAs or log in manually."
435+
: "Agents keep working in this browser — take over to solve CAPTCHAs or log in manually."}
421436
</span>
422437
</div>
423438
)}
@@ -453,6 +468,8 @@ export function LiveView({
453468
>
454469
{!running ? (
455470
<NotRunning profileName={profile.name} onLaunch={onLaunch} launching={launching} />
471+
) : noTabYet ? (
472+
<NoTabYet />
456473
) : frame ? (
457474
<>
458475
<img
@@ -526,6 +543,64 @@ function NotRunning({ profileName, onLaunch, launching }: { profileName: string;
526543
);
527544
}
528545

546+
/** One tab per chat browsing in the profile, a dot while its agent is at work. */
547+
function ChatTabs({ chats, selected, onSelect }: { chats: BrowserChat[]; selected: string | null; onSelect: (conversationId: string) => void }) {
548+
return (
549+
<div
550+
role="tablist"
551+
aria-label="Chats in this browser"
552+
className="-mb-px flex min-w-0 flex-1 items-end gap-1 overflow-x-auto [mask-image:linear-gradient(to_right,black_calc(100%-2rem),transparent)] pr-8 [scrollbar-width:none]"
553+
>
554+
{chats.map((chat) => {
555+
const active = chat.conversationId === selected;
556+
const domain = domainFromUrl(chat.url);
557+
const title = chat.title || "Untitled chat";
558+
return (
559+
<Tooltip key={chat.conversationId}>
560+
<TooltipTrigger asChild>
561+
<button
562+
type="button"
563+
role="tab"
564+
aria-selected={active}
565+
onClick={() => onSelect(chat.conversationId)}
566+
className={cn(
567+
"flex h-8 max-w-56 min-w-28 shrink-0 items-center gap-2 rounded-t-md border border-b-0 px-3 text-xs transition outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
568+
active ? "bg-card font-medium text-foreground" : "border-transparent text-muted-foreground hover:bg-card/60 hover:text-foreground",
569+
)}
570+
>
571+
{domain ? <Favicon domain={domain} name={chat.pageTitle || domain} size="sm" className="size-4 rounded-sm" /> : <Globe className="size-3.5 shrink-0" />}
572+
<span className="truncate">{title}</span>
573+
{chat.active && <LiveDot className="ml-auto shrink-0" />}
574+
</button>
575+
</TooltipTrigger>
576+
<TooltipContent className="max-w-72">
577+
<p className="font-medium">{title}</p>
578+
<p className="truncate text-muted-foreground">
579+
{chat.pageTitle || domain || "New tab"}
580+
{chat.tabs > 1 ? ` · ${chat.tabs} tabs` : ""}
581+
</p>
582+
</TooltipContent>
583+
</Tooltip>
584+
);
585+
})}
586+
</div>
587+
);
588+
}
589+
590+
function NoTabYet() {
591+
return (
592+
<div className="absolute inset-0 grid place-items-center bg-paper-2">
593+
<div className="flex max-w-xs flex-col items-center gap-3 px-6 text-center">
594+
<span className="grid size-10 place-items-center rounded-lg border bg-card text-muted-foreground shadow-card">
595+
<Globe className="size-5" />
596+
</span>
597+
<p className="text-sm font-medium">No page open in this chat</p>
598+
<p className="text-xs text-muted-foreground">The agent opens its own tab when it needs the web. Enter an address above to open one yourself.</p>
599+
</div>
600+
</div>
601+
);
602+
}
603+
529604
function WaitingForFrame({ waitedLong }: { waitedLong: boolean }) {
530605
return (
531606
<div className="absolute inset-0 grid place-items-center bg-paper-2 text-muted-foreground">

‎apps/desktop/src/components/browser/profile-list.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ function ProfileCard({
202202
<Cookie className="size-3" /> {p.cookieCount.toLocaleString()}
203203
</span>
204204
<span className={cn("inline-flex items-center gap-1", p.running && "text-brand-strong")}>
205-
{p.running ? "Running" : "Stopped"}
205+
{p.running ? (p.chats.length ? `${p.chats.length} ${p.chats.length === 1 ? "chat" : "chats"}` : "Running") : "Stopped"}
206206
</span>
207207
</div>
208208
{p.importedFrom && (

0 commit comments

Comments
 (0)