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
7 changes: 5 additions & 2 deletions apps/desktop/src/components/agents/agent-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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 });
Expand Down
23 changes: 20 additions & 3 deletions apps/desktop/src/components/agents/agent-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -523,7 +524,12 @@ export function AgentForm({
onChange={(v) => set("browserEnabled", v)}
/>
<div className={cn("grid grid-cols-1 gap-4 @xl:grid-cols-2", !values.browserEnabled && "pointer-events-none opacity-50")}>
<BrowserProfileField value={values.browserProfileId} onChange={(v) => set("browserProfileId", v)} disabled={!values.browserEnabled} />
<BrowserProfileField
value={values.browserProfileId}
workspaceId={values.workspaceId}
onChange={(v) => set("browserProfileId", v)}
disabled={!values.browserEnabled}
/>
<div className="space-y-1.5">
<span id="agent-headless-label" className="text-sm font-medium">
Window
Expand Down Expand Up @@ -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 (
<div className="space-y-1.5">
<Label htmlFor="agent-browser-profile">Profile</Label>
Expand All @@ -941,7 +958,7 @@ function BrowserProfileField({ value, onChange, disabled }: { value: string | nu
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
{p.isDefault && <span className="text-xs text-muted-foreground">default</span>}
{p.id === defaultId && <span className="text-xs text-muted-foreground">default</span>}
{p.cookieCount > 0 && <span className="text-xs text-muted-foreground">{p.cookieCount} cookies</span>}
</SelectItem>
))}
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/components/automations/one-prompt-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,14 +34,15 @@ 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<HTMLTextAreaElement>(null);
const godmodeId = boot?.defaultAgentId ?? agents.find((a) => a.isDefault)?.id;
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<ConversationWithMessages>(qk.conversation(res.conversation.id), {
...res.conversation,
Expand Down
14 changes: 12 additions & 2 deletions apps/desktop/src/components/browser/import-sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section className="@container rounded-xl border bg-card p-5 shadow-card">
Expand Down Expand Up @@ -52,7 +62,7 @@ export function ImportSessionsCard({ profiles, targetId, onTargetChange }: { pro
{profiles.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
{p.isDefault && <span className="text-muted-foreground"> · default</span>}
{p.id === defaultId && <span className="text-muted-foreground"> · default</span>}
</SelectItem>
))}
</SelectContent>
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/components/browser/profile-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
busy?: boolean;
}) {
Expand All @@ -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
Expand Down
47 changes: 34 additions & 13 deletions apps/desktop/src/components/browser/profile-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -91,6 +98,8 @@ export function ProfileList({
>
<ProfileCard
profile={p}
isDefault={p.id === defaultId}
workspace={workspace}
selected={p.id === selectedId}
onSelect={() => onSelect(p.id)}
actions={actions}
Expand Down Expand Up @@ -135,6 +144,8 @@ export function ProfileList({

function ProfileCard({
profile: p,
isDefault,
workspace,
selected,
onSelect,
actions,
Expand All @@ -143,6 +154,8 @@ function ProfileCard({
onDelete,
}: {
profile: BrowserProfile;
isDefault: boolean;
workspace: Workspace | null;
selected: boolean;
onSelect: () => void;
actions: ProfileActions;
Expand All @@ -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 (
<div
Expand Down Expand Up @@ -190,10 +206,15 @@ function ProfileCard({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium tracking-[-0.01em]">{p.name}</span>
{p.isDefault && (
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px]">
<Star className="size-2.5 fill-current" /> Default
</Badge>
{isDefault && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="h-5 gap-1 px-1.5 text-[10px]">
<Star className="size-2.5 fill-current" /> Default
</Badge>
</TooltipTrigger>
<TooltipContent>{workspace ? `New chats in ${workspace.name} browse here` : "New chats browse here unless their workspace has its own default"}</TooltipContent>
</Tooltip>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
Expand Down Expand Up @@ -226,7 +247,7 @@ function ProfileCard({
{launching || stopping ? <Spinner /> : <Ellipsis />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52" onClick={(e) => e.stopPropagation()}>
<DropdownMenuContent align="end" className="w-64" onClick={(e) => e.stopPropagation()}>
{p.running ? (
<DropdownMenuItem onClick={() => actions.stop.mutate(p)} disabled={stopping}>
<Square /> Stop browser
Expand All @@ -236,20 +257,20 @@ function ProfileCard({
<Play /> Launch browser
</DropdownMenuItem>
)}
{!p.isDefault && (
<DropdownMenuItem onClick={() => actions.setDefault.mutate(p)}>
<Star /> Set as default
{canMakeDefault && (
<DropdownMenuItem onClick={() => actions.setDefault.mutate({ profile: p, workspace })}>
<Star /> {makeDefaultLabel}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onRename}>
<Pencil /> Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={onAssign} disabled={p.isDefault && !p.workspaceId}>
<Layers /> {p.isDefault && !p.workspaceId ? "Default stays global" : "Assign to workspace…"}
<DropdownMenuItem onClick={onAssign} disabled={globalDefault}>
<Layers /> {globalDefault ? "Global default stays global" : "Assign to workspace…"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={p.isDefault} onClick={onDelete}>
<Trash2 /> {p.isDefault ? "Default can't be deleted" : "Delete"}
<DropdownMenuItem variant="destructive" disabled={globalDefault} onClick={onDelete}>
<Trash2 /> {globalDefault ? "Global default can't be deleted" : "Delete"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Expand Down
16 changes: 12 additions & 4 deletions apps/desktop/src/components/browser/use-profile-actions.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
});
Expand Down
25 changes: 19 additions & 6 deletions apps/desktop/src/components/chat/browser-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/** The chat's own tab in its agent's browser (undefined until the agent opens one). */
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ export const api = {
delete: (id: string) => del<{ ok: true }>(`/api/agents/${id}`),
templates: () => get<AgentTemplate[]>("/api/agent-templates"),
/** Start a fresh task conversation for the agent */
run: (id: string, prompt?: string) => post<StartChatResult>(`/api/agents/${id}/run`, { prompt }),
run: (id: string, prompt?: string, workspaceId?: string) => post<StartChatResult>(`/api/agents/${id}/run`, { prompt, workspaceId }),
files: (id: string, path = "") => get<AgentFileEntry[]>(`/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 }),
Expand Down Expand Up @@ -327,7 +327,7 @@ export const api = {
list: (q: { agentId?: string; search?: string; limit?: number; archived?: boolean } = {}) =>
get<Conversation[]>("/api/conversations", q),
get: (id: string) => get<ConversationWithMessages>(`/api/conversations/${id}`),
create: (input: { agentId: string; title?: string }) => post<Conversation>("/api/conversations", input),
create: (input: { agentId: string; title?: string; workspaceId?: string | null }) => post<Conversation>("/api/conversations", input),
update: (id: string, input: ConversationPatch) => patch<Conversation>(`/api/conversations/${id}`, input),
delete: (id: string) => del<{ ok: true }>(`/api/conversations/${id}`),
send: (id: string, input: SendMessageInput) => post<SendMessageResult>(`/api/conversations/${id}/messages`, input),
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading