From cc9e8048b63874250ef94ec43d4c09d18eb328cd Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Wed, 30 Sep 2026 00:38:05 +0200 Subject: [PATCH] One default browser profile per workspace; new chats use the workspace's default (GODM-35) --- .../src/components/agents/agent-actions.tsx | 7 ++- .../src/components/agents/agent-form.tsx | 23 +++++++-- .../automations/one-prompt-card.tsx | 5 +- .../components/browser/import-sessions.tsx | 14 +++++- .../src/components/browser/profile-chip.tsx | 5 +- .../src/components/browser/profile-list.tsx | 47 ++++++++++++++----- .../components/browser/use-profile-actions.ts | 16 +++++-- .../src/components/chat/browser-panel.tsx | 25 +++++++--- apps/desktop/src/lib/api.ts | 4 +- apps/desktop/src/lib/hooks.ts | 7 +++ apps/desktop/src/pages/agents/agent-new.tsx | 4 +- .../src/pages/browser/browser-page.tsx | 19 ++++++-- .../src/pages/chat/chat-conversation.tsx | 5 +- apps/desktop/src/pages/chat/chat-home.tsx | 7 ++- docs/ARCHITECTURE.md | 7 +-- packages/core/src/browser/manager.ts | 25 +++++++--- packages/core/src/db/migrations.ts | 8 ++++ packages/core/src/mcp/tools.ts | 12 +++-- packages/core/src/server/routes/agents.ts | 7 ++- packages/core/src/server/routes/chat.ts | 5 +- packages/core/src/services/conversations.ts | 16 ++++++- packages/core/src/services/workspaces.ts | 1 + packages/core/test/mcp.test.ts | 18 ++++--- packages/core/test/scheduler.test.ts | 1 + packages/core/test/workspaces.test.ts | 26 +++++++++- packages/shared/src/api.ts | 2 + packages/shared/src/models.ts | 2 + 27 files changed, 249 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/components/agents/agent-actions.tsx b/apps/desktop/src/components/agents/agent-actions.tsx index 6db582f6..736b685b 100644 --- a/apps/desktop/src/components/agents/agent-actions.tsx +++ b/apps/desktop/src/components/agents/agent-actions.tsx @@ -6,6 +6,7 @@ import { Play } from "lucide-react"; import type { Agent } from "@godmode/shared"; import { api, errorMessage } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; +import { useScopeWorkspace } from "@/lib/hooks"; import { modKey } from "@/lib/desktop"; import { useLive, type LiveRun } from "@/stores/live"; import { AgentAvatar, Kbd } from "@/components/common"; @@ -43,8 +44,9 @@ export function useAgentLiveRun(agentId: string | undefined): LiveRun | null { export function useStartAgentChat() { const navigate = useNavigate(); const qc = useQueryClient(); + const workspace = useScopeWorkspace(); return useMutation({ - mutationFn: (agentId: string) => api.conversations.create({ agentId }), + mutationFn: (agentId: string) => api.conversations.create({ agentId, workspaceId: workspace?.id }), onSuccess: (conversation) => { qc.invalidateQueries({ queryKey: qk.conversationsAll }); navigate(`/chat/${conversation.id}`); @@ -90,9 +92,10 @@ export function RunTaskDialog({ const agent = useLatest(agentProp); const navigate = useNavigate(); const qc = useQueryClient(); + const workspace = useScopeWorkspace(); const [prompt, setPrompt] = useState(""); const run = useMutation({ - mutationFn: () => api.agents.run(agent!.id, prompt.trim()), + mutationFn: () => api.agents.run(agent!.id, prompt.trim(), workspace?.id), onSuccess: (res) => { qc.invalidateQueries({ queryKey: qk.conversationsAll }); qc.invalidateQueries({ queryKey: qk.runs }); diff --git a/apps/desktop/src/components/agents/agent-form.tsx b/apps/desktop/src/components/agents/agent-form.tsx index 2f2795f3..87cf2219 100644 --- a/apps/desktop/src/components/agents/agent-form.tsx +++ b/apps/desktop/src/components/agents/agent-form.tsx @@ -43,6 +43,7 @@ import { MultiSelect } from "./multi-select"; import { ModelOptions } from "./model-options"; import { useVaultGrant } from "@/components/vault/grant"; import { FolderPickerDialog, folderName, useShortPath } from "@/components/chat/folder-picker"; +import { defaultProfileFor } from "@/components/chat/browser-panel"; import { InheritedInstructions, useInheritedInstructions } from "@/components/instructions/instructions"; import { VmSelectField } from "@/components/vms/vm-picker"; @@ -523,7 +524,12 @@ export function AgentForm({ onChange={(v) => set("browserEnabled", v)} />
- set("browserProfileId", v)} disabled={!values.browserEnabled} /> + set("browserProfileId", v)} + disabled={!values.browserEnabled} + />
Window @@ -926,8 +932,19 @@ function ComputerDisplayField({ value, onChange, disabled }: { value: string | n ); } -function BrowserProfileField({ value, onChange, disabled }: { value: string | null; onChange: (v: string | null) => void; disabled?: boolean }) { +function BrowserProfileField({ + value, + workspaceId, + onChange, + disabled, +}: { + value: string | null; + workspaceId: string | null; + onChange: (v: string | null) => void; + disabled?: boolean; +}) { const { data: profiles = [], isLoading } = useQuery({ queryKey: [...qk.browserProfiles, "list"], queryFn: api.browser.profiles }); + const defaultId = defaultProfileFor(profiles, workspaceId)?.id; return (
@@ -941,7 +958,7 @@ function BrowserProfileField({ value, onChange, disabled }: { value: string | nu {profiles.map((p) => ( {p.name} - {p.isDefault && default} + {p.id === defaultId && default} {p.cookieCount > 0 && {p.cookieCount} cookies} ))} diff --git a/apps/desktop/src/components/automations/one-prompt-card.tsx b/apps/desktop/src/components/automations/one-prompt-card.tsx index 5bbfa76b..d6a450e0 100644 --- a/apps/desktop/src/components/automations/one-prompt-card.tsx +++ b/apps/desktop/src/components/automations/one-prompt-card.tsx @@ -7,7 +7,7 @@ import { Sparkles } from "lucide-react"; import type { ConversationWithMessages, RoutineTriggerType } from "@godmode/shared"; import { api, errorMessage } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; -import { useAllAgents, useBootstrap } from "@/lib/hooks"; +import { useAllAgents, useBootstrap, useScopeWorkspace } from "@/lib/hooks"; import { modKey } from "@/lib/desktop"; import { cn } from "@/lib/utils"; import { Backdrop } from "@/components/brand"; @@ -33,6 +33,7 @@ export function OnePromptCard({ compact = false }: { compact?: boolean }) { const qc = useQueryClient(); const { data: boot } = useBootstrap(); const { data: agents = [] } = useAllAgents(); + const workspace = useScopeWorkspace(); const [text, setText] = useState(""); const [focused, setFocused] = useState(false); const textareaRef = useRef(null); @@ -40,7 +41,7 @@ export function OnePromptCard({ compact = false }: { compact?: boolean }) { const expanded = !compact || focused || text.trim().length > 0; const start = useMutation({ - mutationFn: () => api.chat.start({ agentId: godmodeId, content: `Set up an automation: ${text.trim()}` }), + mutationFn: () => api.chat.start({ agentId: godmodeId, content: `Set up an automation: ${text.trim()}`, workspaceId: workspace?.id }), onSuccess: (res) => { qc.setQueryData(qk.conversation(res.conversation.id), { ...res.conversation, diff --git a/apps/desktop/src/components/browser/import-sessions.tsx b/apps/desktop/src/components/browser/import-sessions.tsx index 5433d893..1fa258e9 100644 --- a/apps/desktop/src/components/browser/import-sessions.tsx +++ b/apps/desktop/src/components/browser/import-sessions.tsx @@ -23,7 +23,17 @@ import { cn } from "@/lib/utils"; const SUGGESTED_DOMAINS = ["google.com", "github.com", "linkedin.com", "notion.so", "slack.com"]; /** Import cookies/sessions from local Chrome (or a cookie JSON) into a Godmode browser profile. */ -export function ImportSessionsCard({ profiles, targetId, onTargetChange }: { profiles: BrowserProfile[]; targetId: string | null; onTargetChange: (id: string) => void }) { +export function ImportSessionsCard({ + profiles, + defaultId, + targetId, + onTargetChange, +}: { + profiles: BrowserProfile[]; + defaultId: string | null; + targetId: string | null; + onTargetChange: (id: string) => void; +}) { const target = profiles.find((p) => p.id === targetId) ?? null; return (
@@ -52,7 +62,7 @@ export function ImportSessionsCard({ profiles, targetId, onTargetChange }: { pro {profiles.map((p) => ( {p.name} - {p.isDefault && · default} + {p.id === defaultId && · default} ))} diff --git a/apps/desktop/src/components/browser/profile-chip.tsx b/apps/desktop/src/components/browser/profile-chip.tsx index 37b3ab53..54fc569e 100644 --- a/apps/desktop/src/components/browser/profile-chip.tsx +++ b/apps/desktop/src/components/browser/profile-chip.tsx @@ -32,12 +32,15 @@ function ProfileTile({ profile, quiet }: { profile: BrowserProfile | null; quiet export function BrowserProfileChip({ agent, value, + workspaceId = null, onChange, busy, }: { agent: Agent | undefined; /** The chat's own profile; null = the agent's. */ value: string | null; + /** Workspace the chat is in: a global agent browses with its default. */ + workspaceId?: string | null; onChange: (profileId: string | null) => void | Promise; busy?: boolean; }) { @@ -53,7 +56,7 @@ export function BrowserProfileChip({ 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 fallback = agentBrowserProfile(agent, profiles, null, workspaceId); const fallbackFrom = !fallback ? "Loading profiles…" : agent.browser.profileId === fallback.id diff --git a/apps/desktop/src/components/browser/profile-list.tsx b/apps/desktop/src/components/browser/profile-list.tsx index 32e398be..ec164b9c 100644 --- a/apps/desktop/src/components/browser/profile-list.tsx +++ b/apps/desktop/src/components/browser/profile-list.tsx @@ -4,7 +4,7 @@ import { AnimatePresence, motion } from "motion/react"; import { formatDistanceToNow } from "date-fns"; import { Cookie, Download, Ellipsis, Globe, Layers, Pencil, Play, Plus, Square, Star, Trash2 } from "lucide-react"; import { toast } from "sonner"; -import type { BrowserProfile } from "@godmode/shared"; +import type { BrowserProfile, Workspace } from "@godmode/shared"; import { AlertDialog, AlertDialogAction, @@ -29,6 +29,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { ScopeBadge } from "@/components/common"; import { LiveDot } from "@/components/aicss/Motion"; import { WorkspaceSelect } from "@/components/vault/workspace-select"; @@ -51,12 +52,18 @@ function relative(iso: string | null) { export function ProfileList({ profiles, + defaultId, + workspace, isLoading, selectedId, onSelect, actions, }: { profiles: BrowserProfile[]; + /** The profile new chats in `workspace` browse with. */ + defaultId: string | null; + /** Workspace picked in the sidebar; null = global. */ + workspace: Workspace | null; isLoading: boolean; selectedId: string | null; onSelect: (id: string) => void; @@ -91,6 +98,8 @@ export function ProfileList({ > onSelect(p.id)} actions={actions} @@ -135,6 +144,8 @@ export function ProfileList({ function ProfileCard({ profile: p, + isDefault, + workspace, selected, onSelect, actions, @@ -143,6 +154,8 @@ function ProfileCard({ onDelete, }: { profile: BrowserProfile; + isDefault: boolean; + workspace: Workspace | null; selected: boolean; onSelect: () => void; actions: ProfileActions; @@ -153,6 +166,9 @@ function ProfileCard({ const launching = actions.launch.isPending && actions.launch.variables?.id === p.id; const stopping = actions.stop.isPending && actions.stop.variables?.id === p.id; const imported = relative(p.importedAt); + const globalDefault = p.isDefault && !p.workspaceId; + const canMakeDefault = !isDefault && (!p.workspaceId || p.workspaceId === workspace?.id); + const makeDefaultLabel = !workspace ? "Set as default" : p.workspaceId || globalDefault ? `Make default in ${workspace.name}` : `Move to ${workspace.name} as default`; return (
{p.name} - {p.isDefault && ( - - Default - + {isDefault && ( + + + + Default + + + {workspace ? `New chats in ${workspace.name} browse here` : "New chats browse here unless their workspace has its own default"} + )}
@@ -226,7 +247,7 @@ function ProfileCard({ {launching || stopping ? : } - e.stopPropagation()}> + e.stopPropagation()}> {p.running ? ( actions.stop.mutate(p)} disabled={stopping}> Stop browser @@ -236,20 +257,20 @@ function ProfileCard({ Launch browser )} - {!p.isDefault && ( - actions.setDefault.mutate(p)}> - Set as default + {canMakeDefault && ( + actions.setDefault.mutate({ profile: p, workspace })}> + {makeDefaultLabel} )} Rename - - {p.isDefault && !p.workspaceId ? "Default stays global" : "Assign to workspace…"} + + {globalDefault ? "Global default stays global" : "Assign to workspace…"} - - {p.isDefault ? "Default can't be deleted" : "Delete"} + + {globalDefault ? "Global default can't be deleted" : "Delete"} diff --git a/apps/desktop/src/components/browser/use-profile-actions.ts b/apps/desktop/src/components/browser/use-profile-actions.ts index 9ac4f047..bfa1459d 100644 --- a/apps/desktop/src/components/browser/use-profile-actions.ts +++ b/apps/desktop/src/components/browser/use-profile-actions.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import type { BrowserProfile } from "@godmode/shared"; +import type { BrowserProfile, Workspace } from "@godmode/shared"; import { api } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; import { toastApiError } from "@/components/vault/vault-utils"; @@ -29,10 +29,18 @@ export function useProfileActions() { }); const setDefault = useMutation({ - mutationFn: (p: BrowserProfile) => api.browser.updateProfile(p.id, { isDefault: true }), - onSuccess: (_res, p) => { - toast.success(`${p.name} is now the default profile`); + mutationFn: async ({ profile, workspace }: { profile: BrowserProfile; workspace: Workspace | null }) => { + if (workspace) await api.workspaces.update(workspace.id, { browserProfileId: !profile.workspaceId && profile.isDefault ? null : profile.id }); + else await api.browser.updateProfile(profile.id, { isDefault: true }); + }, + onSuccess: (_res, { profile, workspace }) => { + if (workspace) + toast.success(`${profile.name} is now the default in ${workspace.name}`, { + description: profile.workspaceId ? undefined : profile.isDefault ? `${workspace.name} follows the global default again.` : `Moved from Global into ${workspace.name}.`, + }); + else toast.success(`${profile.name} is now the default profile`); void invalidate(); + void qc.invalidateQueries({ queryKey: qk.workspaces }); }, onError: (e) => toastApiError(e, "Could not change the default profile", qc), }); diff --git a/apps/desktop/src/components/chat/browser-panel.tsx b/apps/desktop/src/components/chat/browser-panel.tsx index 29864121..0a3d27b4 100644 --- a/apps/desktop/src/components/chat/browser-panel.tsx +++ b/apps/desktop/src/components/chat/browser-panel.tsx @@ -24,22 +24,35 @@ import { cn } from "@/lib/utils"; export type BrowserFocusMode = "watch" | "control"; -/** 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 { +/** The profile new chats in a workspace browse with: its default, else the global default. */ +export function defaultProfileFor(profiles: BrowserProfile[], workspaceId: string | null | undefined): BrowserProfile | null { + const workspaceDefault = workspaceId ? profiles.find((p) => p.workspaceId === workspaceId && p.isDefault) : undefined; + return workspaceDefault ?? profiles.find((p) => !p.workspaceId && p.isDefault) ?? null; +} + +/** + * Mirrors the core's resolveProfileForAgent: the chat's profile → pinned profile → default of the agent's (or the + * chat's) workspace → global default. + */ +export function agentBrowserProfile( + agent: Agent, + profiles: BrowserProfile[], + chatProfileId: string | null = null, + chatWorkspaceId: 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; + return defaultProfileFor(profiles, agent.workspaceId ?? chatWorkspaceId); } /** 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 { +export function useChatBrowser(agent: Agent | undefined, chatProfileId: string | null = null, chatWorkspaceId: 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, chatProfileId) : null; + return enabled && agent && profiles ? agentBrowserProfile(agent, profiles, chatProfileId, chatWorkspaceId) : null; } function useFrame(profile: BrowserProfile) { diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 46104dbe..abc18da3 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -285,7 +285,7 @@ export const api = { delete: (id: string) => del<{ ok: true }>(`/api/agents/${id}`), templates: () => get("/api/agent-templates"), /** Start a fresh task conversation for the agent */ - run: (id: string, prompt?: string) => post(`/api/agents/${id}/run`, { prompt }), + run: (id: string, prompt?: string, workspaceId?: string) => post(`/api/agents/${id}/run`, { prompt, workspaceId }), files: (id: string, path = "") => get(`/api/agents/${id}/files`, { path }), readFile: (id: string, path: string) => get<{ path: string; content: string }>(`/api/agents/${id}/file`, { path }), writeFile: (id: string, path: string, content: string) => put<{ ok: true }>(`/api/agents/${id}/file`, { path, content }), @@ -327,7 +327,7 @@ export const api = { list: (q: { agentId?: string; search?: string; limit?: number; archived?: boolean } = {}) => get("/api/conversations", q), get: (id: string) => get(`/api/conversations/${id}`), - create: (input: { agentId: string; title?: string }) => post("/api/conversations", input), + create: (input: { agentId: string; title?: string; workspaceId?: string | null }) => post("/api/conversations", input), update: (id: string, input: ConversationPatch) => patch(`/api/conversations/${id}`, input), delete: (id: string) => del<{ ok: true }>(`/api/conversations/${id}`), send: (id: string, input: SendMessageInput) => post(`/api/conversations/${id}/messages`, input), diff --git a/apps/desktop/src/lib/hooks.ts b/apps/desktop/src/lib/hooks.ts index 3ad8ecda..a51bf44f 100644 --- a/apps/desktop/src/lib/hooks.ts +++ b/apps/desktop/src/lib/hooks.ts @@ -18,6 +18,13 @@ export function useWorkspaces() { return useQuery({ queryKey: qk.workspaces, queryFn: api.workspaces.list }); } +/** The workspace picked in the sidebar; null for "All workspaces" and "Global". */ +export function useScopeWorkspace() { + const scope = useUi((s) => s.workspace); + const { data } = useWorkspaces(); + return data?.find((w) => w.id === scope) ?? null; +} + export function useAgents(workspaceId?: ScopeFilter) { const scope = useUi((s) => s.workspace); const ws = workspaceId ?? scope; diff --git a/apps/desktop/src/pages/agents/agent-new.tsx b/apps/desktop/src/pages/agents/agent-new.tsx index f54ddccb..c0f40843 100644 --- a/apps/desktop/src/pages/agents/agent-new.tsx +++ b/apps/desktop/src/pages/agents/agent-new.tsx @@ -8,7 +8,7 @@ import type { Agent, AgentInput, AgentTemplate } from "@godmode/shared"; import { api, errorMessage } from "@/lib/api"; import { isGrantCancelled, withGrant } from "@/components/vault/grant"; import { qk } from "@/lib/queryKeys"; -import { useAgentTemplates, useBootstrap } from "@/lib/hooks"; +import { useAgentTemplates, useBootstrap, useScopeWorkspace } from "@/lib/hooks"; import { modKey } from "@/lib/desktop"; import { cn } from "@/lib/utils"; import { AgentAvatar, EmptyState, Kbd, PageBody, PageHeader } from "@/components/common"; @@ -46,6 +46,7 @@ function ChooseStep({ onPick }: { onPick: (templateId: string) => void }) { const navigate = useNavigate(); const qc = useQueryClient(); const { data: boot } = useBootstrap(); + const workspace = useScopeWorkspace(); const templates = useAgentTemplates(); const [description, setDescription] = useState(""); const [focused, setFocused] = useState(false); @@ -54,6 +55,7 @@ function ChooseStep({ onPick }: { onPick: (templateId: string) => void }) { mutationFn: () => api.chat.start({ agentId: boot?.defaultAgentId ?? undefined, + workspaceId: workspace?.id, content: `Create a new agent for me: ${description.trim()}. Configure sensible instructions and an automation if it should work on its own (on a schedule or when something happens).`, }), onSuccess: (res) => { diff --git a/apps/desktop/src/pages/browser/browser-page.tsx b/apps/desktop/src/pages/browser/browser-page.tsx index 70cb1ca3..52678a64 100644 --- a/apps/desktop/src/pages/browser/browser-page.tsx +++ b/apps/desktop/src/pages/browser/browser-page.tsx @@ -9,8 +9,10 @@ import { LiveView } from "@/components/browser/live-view"; import { ImportSessionsCard } from "@/components/browser/import-sessions"; import { ProfileUseCard } from "@/components/browser/profile-use-card"; import { useProfileActions } from "@/components/browser/use-profile-actions"; +import { defaultProfileFor } from "@/components/chat/browser-panel"; import { isVaultLocked } from "@/components/vault/vault-utils"; import { api, errorMessage } from "@/lib/api"; +import { useScopeWorkspace } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { Skeleton } from "@/components/ui/skeleton"; @@ -21,9 +23,12 @@ export default function BrowserPage() { const profilesQuery = useQuery({ queryKey: qk.browserProfiles, queryFn: api.browser.profiles }); const profiles = profilesQuery.data ?? []; + const workspace = useScopeWorkspace(); + const defaultProfile = defaultProfileFor(profiles, workspace?.id); + const ordered = defaultProfile ? [defaultProfile, ...profiles.filter((p) => p.id !== defaultProfile.id)] : profiles; const requested = params.get("profile"); - const selected = profiles.find((p) => p.id === requested) ?? profiles.find((p) => p.isDefault) ?? profiles[0] ?? null; + const selected = profiles.find((p) => p.id === requested) ?? defaultProfile ?? profiles[0] ?? null; const select = (id: string) => setParams( @@ -90,7 +95,15 @@ export default function BrowserPage() {
{selected ? ( @@ -99,7 +112,7 @@ export default function BrowserPage() { )}
- +
diff --git a/apps/desktop/src/pages/chat/chat-conversation.tsx b/apps/desktop/src/pages/chat/chat-conversation.tsx index 59c637d1..481e1aed 100644 --- a/apps/desktop/src/pages/chat/chat-conversation.tsx +++ b/apps/desktop/src/pages/chat/chat-conversation.tsx @@ -61,7 +61,7 @@ function ConversationView({ conversationId }: { conversationId: string }) { 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 browser = useChatBrowser(agent, chatProfileId, conv?.workspaceId ?? null); const browserPanel = useUi((s) => s.browserPanel); const setBrowserPanel = useUi((s) => s.setBrowserPanel); const wide = useMediaQuery("(min-width: 1024px)"); @@ -232,7 +232,7 @@ function ConversationView({ conversationId }: { conversationId: string }) { const profiles = qc.getQueryData(qk.browserProfiles) ?? []; const profile = updated.browserProfileId ? profiles.find((p) => p.id === updated.browserProfileId) - : agent && agentBrowserProfile(agent, profiles); + : agent && agentBrowserProfile(agent, profiles, null, updated.workspaceId); 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.` }); @@ -399,6 +399,7 @@ function ConversationView({ conversationId }: { conversationId: string }) { setBrowserProfile.mutateAsync(id).catch(() => undefined)} busy={setBrowserProfile.isPending} /> diff --git a/apps/desktop/src/pages/chat/chat-home.tsx b/apps/desktop/src/pages/chat/chat-home.tsx index 01097f99..30cf0156 100644 --- a/apps/desktop/src/pages/chat/chat-home.tsx +++ b/apps/desktop/src/pages/chat/chat-home.tsx @@ -25,7 +25,7 @@ import { VoiceMode } from "@/components/chat/voice-mode"; import { formatElapsed } from "@/components/runs/run-status"; import { api, errorMessage } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; -import { useAllAgents, useBootstrap, useConversations, useWorkspaces } from "@/lib/hooks"; +import { useAllAgents, useBootstrap, useConversations, useScopeWorkspace, useWorkspaces } from "@/lib/hooks"; import { modKey } from "@/lib/desktop"; import { useVoiceSession } from "@/lib/voice"; import { useLive, type LiveRun } from "@/stores/live"; @@ -86,6 +86,7 @@ export default function ChatHome() { /** Browser profile for the new chat; null = the agent's. */ const [browserProfileId, setBrowserProfileId] = useState(null); const { data: workspaces = [] } = useWorkspaces(); + const scopeWorkspaceId = useScopeWorkspace()?.id ?? null; const available = useMemo(() => agents.filter((a) => a.enabled), [agents]); const selected = @@ -171,7 +172,7 @@ export default function ChatHome() { agentName={selected?.name} onChange={setFolder} /> - + --user-data-dir=~/.godmode/browser/` on 127.0.0.1. * A run on this computer uses its chat's profile (`conversations.browser_profile_id`, picked in the composer), else its agent's pinned profile, else its workspace's default profile (`Workspace.browserProfileId`), else the global default - (a run in a VM browses in the VM instead). Delegated work - for an agent without a pinned profile keeps the profile picked for the caller's chat when it's global or in the target's - workspace; deleting a profile sends its chats back to their default. Profiles can be reassigned to another workspace + (a run in a VM browses in the VM instead). A global agent's chat remembers the workspace selected in the sidebar when + it started (`conversations.workspace_id`) and uses that workspace's default. Delegated work stays in the caller's + workspace, and for an agent without a pinned profile keeps the profile picked for the caller's chat when it's global or + in the target's workspace; deleting a profile sends its chats back to their default. Profiles can be reassigned to another workspace (`PATCH /api/browser/profiles/:id { workspaceId }`) or picked in the workspace's settings (`browserProfileId`, which moves a global profile into the workspace); cookies and sessions travel with the profile. The global default always stays global. diff --git a/packages/core/src/browser/manager.ts b/packages/core/src/browser/manager.ts index ea36b16c..112d3dc8 100644 --- a/packages/core/src/browser/manager.ts +++ b/packages/core/src/browser/manager.ts @@ -217,21 +217,34 @@ export async function deleteProfile(id: string): Promise { if (r.workspace_id && r.is_default) bus.changed("workspaces"); } +function chatRow(conversationId: string) { + return get<{ browser_profile_id: string | null; workspace_id: string | null }>( + "SELECT browser_profile_id, workspace_id FROM conversations WHERE id = ?", + conversationId, + ); +} + /** Profile picked for the chat itself (not inherited from its agent). */ export function chatProfileId(conversationId: string): string | null { - return get<{ browser_profile_id: string | null }>("SELECT browser_profile_id FROM conversations WHERE id = ?", conversationId)?.browser_profile_id ?? null; + return chatRow(conversationId)?.browser_profile_id ?? null; +} + +/** Workspace a chat was started in (set for global agents only). */ +export function chatWorkspaceId(conversationId: string | null | undefined): string | null { + return conversationId ? (chatRow(conversationId)?.workspace_id ?? null) : null; } -/** Profile a run uses: its chat's ?? agent.browser.profileId ?? workspace default ?? global default. */ +/** Profile a run uses: its chat's ?? agent.browser.profileId ?? default of the agent's (or the chat's) workspace ?? global default. */ export function resolveProfileForAgent(agent: Agent, conversationId?: string | null): BrowserProfile { - const chosen = conversationId ? chatProfileId(conversationId) : null; - const forChat = chosen ? row(chosen) : null; + const chat = conversationId ? chatRow(conversationId) : null; + const forChat = chat?.browser_profile_id ? row(chat.browser_profile_id) : null; if (forChat) return toProfile(forChat); const pinned = agent.browser?.profileId ? row(agent.browser.profileId) : null; if (pinned) return toProfile(pinned); if (agent.browser?.profileId) log.warn(`agent ${agent.id} references missing browser profile ${agent.browser.profileId}; using default`); - if (agent.workspaceId) { - const wsDefault = get("SELECT * FROM browser_profiles WHERE workspace_id = ? AND is_default = 1 ORDER BY created_at LIMIT 1", agent.workspaceId); + const workspaceId = agent.workspaceId ?? chat?.workspace_id ?? null; + if (workspaceId) { + const wsDefault = get("SELECT * FROM browser_profiles WHERE workspace_id = ? AND is_default = 1 ORDER BY created_at LIMIT 1", workspaceId); if (wsDefault) return toProfile(wsDefault); } return ensureDefaultProfile(); diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts index 8cbfbf2a..c21a8a69 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -466,6 +466,14 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_messaging_chats_external ON messaging_chat sql: /* sql */ ` -- Browser profile a chat works in, overriding its agent's (and the workspace / global default). ALTER TABLE conversations ADD COLUMN browser_profile_id TEXT; +`, + }, + { + id: 12, + name: "chat_workspace", + sql: /* sql */ ` +-- Workspace a chat with a global agent was started in: it browses with that workspace's default profile. +ALTER TABLE conversations ADD COLUMN workspace_id TEXT; `, }, ]; diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index 54494af7..50c40d7f 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -24,7 +24,7 @@ import { createAgent, deleteAgent, getAgent, listAgents, peersFor, updateAgent } import { addCredentialDomain, credentialsForAgent, findCredentialsForAgent, getCredential, listCredentials, markCredentialUsed, revealForAgent } from "../vault/credentials"; import { codeForAgent, listTotp, totpForAgent } from "../vault/totp"; import { nameGuessMatchesHost } from "../vault/match"; -import { currentPage, fillIntoPage, resolveProfileForAgent } from "../browser/manager"; +import { chatWorkspaceId, currentPage, fillIntoPage, resolveProfileForAgent } from "../browser/manager"; import { currentVmPage, fillIntoVm } from "../vm/guest"; import { getMcpServer, mcpServerInAgentScope } from "../integrations/mcpServers"; import { loginFillScope } from "../browser/fill"; @@ -636,11 +636,13 @@ const TOOLS: ToolDef[] = [ if (refusal) return fail(refusal); // From a VM, work for an agent without its own VM stays in the caller's VM. const vmId = lockedVm(ctx) && !resolveVmId(null, target) ? lockedVm(ctx) : null; - // Work for an agent without its own profile stays in the browser profile picked for the caller's chat, within the - // target's reach (global or its workspace's). + // Work stays in the caller's workspace, and for an agent without its own profile in the browser profile picked for + // the caller's chat, within the target's reach (global or its workspace's). + const workspaceId = agent.workspaceId ?? chatWorkspaceId(ctx.conversationId); const inherited = target.browser.profileId ? null : runChatBrowserProfile(ctx.runId); - const browserProfileId = inherited && (!inherited.workspaceId || inherited.workspaceId === target.workspaceId) ? inherited.id : null; - const conversation = createConversation({ agentId: target.id, title: `Task from ${agent.name}`, origin: "delegation", vmId, browserProfileId }); + const reach = target.workspaceId ?? workspaceId; + const browserProfileId = inherited && (!inherited.workspaceId || inherited.workspaceId === reach) ? inherited.id : null; + const conversation = createConversation({ agentId: target.id, title: `Task from ${agent.name}`, origin: "delegation", vmId, browserProfileId, workspaceId }); const { run } = await sendMessage(conversation.id, { content: `[Delegated by ${agent.name}]\n\n${task}`, trigger: "delegation", diff --git a/packages/core/src/server/routes/agents.ts b/packages/core/src/server/routes/agents.ts index 42dab798..e806fad3 100644 --- a/packages/core/src/server/routes/agents.ts +++ b/packages/core/src/server/routes/agents.ts @@ -160,9 +160,12 @@ export function registerAgentRoutes(app: Hono): void { app.post("/api/agents/:id/run", async (c) => { const agent = getAgent(c.req.param("id")); - const { prompt } = await body(c, z.object({ prompt: z.string().trim().max(100_000).optional() })); + const { prompt, workspaceId } = await body( + c, + z.object({ prompt: z.string().trim().max(100_000).optional(), workspaceId: z.string().trim().max(100).nullable().optional() }), + ); if (!agent.enabled) throw conflict(`Agent "${agent.name}" is disabled`); - return c.json(await startChat({ agentId: agent.id, content: prompt || DEFAULT_TASK_PROMPT, origin: "api" })); + return c.json(await startChat({ agentId: agent.id, content: prompt || DEFAULT_TASK_PROMPT, origin: "api", workspaceId })); }); app.get("/api/agents/:id/commands", async (c) => c.json(await listSlashCommands(getAgent(c.req.param("id"))))); diff --git a/packages/core/src/server/routes/chat.ts b/packages/core/src/server/routes/chat.ts index 1a0369e1..18a111d5 100644 --- a/packages/core/src/server/routes/chat.ts +++ b/packages/core/src/server/routes/chat.ts @@ -40,6 +40,8 @@ const instructions = z.string().max(MAX_INSTRUCTIONS_LENGTH).optional(); const vmId = z.string().trim().max(100).nullable().optional(); /** Browser profile of the chat; null = the agent's (or the default). */ const browserProfileId = z.string().trim().max(100).nullable().optional(); +/** Workspace the chat is started in; a global agent browses with its default profile. */ +const workspaceId = z.string().trim().max(100).nullable().optional(); const sendSchema = z.object({ content: z.string().max(200_000).default(""), @@ -71,7 +73,7 @@ export function registerChatRoutes(app: Hono): void { ); app.post("/api/conversations", async (c) => { - const input = await body(c, z.object({ agentId: z.string().min(1), title: z.string().max(200).optional(), workingDirectory: folder, vmId, browserProfileId, instructions, ...modelChoice })); + const input = await body(c, z.object({ agentId: z.string().min(1), title: z.string().max(200).optional(), workingDirectory: folder, vmId, browserProfileId, workspaceId, instructions, ...modelChoice })); return c.json(createConversation({ ...input, origin: "chat" }), 201); }); @@ -118,6 +120,7 @@ export function registerChatRoutes(app: Hono): void { computerTarget: computerTargetSchema.nullable().optional(), vmId, browserProfileId, + workspaceId, instructions, ...modelChoice, }), diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index b37e1345..c76ab84c 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -52,6 +52,7 @@ interface ConversationRow { computer_target: string | null; vm_id: string | null; browser_profile_id: string | null; + workspace_id: string | null; instructions: string; pinned: number; archived: number; @@ -106,6 +107,7 @@ function toConversation(r: ConversationRow): Conversation { computerTarget: parseComputerTarget(parseJson(r.computer_target, null)), vmId: r.vm_id ?? null, browserProfileId: r.browser_profile_id ?? null, + workspaceId: r.workspace_id ?? null, instructions: r.instructions, pinned: bool(r.pinned), archived: bool(r.archived), @@ -178,6 +180,13 @@ function normalizeBrowserProfileId(value: string | null | undefined): string | n return id; } +/** A global agent's chat keeps the workspace it was started in; a workspace agent's chat is in the agent's workspace. */ +function normalizeWorkspaceId(agent: Agent, value: string | null | undefined): string | null { + const id = value?.trim(); + if (!id || agent.workspaceId) return null; + return get<{ id: string }>("SELECT id FROM workspaces WHERE id = ?", id)?.id ?? null; +} + export interface ModelChoice { /** `claude --model` value; null/empty = the agent's model. */ model?: string | null; @@ -192,10 +201,11 @@ export function createConversation( workingDirectory?: string | null; vmId?: string | null; browserProfileId?: string | null; + workspaceId?: string | null; instructions?: string; } & ModelChoice, ): Conversation { - getAgent(input.agentId); // 404 if the agent doesn't exist + const agent = getAgent(input.agentId); // 404 if the agent doesn't exist const workingDirectory = normalizeWorkingDirectory(input.workingDirectory); const vmId = normalizeVmId(input.vmId) ?? null; const browserProfileId = normalizeBrowserProfileId(input.browserProfileId) ?? null; @@ -213,6 +223,7 @@ export function createConversation( working_directory: workingDirectory, vm_id: vmId, browser_profile_id: browserProfileId, + workspace_id: normalizeWorkspaceId(agent, input.workspaceId), instructions: input.instructions?.trim() ?? "", pinned: 0, archived: 0, @@ -508,6 +519,8 @@ export async function startChat( vmId?: string | null; /** Browser profile for this chat (null/omitted = the agent's). */ browserProfileId?: string | null; + /** Workspace the chat is started in (the sidebar's); a global agent browses with its default profile. */ + workspaceId?: string | null; instructions?: string; } & ModelChoice, ): Promise { @@ -525,6 +538,7 @@ export async function startChat( workingDirectory: input.workingDirectory, vmId: input.vmId, browserProfileId: input.browserProfileId, + workspaceId: input.workspaceId, instructions: input.instructions, model: input.model, effort: input.effort, diff --git a/packages/core/src/services/workspaces.ts b/packages/core/src/services/workspaces.ts index 54fdf19c..688ad19a 100644 --- a/packages/core/src/services/workspaces.ts +++ b/packages/core/src/services/workspaces.ts @@ -216,6 +216,7 @@ export async function deleteWorkspace(id: string, force = false): Promise tx(() => { // The default agent is always global; never let a cascade take it down. run("UPDATE agents SET workspace_id = NULL WHERE workspace_id = ? AND is_default = 1", id); + run("UPDATE conversations SET workspace_id = NULL WHERE workspace_id = ?", id); // Global logins/2FA entries must not keep links to items the cascade is about to delete. run("UPDATE totp SET credential_id = NULL WHERE credential_id IN (SELECT id FROM credentials WHERE workspace_id = ?)", id); run("UPDATE credentials SET totp_id = NULL WHERE totp_id IN (SELECT id FROM totp WHERE workspace_id = ?)", id); diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 34394990..a3d32064 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -392,23 +392,27 @@ describe("agents + delegation", () => { expect(other.isError).toBeUndefined(); }); - test("agent_delegate keeps the browser profile picked for the caller's chat, within the target's reach", async () => { + test("agent_delegate keeps the caller's workspace and its chat's browser profile, within the target's reach", async () => { const browsing = await makeAgent({ name: "Browsing delegator", browser: { enabled: true }, permissions: { allowDelegation: true } }); const global = createProfile({ name: "Delegation profile", workspaceId: null }); const elsewhere = createProfile({ name: "Other workspace profile", workspaceId: createWorkspace({ name: "Delegation" }).id }); - const delegate = async (profileId: string) => { - const parent = await startChat({ agentId: browsing.id, content: "SLEEP", browserProfileId: profileId }); + const delegate = async (profileId: string | null, workspaceId: string | null = null) => { + const parent = await startChat({ agentId: browsing.id, content: "SLEEP", browserProfileId: profileId, workspaceId }); const token = issueRunToken({ runId: parent.run.id, agentId: browsing.id, conversationId: parent.conversation.id, workspaceId: null, depth: 0 }); const r = await call(token, "agent_delegate", { agentId: worker.id, task: "Profile check", wait: false }); const runId = /run (run_[A-Za-z0-9]+)/.exec(r.content[0]!.text)![1]!; - const inherited = getConversation(getRun(runId).conversationId).browserProfileId; + const child = getConversation(getRun(runId).conversationId); revokeRunToken(token); await cancelRun(parent.run.id); await waitForRun(runId, 20_000); - return inherited; + return { profileId: child.browserProfileId, workspaceId: child.workspaceId }; }; - expect(await delegate(global.id)).toBe(global.id); - expect(await delegate(elsewhere.id)).toBeNull(); + expect(await delegate(global.id)).toEqual({ profileId: global.id, workspaceId: null }); + expect(await delegate(elsewhere.id)).toEqual({ profileId: null, workspaceId: null }); + const home = createWorkspace({ name: "Delegation home" }); + const homeProfile = createProfile({ name: "Home profile", workspaceId: home.id }); + expect(await delegate(null, home.id)).toEqual({ profileId: null, workspaceId: home.id }); + expect(await delegate(homeProfile.id, home.id)).toEqual({ profileId: homeProfile.id, workspaceId: home.id }); }); test("delegation limits: depth, self, non-peers", async () => { diff --git a/packages/core/test/scheduler.test.ts b/packages/core/test/scheduler.test.ts index cf9b2af2..4fea9178 100644 --- a/packages/core/test/scheduler.test.ts +++ b/packages/core/test/scheduler.test.ts @@ -145,6 +145,7 @@ describe("triggering", () => { computerTarget: null, vmId: null, browserProfileId: null, + workspaceId: null, instructions: "", pinned: false, archived: false, diff --git a/packages/core/test/workspaces.test.ts b/packages/core/test/workspaces.test.ts index 98bd1bce..24140b6d 100644 --- a/packages/core/test/workspaces.test.ts +++ b/packages/core/test/workspaces.test.ts @@ -10,7 +10,8 @@ import { setLogLevel } from "../src/log"; import { resetSettingsCache } from "../src/services/settings"; import { createWorkspace, deleteWorkspace, getWorkspace, listWorkspaces, updateWorkspace } from "../src/services/workspaces"; import { createAgent, ensureDefaultAgent, getAgent } from "../src/agents/service"; -import { createProfile, ensureDefaultProfile, getProfile, shutdownBrowsers } from "../src/browser/manager"; +import { chatWorkspaceId, createProfile, ensureDefaultProfile, getProfile, resolveProfileForAgent, shutdownBrowsers } from "../src/browser/manager"; +import { createConversation } from "../src/services/conversations"; import * as repo from "../src/agents/repo"; import { HttpError } from "../src/util"; @@ -101,6 +102,29 @@ describe("workspaces", () => { expect(getWorkspace(ws.id).name).toBe("Browsing"); }); + test("a global agent's chat browses with the default profile of the workspace it was started in", async () => { + const globalDefault = ensureDefaultProfile(); + const godmode = await ensureDefaultAgent(); + const ws = createWorkspace({ name: "Chat Scope" }); + const wsProfile = createProfile({ name: "Chat Scope browser", workspaceId: ws.id }); + const member = await createAgent({ name: "Scoped member", workspaceId: ws.id }); + + const chat = createConversation({ agentId: godmode.id, workspaceId: ws.id }); + expect(chat.workspaceId).toBe(ws.id); + expect(resolveProfileForAgent(godmode, chat.id).id).toBe(wsProfile.id); + expect(resolveProfileForAgent(godmode).id).toBe(globalDefault.id); + + const picked = createConversation({ agentId: godmode.id, workspaceId: ws.id, browserProfileId: globalDefault.id }); + expect(resolveProfileForAgent(godmode, picked.id).id).toBe(globalDefault.id); + + expect(createConversation({ agentId: member.id, workspaceId: ws.id }).workspaceId).toBeNull(); + expect(createConversation({ agentId: godmode.id, workspaceId: "wsp_missing" }).workspaceId).toBeNull(); + + await deleteWorkspace(ws.id, true); + expect(chatWorkspaceId(chat.id)).toBeNull(); + expect(resolveProfileForAgent(godmode, chat.id).id).toBe(globalDefault.id); + }); + test("renaming a workspace refreshes its agents' CLAUDE.md", async () => { const ws = createWorkspace({ name: "Old Name" }); const agent = await createAgent({ name: "Member", workspaceId: ws.id }); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index a23848b7..b26cd929 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -132,6 +132,8 @@ export interface StartChatInput { vmId?: ID | null; /** Browse in this profile instead of the agent's. */ browserProfileId?: ID | null; + /** Workspace selected in the sidebar: a global agent browses with its default profile. */ + workspaceId?: ID | null; instructions?: string; } diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 37bcbdd4..3c1c7341 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -298,6 +298,8 @@ export interface Conversation { vmId: ID | null; /** Browser profile this chat works in, overriding the agent's and the workspace / global default. null = theirs. */ browserProfileId: ID | null; + /** Workspace a global agent's chat was started in; it browses with that workspace's default profile. */ + workspaceId: ID | null; /** Standing instructions for this chat only; they take precedence over the agent's, workspace and global ones. */ instructions: string; pinned: boolean;