Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
187 changes: 187 additions & 0 deletions apps/desktop/src/components/browser/profile-chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { useState } from "react";
import { useNavigate } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { Agent, BrowserProfile } from "@godmode/shared";
import { Check, ChevronDown, Globe, Loader2, Plus, Settings2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { LiveDot } from "@/components/aicss/Motion";
import { agentBrowserProfile } from "@/components/chat/browser-panel";
import { api } from "@/lib/api";
import { useSettings, useWorkspaces } from "@/lib/hooks";
import { qk } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { CreateProfileDialog } from "./profile-list";

function ProfileTile({ profile, quiet }: { profile: BrowserProfile | null; quiet?: boolean }) {
return (
<span className={cn("relative grid size-6 shrink-0 place-items-center rounded-md border text-muted-foreground", quiet ? "bg-paper-2" : "bg-card")}>
<Globe className="size-3.5" />
{profile?.running && <LiveDot className="absolute -right-0.5 -bottom-0.5 ring-2 ring-popover" />}
</span>
);
}

/**
* Composer control for the browser profile a chat works in: a quiet button while the chat uses the default profile,
* a pill with the profile's name otherwise (dashed when it comes from the agent or its workspace). Hidden when the
* agent has no browser.
*/
export function BrowserProfileChip({
agent,
value,
onChange,
busy,
}: {
agent: Agent | undefined;
/** The chat's own profile; null = the agent's. */
value: string | null;
onChange: (profileId: string | null) => void | Promise<unknown>;
busy?: boolean;
}) {
const { data: settings } = useSettings();
const enabled = !!agent?.browser.enabled && settings?.browser.enabled !== false;
const { data: profiles = [] } = useQuery({ queryKey: qk.browserProfiles, queryFn: api.browser.profiles, enabled });
const { data: workspaces = [] } = useWorkspaces();
const navigate = useNavigate();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
if (!enabled || !agent) return null;

const scopeName = (p: BrowserProfile) => (p.workspaceId ? (workspaces.find((w) => w.id === p.workspaceId)?.name ?? "Workspace") : "Global");
const own = value ? (profiles.find((p) => p.id === value) ?? null) : null;
const fallback = agentBrowserProfile(agent, profiles);
const fallbackFrom = !fallback
? "Loading profiles…"
: agent.browser.profileId === fallback.id
? `Pinned in ${agent.name}'s settings`
: fallback.workspaceId
? `Default of the ${scopeName(fallback)} workspace`
: "Your default profile";
const shown = own ?? (fallback && (fallback.workspaceId || !fallback.isDefault) ? fallback : null);

const pick = (profileId: string | null) => {
setOpen(false);
if (profileId !== value) void onChange(profileId);
};
const tooltip = own ? `This chat browses in ${own.name}` : fallback ? `Browses in ${fallback.name} — ${fallbackFrom.toLowerCase()}` : "Browser profile";
const icon = busy ? <Loader2 className="size-3.5 shrink-0 animate-spin" /> : <Globe className="size-3.5 shrink-0 text-muted-foreground" />;

return (
<>
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
{shown ? (
<button
type="button"
aria-label={`Browser profile: ${shown.name}${own ? "" : " (default)"}`}
className={cn(
"flex h-8 max-w-[12rem] min-w-14 shrink items-center gap-1.5 rounded-lg border pr-1.5 pl-2 text-[13px] transition focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
own ? "bg-card hover:bg-accent" : "border-dashed text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{icon}
<span className={cn("truncate @max-md/composer:sr-only", own && "font-medium")}>{shown.name}</span>
{shown.running && <LiveDot className="shrink-0" />}
<ChevronDown className="size-3.5 shrink-0 opacity-60" />
</button>
) : (
<Button
type="button"
variant="ghost"
aria-label="Browser profile"
className={cn(
"h-8 gap-1.5 rounded-lg px-2 text-[13px] font-normal text-muted-foreground hover:text-foreground has-[>svg]:px-2 [&_svg:not([class*='size-'])]:size-4",
open && "bg-accent text-foreground",
)}
>
{busy ? <Loader2 className="animate-spin" /> : <Globe />}
<span className="@max-sm/composer:sr-only">Browser</span>
</Button>
)}
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
<PopoverContent align="start" side="top" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0">
<div className="border-b px-3 pt-2.5 pb-2">
<p className="text-[13px] font-medium">Browser profile</p>
<p className="text-xs text-muted-foreground">Runs in this chat browse with this profile's cookies and logins.</p>
</div>
<Command>
{profiles.length > 6 && <CommandInput placeholder="Find a profile…" />}
<CommandList className="max-h-72">
<CommandEmpty>No profile with that name.</CommandEmpty>
<CommandGroup>
<CommandItem value={`__default ${fallback?.name ?? ""}`} onSelect={() => pick(null)} className="gap-2.5 py-2">
<ProfileTile profile={fallback} quiet />
<span className="min-w-0 flex-1">
<span className="block truncate">{fallback && fallback.name.toLowerCase() !== "default" ? `Default — ${fallback.name}` : "Default"}</span>
<span className="block truncate text-[11px] text-muted-foreground">{fallbackFrom}</span>
</span>
{!value && <Check className="size-4" aria-label="Selected" />}
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Browse in">
{profiles.map((p) => (
<CommandItem key={p.id} value={`${p.name} ${p.id}`} onSelect={() => pick(p.id)} className="gap-2.5 py-2">
<ProfileTile profile={p} />
<span className="min-w-0 flex-1">
<span className="block truncate">{p.name}</span>
<span className="block truncate text-[11px] text-muted-foreground">
{[p.running ? "Running" : null, scopeName(p), p.cookieCount ? `${p.cookieCount.toLocaleString()} cookies` : "No sessions yet"]
.filter(Boolean)
.join(" · ")}
</span>
</span>
{own?.id === p.id && <Check className="size-4" aria-label="Selected" />}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<div className="flex items-center gap-1 border-t p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="flex-1 justify-start font-normal text-muted-foreground"
onClick={() => {
setOpen(false);
setCreating(true);
}}
>
<Plus /> New profile
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="font-normal text-muted-foreground"
onClick={() => {
setOpen(false);
navigate(own ? `/browser?profile=${own.id}` : "/browser");
}}
>
<Settings2 /> Manage
</Button>
</div>
</PopoverContent>
</Popover>
<CreateProfileDialog
open={creating}
onOpenChange={setCreating}
onCreated={(p) => {
qc.setQueryData<BrowserProfile[]>(qk.browserProfiles, (old) => (old && !old.some((x) => x.id === p.id) ? [...old, p] : old));
void onChange(p.id);
}}
/>
</>
);
}
25 changes: 17 additions & 8 deletions apps/desktop/src/components/chat/browser-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,22 @@ import { cn } from "@/lib/utils";

export type BrowserFocusMode = "watch" | "control";

/** Mirrors the core's resolveProfileForAgent: pinned profile → workspace default → global default. */
export function agentBrowserProfile(agent: Agent, profiles: BrowserProfile[]): BrowserProfile | null {
/** Mirrors the core's resolveProfileForAgent: the chat's profile → pinned profile → workspace default → global default. */
export function agentBrowserProfile(agent: Agent, profiles: BrowserProfile[], chatProfileId: string | null = null): BrowserProfile | null {
const own = chatProfileId ? profiles.find((p) => p.id === chatProfileId) : undefined;
if (own) return own;
const pinned = agent.browser.profileId ? profiles.find((p) => p.id === agent.browser.profileId) : undefined;
if (pinned) return pinned;
const workspaceDefault = agent.workspaceId ? profiles.find((p) => p.workspaceId === agent.workspaceId && p.isDefault) : undefined;
return workspaceDefault ?? profiles.find((p) => !p.workspaceId && p.isDefault) ?? null;
}

/** The browser profile this chat's agent drives (null when the agent has no browser). */
export function useChatBrowser(agent: Agent | undefined): BrowserProfile | null {
/** The browser profile this chat drives (null when its agent has no browser). */
export function useChatBrowser(agent: Agent | undefined, chatProfileId: string | null = null): BrowserProfile | null {
const { data: settings } = useSettings();
const enabled = !!agent?.browser.enabled && settings?.browser.enabled !== false;
const { data: profiles } = useQuery({ queryKey: qk.browserProfiles, queryFn: api.browser.profiles, enabled });
return enabled && agent && profiles ? agentBrowserProfile(agent, profiles) : null;
return enabled && agent && profiles ? agentBrowserProfile(agent, profiles, chatProfileId) : null;
}

function useFrame(profile: BrowserProfile) {
Expand All @@ -49,12 +51,15 @@ function useFrame(profile: BrowserProfile) {
export function BrowserPanel({
profile,
agent,
forChat,
activity,
onHide,
onFocus,
}: {
profile: BrowserProfile;
agent: Agent;
/** The profile was picked for this chat rather than inherited from the agent. */
forChat?: boolean;
/** What the agent is doing right now; null while this chat is idle. */
activity: string | null;
onHide: () => void;
Expand Down Expand Up @@ -197,15 +202,19 @@ export function BrowserPanel({
)}
</div>

<ProfileFooter profile={profile} pinned={agent.browser.profileId === profile.id} />
<ProfileFooter profile={profile} forChat={!!forChat} pinned={agent.browser.profileId === profile.id} />
</div>
</motion.aside>
);
}

function ProfileFooter({ profile, pinned }: { profile: BrowserProfile; pinned: boolean }) {
function ProfileFooter({ profile, forChat, pinned }: { profile: BrowserProfile; forChat: boolean; pinned: boolean }) {
const workspace = useWorkspaceName(profile.workspaceId);
const shared = pinned ? "Pinned in this agent's settings" : profile.workspaceId ? `Shared by every chat in ${workspace}` : "Shared by every chat without a workspace profile";
const shared = forChat
? "Picked for this chat"
: pinned
? "Pinned in this agent's settings"
: profile.workspaceId ? `Shared by every chat in ${workspace}` : "Shared by every chat without a workspace profile";
return (
<Link
to={`/browser?profile=${profile.id}`}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/chat/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ export function Composer({

{/* Context tray: who works on this and with what, kept apart from the input actions so neither row gets crowded. */}
{leading && (
<div className="flex min-w-0 items-center gap-1 rounded-b-[calc(var(--radius-2xl)-1px)] border-t border-border/60 bg-muted/40 px-2 py-1.5">
<div className="flex min-w-0 flex-wrap items-center gap-1 rounded-b-[calc(var(--radius-2xl)-1px)] border-t border-border/60 bg-muted/40 px-2 py-1.5">
{leading}
</div>
)}
Expand Down
49 changes: 39 additions & 10 deletions apps/desktop/src/pages/chat/chat-conversation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import type { Agent, ComputerTarget, ConversationWithMessages, Message, SendMessageInput, Vm } from "@godmode/shared";
import type { Agent, BrowserProfile, ComputerTarget, ConversationWithMessages, Message, SendMessageInput, Vm } from "@godmode/shared";
import { computerTargetLabel } from "@godmode/shared";
import { Archive, ArchiveRestore, ArrowUpRight, Brain, MessageSquareDashed, MessageSquarePlus, Moon, Sparkles, Wand2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { AgentAvatar, EmptyState } from "@/components/common";
import { BrowserFocus, BrowserPanel, BrowserToggle, useChatBrowser, type BrowserFocusMode } from "@/components/chat/browser-panel";
import { BrowserFocus, BrowserPanel, BrowserToggle, agentBrowserProfile, useChatBrowser, type BrowserFocusMode } from "@/components/chat/browser-panel";
import { BrowserProfileChip } from "@/components/browser/profile-chip";
import { ComputerFocus, ComputerPanel, ComputerShareChip, ComputerToggle, type ComputerFocusMode } from "@/components/computer/computer-panel";
import { useStartAgentChat } from "@/components/agents/agent-actions";
import { Composer, type ComposerHandle } from "@/components/chat/composer";
Expand Down Expand Up @@ -56,7 +57,11 @@ function ConversationView({ conversationId }: { conversationId: string }) {
const composerRef = useRef<ComposerHandle>(null);
const [queued, setQueued] = useState<Record<string, string>>({});
const mountedAt = useRef(Date.now());
const browser = useChatBrowser(agent);
// A profile picked mid-run applies from the next message: keep showing the browser the running agent drives.
const [runProfile, setRunProfile] = useState<{ runId: string; profileId: string | null } | null>(null);
if ((live?.runId ?? null) !== (runProfile?.runId ?? null)) setRunProfile(live ? { runId: live.runId, profileId: conv?.browserProfileId ?? null } : null);
const chatProfileId = runProfile ? runProfile.profileId : (conv?.browserProfileId ?? null);
const browser = useChatBrowser(agent, chatProfileId);
const browserPanel = useUi((s) => s.browserPanel);
const setBrowserPanel = useUi((s) => s.setBrowserPanel);
const wide = useMediaQuery("(min-width: 1024px)");
Expand Down Expand Up @@ -220,6 +225,21 @@ function ConversationView({ conversationId }: { conversationId: string }) {
onError: (err) => toast.error("Couldn't change the VM", { description: errorMessage(err) }),
});

const setBrowserProfile = useMutation({
mutationFn: (browserProfileId: string | null) => api.conversations.update(conversationId, { browserProfileId }),
onSuccess: (updated) => {
qc.setQueryData<ConversationWithMessages>(key, (old) => (old ? { ...old, ...updated } : old));
const profiles = qc.getQueryData<BrowserProfile[]>(qk.browserProfiles) ?? [];
const profile = updated.browserProfileId
? profiles.find((p) => p.id === updated.browserProfileId)
: agent && agentBrowserProfile(agent, profiles);
const when = busyRef.current ? "Your next message uses" : "The next messages use";
if (updated.browserProfileId) toast.success(`Browsing in ${profile?.name ?? "the new profile"}`, { description: `${when} its cookies and logins.` });
else toast.success("Back to the default profile", { description: `${agent?.name ?? "The agent"} browses in ${profile?.name ?? "its own profile"} again.` });
},
onError: (err) => toast.error("Couldn't change the browser profile", { description: errorMessage(err) }),
});

const share = useMutation({
mutationFn: (computerTarget: ComputerTarget | null) => api.conversations.update(conversationId, { computerTarget }),
onSuccess: (updated, target) => {
Expand Down Expand Up @@ -375,13 +395,21 @@ function ConversationView({ conversationId }: { conversationId: string }) {
busy={setFolder.isPending}
/>
{!chatVm && (
<ComputerShareChip
target={computerTarget}
agentName={agent?.name}
onShare={(t) => share.mutateAsync(t)}
onWatch={() => setComputerFocus("watch")}
busy={share.isPending}
/>
<>
<BrowserProfileChip
agent={agent}
value={conv.browserProfileId ?? null}
onChange={(id) => setBrowserProfile.mutateAsync(id).catch(() => undefined)}
busy={setBrowserProfile.isPending}
/>
<ComputerShareChip
target={computerTarget}
agentName={agent?.name}
onShare={(t) => share.mutateAsync(t)}
onWatch={() => setComputerFocus("watch")}
busy={share.isPending}
/>
</>
)}
<VmChip
value={conv.vmId ?? null}
Expand Down Expand Up @@ -451,6 +479,7 @@ function ConversationView({ conversationId }: { conversationId: string }) {
key={browser.id}
profile={browser}
agent={agent}
forChat={browser.id === chatProfileId}
activity={activeRunId ? liveActivityLabel(live) : null}
onHide={() => setBrowserPanel(false)}
onFocus={setBrowserFocus}
Expand Down
Loading
Loading