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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"oxc-transform-react": "0.156.0",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"vite": "^8.3.5"
Expand Down
24 changes: 15 additions & 9 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, type Location } from "react-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CloudErrorCode, type Bootstrap } from "@godmode/shared";
Expand Down Expand Up @@ -46,16 +46,23 @@ const SettingsDialog = lazy(() => import("@/pages/settings/settings-page"));

const isSettingsPath = (pathname: string) => pathname === "/settings" || pathname.startsWith("/settings/");

const listenTauri = <T,>(event: string, handler: (e: { payload: T }) => void) => import("@tauri-apps/api/event").then(({ listen }) => listen<T>(event, handler));

const invokeTauri = (cmd: string, args?: Record<string, unknown>) => import("@tauri-apps/api/core").then(({ invoke }) => invoke(cmd, args));

export function App() {
const qc = useQueryClient();
const navigate = useNavigate();
// The page on screen as the router sees it (through Godmode Cloud the address bar has a /d/<device> prefix).
const location = useLocation();
const onScreen = useRef("");
onScreen.current = `${location.pathname}${location.search}`;
const screenPath = `${location.pathname}${location.search}`;
useLayoutEffect(() => {
onScreen.current = screenPath;
}, [screenPath]);
const settingsOpen = isSettingsPath(location.pathname);
const underSettings = useRef<Location | null>(null);
if (!settingsOpen) underSettings.current = location;
const [underSettings, setUnderSettings] = useState<Location | null>(settingsOpen ? null : location);
if (!settingsOpen && underSettings !== location) setUnderSettings(location);
const [coreReady, setCoreReady] = useState(false);
const [coreError, setCoreError] = useState<string | null>(null);
const [cloudIssue, setCloudIssue] = useState<CloudIssue | null>(null);
Expand All @@ -72,8 +79,7 @@ export function App() {
if (!isTauri) return;
let stopped = false;
let unlisten: (() => void) | undefined;
void import("@tauri-apps/api/event").then(async ({ listen }) => {
const off = await listen<string>("navigate", (e) => navigate(e.payload));
void listenTauri<string>("navigate", (e) => navigate(e.payload)).then((off) => {
if (stopped) off();
else unlisten = off;
});
Expand Down Expand Up @@ -124,7 +130,7 @@ export function App() {
const minimizeToTray = boot.data?.settings.general.minimizeToTray;
useEffect(() => {
if (!isTauri || minimizeToTray === undefined) return;
void import("@tauri-apps/api/core").then(({ invoke }) => invoke("set_close_to_tray", { enabled: minimizeToTray })).catch(() => {});
void invokeTauri("set_close_to_tray", { enabled: minimizeToTray }).catch(() => {});
}, [minimizeToTray]);

// Realtime connection once authenticated
Expand Down Expand Up @@ -213,7 +219,7 @@ export function App() {
return (
<AppShell>
<Suspense fallback={<div className="p-8" />}>
<Routes location={settingsOpen ? (underSettings.current ?? "/") : location}>
<Routes location={settingsOpen ? (underSettings ?? "/") : location}>
<Route path="/" element={<ChatHome />} />
<Route path="/chat/:conversationId" element={<ChatConversation />} />
<Route path="/archived" element={<ArchivedPage />} />
Expand Down Expand Up @@ -252,7 +258,7 @@ export function App() {
</Suspense>
{settingsOpen && (
<Suspense fallback={null}>
<SettingsDialog closeTo={underSettings.current ?? "/"} />
<SettingsDialog closeTo={underSettings ?? "/"} />
</Suspense>
)}
</AppShell>
Expand Down
21 changes: 11 additions & 10 deletions apps/desktop/src/components/activity/activity-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { useSearchParams } from "react-router";
import { AnimatePresence, motion } from "motion/react";
import { format, isToday, isYesterday } from "date-fns";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Activity, RefreshCw, Search } from "lucide-react";
import type { Agent, Run, RunStatus, RunTrigger } from "@godmode/shared";
import { useAllAgents, useRuns } from "@/lib/hooks";
import { dateLocale, t as translate } from "@/i18n";
import { dateLocale } from "@/i18n";
import { errorMessage } from "@/lib/api";
import { useLive, type LiveRun } from "@/stores/live";
import { useLiveRunsMap, type LiveRun } from "@/stores/live";
import { cn } from "@/lib/utils";
import { AgentAvatar, EmptyState } from "@/components/common";
import { Orb } from "@/components/aicss/Orb";
Expand All @@ -26,16 +27,16 @@ const STATUSES: ("all" | RunStatus)[] = ["all", "running", "paused", "succeeded"

const TRIGGERS: RunTrigger[] = ["chat", "routine", "check", "dream", "delegation", "task", "followup", "heartbeat", "manual", "api"];

function dayLabel(d: Date) {
if (isToday(d)) return translate("common:words.today");
if (isYesterday(d)) return translate("common:words.yesterday");
return format(d, translate("activity:dayFormat"), { locale: dateLocale() });
function dayLabel(d: Date, t: TFunction<["activity", "runs", "common"]>) {
if (isToday(d)) return t("common:words.today");
if (isYesterday(d)) return t("common:words.yesterday");
return format(d, t("activity:dayFormat"), { locale: dateLocale() });
}

/** Every run of every agent, filtered by agent, trigger and outcome; `?run=` opens one. */
export function ActivityOverview() {
const reduce = useReduceAnimations();
const { t, i18n } = useTranslation(["activity", "runs", "common"]);
const { t } = useTranslation(["activity", "runs", "common"]);
const [params, setParams] = useSearchParams();
const agentFilter = params.get("agent") ?? "all";
const statusFilter = params.get("status") ?? "all";
Expand All @@ -46,7 +47,7 @@ export function ActivityOverview() {
const runsQ = useRuns(agentFilter, statusFilter);
const { data: agents = [] } = useAllAgents();
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const liveRuns = useLive((s) => s.runs);
const liveRuns = useLiveRunsMap();
const liveList = useMemo(
// Working right now; a queued run shows in the list below as queued.
() => Object.values(liveRuns).filter((r) => r.status === "running" && (agentFilter === "all" || r.agentId === agentFilter)),
Expand Down Expand Up @@ -75,13 +76,13 @@ export function ActivityOverview() {
const groups = useMemo(() => {
const out: { label: string; runs: Run[] }[] = [];
for (const r of runs) {
const label = dayLabel(new Date(r.startedAt ?? r.createdAt));
const label = dayLabel(new Date(r.startedAt ?? r.createdAt), t);
const last = out[out.length - 1];
if (last?.label === label) last.runs.push(r);
else out.push({ label, runs: [r] });
}
return out;
}, [runs, i18n.language]);
}, [runs, t]);

const successRate = useMemo(() => {
const finished = runs.filter((r) => r.status === "succeeded" || r.status === "failed");
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/components/agents/agent-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { qk } from "@/lib/queryKeys";
import { useMissingLogins, useScopeWorkspace } from "@/lib/hooks";
import { modKey } from "@/lib/desktop";
import { clearDraft, useDraft } from "@/lib/drafts";
import { useLive, type LiveRun } from "@/stores/live";
import { useLive, useLiveRunsMap, type LiveRun } from "@/stores/live";
import { AgentAvatar, Kbd } from "@/components/common";
import { liveMood } from "@/components/chat/conversation-mood";
import { LiveDot } from "@/components/aicss/Motion";
Expand Down Expand Up @@ -52,7 +52,7 @@ export function useAgentLiveRuns(agentId: string | undefined): LiveRun[] {
.join(",")
: "",
);
const runs = useLive((s) => s.runs);
const runs = useLiveRunsMap();
return useMemo(() => (ids ? ids.split(",").flatMap((id) => (runs[id] ? [runs[id]] : [])) : []), [ids, runs]);
}

Expand Down
13 changes: 7 additions & 6 deletions apps/desktop/src/components/agents/agent-dnd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,14 +324,14 @@ function Dock({

function DockTarget({ workspace, here }: { workspace: Workspace | null; here: boolean }) {
const { t } = useTranslation("agents");
const drop = useAgentDrop({ kind: "workspace", workspaceId: workspace?.id ?? null }, `dock:${workspace?.id ?? "global"}`);
const { ref: dropRef, state: dropState } = useAgentDrop({ kind: "workspace", workspaceId: workspace?.id ?? null }, `dock:${workspace?.id ?? "global"}`);
return (
<div
ref={drop.ref}
ref={dropRef}
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-xl border border-transparent px-2 py-1.5 text-sm transition-[background-color,border-color,transform] duration-150",
here ? "text-muted-foreground opacity-50" : "text-foreground",
drop.state === "valid" && "scale-[1.04] border-brand/50 bg-brand/10",
dropState === "valid" && "scale-[1.04] border-brand/50 bg-brand/10",
)}
>
<GroupIcon workspace={workspace} isGlobal={!workspace} className="size-5" />
Expand All @@ -342,7 +342,8 @@ function DockTarget({ workspace, here }: { workspace: Workspace | null; here: bo
}

/** Makes an agent draggable; the built-in agent stays put. */
export function useAgentDrag(agent: Agent, id = `agent:${agent.id}`) {
export function useAgentDrag(agent: Agent, dragId?: string) {
const id = dragId ?? `agent:${agent.id}`;
const { setNodeRef, listeners, isDragging } = useDraggable({ id, data: { agent }, disabled: agent.isDefault });
return {
ref: setNodeRef,
Expand Down Expand Up @@ -381,9 +382,9 @@ export function DroppableSection({ target, dropId, ...props }: ComponentProps<ty

/** Wraps a card or row so it can be picked up. */
export function DraggableAgent({ agent, className, children }: { agent: Agent; className?: string; children: ReactNode }) {
const drag = useAgentDrag(agent);
const { ref: dragRef, props: dragProps, isDragging } = useAgentDrag(agent);
return (
<div ref={drag.ref} {...drag.props} className={cn("h-full transition-opacity", drag.isDragging && "opacity-35", className)}>
<div ref={dragRef} {...dragProps} className={cn("h-full transition-opacity", isDragging && "opacity-35", className)}>
{children}
</div>
);
Expand Down
16 changes: 9 additions & 7 deletions apps/desktop/src/components/agents/agent-form.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { useEffect, useEffectEvent, useMemo, useState, type ComponentProps, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useLocation } from "react-router";
import { Trans, useTranslation } from "react-i18next";
Expand Down Expand Up @@ -332,15 +332,14 @@ export function AgentForm({
};

// Save shortcut
const submitRef = useRef(submit);
submitRef.current = submit;
const onSaveShortcut = useEffectEvent(() => submit());
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = isMac ? e.metaKey : e.ctrlKey;
if (mod && e.key.toLowerCase() === "s") {
e.preventDefault();
if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return;
submitRef.current();
onSaveShortcut();
}
};
window.addEventListener("keydown", onKey);
Expand Down Expand Up @@ -1315,14 +1314,17 @@ function TeamField({
const [resetNote, setResetNote] = useState<string | null>(null);

// A new workspace can put the chosen lead out of bounds: back to the built-in agent, and say why.
useEffect(() => {
const checkLead = useEffectEvent(() => {
if (!current) return;
if (leadProblem(self, current, team) === "workspace") {
setResetNote(t("agentForm.team.resetNote", { lead: builtin?.name ?? "Godmode", name: current.name }));
onChange(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceId, current?.id]);
});
const currentId = current?.id;
useEffect(() => {
checkLead();
}, [workspaceId, currentId]);

if (isDefault) {
return (
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/components/agents/agent-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,16 @@ function AgentRow({
const chat = useStartAgentChat();
const chatting = chat.isPending && chat.variables?.agent.id === agent.id;
const lead = leadOf(agent, all);
const drag = useAgentDrag(agent);
const { ref: dragRef, props: dragProps, isDragging } = useAgentDrag(agent);
return (
<li
ref={drag.ref}
{...drag.props}
ref={dragRef}
{...dragProps}
className={cn(
"group relative px-3 py-2 transition-[background-color,opacity] hover:bg-foreground/[0.025]",
COLUMNS,
(!!live || agent.status === "running") && "bg-brand/[0.04]",
drag.isDragging && "opacity-35",
isDragging && "opacity-35",
)}
>
<div className={cn("flex min-w-0 items-center gap-3", !agent.enabled && "opacity-60")}>
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/components/agents/character-studio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ export function PersonalityPicker({ value, onChange }: { value: string; onChange
const isCustom = !!value.trim() && !personalityPreset(value);
const [custom, setCustom] = useState(isCustom);
const lastCustom = useRef(isCustom ? value : "");
if (isCustom) lastCustom.current = value;
useEffect(() => {
if (isCustom) lastCustom.current = value;
}, [isCustom, value]);
const customOn = custom || isCustom;

const card = (selected: boolean) =>
Expand Down
21 changes: 11 additions & 10 deletions apps/desktop/src/components/agents/detail/history-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion } from "motion/react";
import { format, formatDistanceToNowStrict, isToday, isYesterday } from "date-fns";
import { format, formatDistanceToNowStrict, isToday, isYesterday, type Locale } from "date-fns";
import { GitCommitHorizontal, History } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Agent, GitCommit } from "@godmode/shared";
Expand All @@ -11,7 +11,7 @@ import { EmptyState } from "@/components/common";
import { CopyButton } from "@/components/chat/copy-button";
import { Skeleton } from "@/components/ui/skeleton";
import { useReduceAnimations } from "@/lib/motion";
import { dateLocale, t } from "@/i18n";
import { t } from "@/i18n";
import { useLocale } from "@/i18n/language";

/** isomorphic-git reports seconds; accept ISO strings and ms too. */
Expand All @@ -23,10 +23,10 @@ function commitDate(ts: string): Date {
return new Date(ts);
}

function dayLabel(d: Date) {
function dayLabel(d: Date, locale: Locale) {
if (isToday(d)) return t("common:words.today");
if (isYesterday(d)) return t("common:words.yesterday");
return format(d, t("agents:historyTab.dayFormat"), { locale: dateLocale() });
return format(d, t("agents:historyTab.dayFormat"), { locale });
}

export function HistoryTab({ agent }: { agent: Agent }) {
Expand All @@ -36,13 +36,15 @@ export function HistoryTab({ agent }: { agent: Agent }) {
const q = useQuery({ queryKey: qk.agentCommits(agent.id), queryFn: () => api.agents.commits(agent.id) });

const groups = useMemo(() => {
const out: { label: string; commits: (GitCommit & { date: Date })[] }[] = [];
const out: { label: string; start: number; commits: (GitCommit & { date: Date })[] }[] = [];
let count = 0;
for (const c of q.data ?? []) {
const date = commitDate(c.timestamp);
const label = Number.isNaN(date.getTime()) ? t("historyTab.unknownDate") : dayLabel(date);
const label = Number.isNaN(date.getTime()) ? t("historyTab.unknownDate") : dayLabel(date, dateLocale);
const last = out[out.length - 1];
if (last?.label === label) last.commits.push({ ...c, date });
else out.push({ label, commits: [{ ...c, date }] });
else out.push({ label, start: count, commits: [{ ...c, date }] });
count = count + 1;
}
return out;
}, [q.data, t, dateLocale]);
Expand All @@ -67,7 +69,6 @@ export function HistoryTab({ agent }: { agent: Agent }) {
);
}

let index = 0;
return (
<div className="space-y-8">
<p className="text-sm text-muted-foreground">
Expand All @@ -78,10 +79,10 @@ export function HistoryTab({ agent }: { agent: Agent }) {
<section key={g.label}>
<h3 className="eyebrow mb-3">{g.label}</h3>
<ol className="relative space-y-1 border-l pl-6">
{g.commits.map((c) => {
{g.commits.map((c, j) => {
const [title, ...rest] = c.message.trim().split("\n");
const body = rest.join("\n").trim();
const i = index++;
const i = g.start + j;
return (
<motion.li
key={c.oid}
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/components/agents/org-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,17 +179,17 @@ function Hub({ agent, dim, reports }: { agent: Agent; dim: boolean; reports: Age
const workspaces = new Set(reports.flatMap((a) => (a.workspaceId ? [a.workspaceId] : []))).size;
const where = global ? t("orgChart.hub.whereGlobalAnd", { count: workspaces }) : t("orgChart.hub.whereWorkspaces", { count: workspaces });
const label = t("orgChart.hub.ariaLabel", { name: agent.name, role: agent.role || t("orgChart.noRoleLower"), status: agentPresenceLabel(presence) });
const drop = useAgentDrop({ kind: "hub", agent }, "hub");
const { ref: dropRef, state: dropState } = useAgentDrop({ kind: "hub", agent }, "hub");
return (
<div className="flex flex-col items-start gap-3 @2xl:flex-row @2xl:items-center">
<div
ref={drop.ref}
ref={dropRef}
className={cn(
"group relative flex w-full max-w-sm items-center gap-3 rounded-xl border bg-card p-3 pr-4 shadow-card transition-[box-shadow,border-color,opacity] hover:border-foreground/15 hover:shadow-float",
presence.state === "working" && "glow-border",
dim && "opacity-40",
drop.state === "available" && "border-brand/30",
drop.state === "valid" && "border-brand/60 ring-4 ring-brand/15",
dropState === "available" && "border-brand/30",
dropState === "valid" && "border-brand/60 ring-4 ring-brand/15",
)}
>
<AgentAvatar agent={agent} size="lg" mood={mood} still={dim} />
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/components/agents/role-templates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const searchText = (t: AgentTemplate) => {
return [t.name, t.role, t.description, AGENT_TEMPLATE_CATEGORY_LABELS[t.category], ...(t.covers ?? []), shown.name, shown.role, shown.description, translate(`agents:roleTemplates.category.${t.category}`), ...(shown.covers ?? [])].join(" ");
};

const matching = (templates: AgentTemplate[], terms: RegExp[], _language: string) => templates.filter((t) => terms.every((re) => re.test(searchText(t))));

/**
* Every role a company has, by department: one agent per job, or one all-in-one agent per department that covers
* them all. Searchable, because there are many.
Expand All @@ -55,7 +57,7 @@ export function RoleTemplates({
const [filter, setFilter] = useState<Filter>("all");
const [query, setQuery] = useState("");
const terms = useMemo(() => wordStarts(query), [query]);
const found = useMemo(() => templates.filter((t) => terms.every((re) => re.test(searchText(t)))), [templates, terms, i18n.language]);
const found = useMemo(() => matching(templates, terms, i18n.language), [templates, terms, i18n.language]);
const inFilter = (t: AgentTemplate, f: Filter) => f === "all" || (f === "all-in-one" ? !!t.covers : t.category === f);
const shown = found.filter((t) => inFilter(t, filter));
const grouped = AGENT_TEMPLATE_CATEGORIES.map((c) => ({ category: c, items: shown.filter((t) => t.category === c) })).filter((g) => g.items.length > 0);
Expand Down
Loading
Loading