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
10 changes: 4 additions & 6 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ const AgentDetailPage = lazy(() => import("@/pages/agents/agent-detail"));
const FilesPage = lazy(() => import("@/pages/files/files-page"));
const ArtifactPage = lazy(() => import("@/pages/artifacts/artifact-page"));
const AutomationsPage = lazy(() => import("@/pages/automations/automations-page"));
const PluginsPage = lazy(() => import("@/pages/plugins/plugins-page"));
const ActivityPage = lazy(() => import("@/pages/activity/activity-page"));
const WorkspacesPage = lazy(() => import("@/pages/workspaces/workspaces-page"));
const TasksPage = lazy(() => import("@/pages/tasks/tasks-page"));
const LoginsPage = lazy(() => import("@/pages/vault/logins-page"));
Expand Down Expand Up @@ -230,9 +228,9 @@ export function App() {
<Route path="/my-tasks" element={<MyTasksRedirect />} />
<Route path="/automations" element={<AutomationsPage />} />
<Route path="/routines" element={<Navigate to="/automations" replace />} />
<Route path="/plugins" element={<FeaturePage on={b.settings.plugins.enabled} section="plugins" keep="plugin"><PluginsPage /></FeaturePage>} />
<Route path="/plugins" element={<ToSettings section="plugins" />} />
<Route path="/mods" element={<LegacyModsRedirect />} />
<Route path="/activity" element={<FeaturePage on={b.settings.activity.enabled} section="activity" keep="run"><ActivityPage /></FeaturePage>} />
<Route path="/activity" element={<ToSettings section="activity" />} />
<Route path="/workspaces" element={<WorkspacesPage />} />
<Route path="/vault" element={<Navigate to="/vault/logins" replace />} />
<Route path="/vault/logins" element={<LoginsPage />} />
Expand Down Expand Up @@ -293,7 +291,7 @@ function NotFoundRedirect() {
return null;
}

/** Links from before Mods were Plugins (notifications, chat notes): /mods?mod=<id> → /plugins?plugin=<id>. */
/** Links from before Mods were Plugins (notifications, chat notes): /mods?mod=<id> → /settings/plugins?plugin=<id>. */
/** My tasks became the Inbox's board: old links (notifications, tickets) open the same task there. */
/**
* A page of a feature the human can turn off: while it's off, its address opens the feature's switch in Settings. A
Expand Down Expand Up @@ -327,5 +325,5 @@ function LegacyModsRedirect() {
params.set("plugin", id);
}
const query = params.toString();
return <Navigate to={`/plugins${query ? `?${query}` : ""}`} replace />;
return <Navigate to={`/settings/plugins${query ? `?${query}` : ""}`} replace />;
}
279 changes: 279 additions & 0 deletions apps/desktop/src/components/activity/activity-overview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { AnimatePresence, motion } from "motion/react";
import { format, isToday, isYesterday } from "date-fns";
import { useTranslation } from "react-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 { errorMessage } from "@/lib/api";
import { useLive, type LiveRun } from "@/stores/live";
import { cn } from "@/lib/utils";
import { AgentAvatar, EmptyState } from "@/components/common";
import { Orb } from "@/components/aicss/Orb";
import { LiveDot, WorkingTicks } from "@/components/aicss/Motion";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RunRow, useNow } from "@/components/runs/run-row";
import { RunDetailSheet } from "@/components/runs/run-detail-sheet";
import { formatElapsed, runStatusLabel } from "@/components/runs/run-status";
import { useReduceAnimations } from "@/lib/motion";

const STATUSES: ("all" | RunStatus)[] = ["all", "running", "paused", "succeeded", "failed", "cancelled", "queued"];

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() });
}

/** 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 [params, setParams] = useSearchParams();
const agentFilter = params.get("agent") ?? "all";
const statusFilter = params.get("status") ?? "all";
const selectedId = params.get("run");
const [trigger, setTrigger] = useState<"all" | RunTrigger>("all");
const [search, setSearch] = useState("");

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 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)),
[liveRuns, agentFilter],
);

const setParam = (key: string, value: string | null) => {
setParams(
(p) => {
const next = new URLSearchParams(p);
if (value === null || value === "all") next.delete(key);
else next.set(key, value);
return next;
},
{ replace: key !== "run" },
);
};

const runs = useMemo(() => {
const q = search.trim().toLowerCase();
return [...(runsQ.data ?? [])]
.filter((r) => (trigger === "all" || r.trigger === trigger) && (!q || `${r.prompt} ${r.result ?? ""} ${r.error ?? ""}`.toLowerCase().includes(q)))
.sort((a, b) => (b.startedAt ?? b.createdAt).localeCompare(a.startedAt ?? a.createdAt));
}, [runsQ.data, trigger, search]);

const groups = useMemo(() => {
const out: { label: string; runs: Run[] }[] = [];
for (const r of runs) {
const label = dayLabel(new Date(r.startedAt ?? r.createdAt));
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]);

const successRate = useMemo(() => {
const finished = runs.filter((r) => r.status === "succeeded" || r.status === "failed");
const ok = finished.filter((r) => r.status === "succeeded").length;
return finished.length ? Math.round((ok / finished.length) * 100) : null;
}, [runs]);

const selectedRun = runs.find((r) => r.id === selectedId) ?? null;
const filtered = agentFilter !== "all" || statusFilter !== "all" || trigger !== "all" || !!search.trim();
const agentsWithAvatar = [...agents].sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name));

return (
<div className="@container space-y-6">
<AnimatePresence initial={!reduce}>
{liveList.length > 0 && statusFilter !== "succeeded" && statusFilter !== "failed" && statusFilter !== "cancelled" && statusFilter !== "paused" && (
<motion.section
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-visible"
aria-label={t("runningNow")}
>
<h2 className="eyebrow mb-3 flex items-center gap-2">
<LiveDot /> {t("liveNow")}
<span className="rounded-[4px] border bg-card px-1 font-mono text-[10px] tabular-nums">{liveList.length}</span>
</h2>
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2 @5xl:grid-cols-3">
{liveList.map((r) => (
<LiveCard key={r.runId} live={r} agent={agentById.get(r.agentId)} onOpen={() => setParam("run", r.runId)} />
))}
</div>
</motion.section>
)}
</AnimatePresence>

<div className="flex flex-wrap items-center gap-3">
<div className="relative w-full max-w-xs">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("searchPlaceholder")} aria-label={t("searchLabel")} className="pl-9" />
</div>
<Select value={agentFilter} onValueChange={(v) => setParam("agent", v)}>
<SelectTrigger className="w-48" aria-label={t("filterByAgent")}>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">{t("allAgents")}</SelectItem>
{agentsWithAvatar.length > 0 && <SelectSeparator />}
{agentsWithAvatar.map((a) => (
<SelectItem key={a.id} value={a.id}>
<AgentAvatar agent={a} size="sm" still className="size-4" /> {a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={trigger} onValueChange={(v) => setTrigger(v as "all" | RunTrigger)}>
<SelectTrigger className="w-40" aria-label={t("filterByTrigger")}>
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">{t("anyTrigger")}</SelectItem>
{TRIGGERS.map((id) => (
<SelectItem key={id} value={id}>
{t(`runs:trigger.${id}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="icon" className="ml-auto" onClick={() => runsQ.refetch()} aria-label={t("common:actions.refresh")} disabled={runsQ.isFetching}>
<RefreshCw className={cn(runsQ.isFetching && "animate-spin")} />
</Button>
</div>

<div role="tablist" aria-label={t("filterByStatus")} className="-mt-2 flex flex-wrap gap-1.5">
{STATUSES.map((s) => (
<button
key={s}
role="tab"
aria-selected={statusFilter === s}
onClick={() => setParam("status", s)}
className={cn(
"rounded-md border px-3 py-1 text-xs font-medium transition focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none",
statusFilter === s
? "border-foreground/25 bg-card text-foreground shadow-card"
: "text-muted-foreground hover:border-foreground/15 hover:text-foreground",
)}
>
{s === "all" ? t("common:words.all") : runStatusLabel(s)}
</button>
))}
{runs.length > 0 && (
<span className="ml-auto self-center text-xs text-muted-foreground tabular-nums">
{successRate != null ? t("countWithSuccess", { count: runs.length, rate: successRate }) : t("count", { count: runs.length })}
</span>
)}
</div>

{runsQ.isLoading ? (
<div className="space-y-1 rounded-xl border bg-card p-2 shadow-card">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="size-6 rounded-md" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3.5 w-40" />
<Skeleton className="h-3 w-2/3" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
) : runsQ.isError ? (
<EmptyState
icon={<Activity />}
title={t("loadFailed")}
description={errorMessage(runsQ.error)}
action={
<Button variant="outline" onClick={() => runsQ.refetch()}>
{t("common:actions.tryAgain")}
</Button>
}
/>
) : runs.length === 0 ? (
<EmptyState
icon={<Activity />}
title={filtered ? t("noMatch") : t("emptyTitle")}
description={filtered ? undefined : t("emptyDescription")}
action={
filtered ? (
<Button
variant="outline"
onClick={() => {
setParams({});
setTrigger("all");
setSearch("");
}}
>
{t("clearFilters")}
</Button>
) : undefined
}
/>
) : (
<div className="space-y-6">
{groups.map((g) => (
<section key={g.label}>
<h2 className="eyebrow mb-2 px-1">{g.label}</h2>
<div className="space-y-0.5 rounded-xl border bg-card p-1.5 shadow-card">
{g.runs.map((r, i) => (
<motion.div key={r.id} initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: Math.min(i, 15) * 0.015 }}>
<RunRow run={r} agent={agentById.get(r.agentId)} selected={r.id === selectedId} onSelect={(run) => setParam("run", run.id)} />
</motion.div>
))}
</div>
</section>
))}
{(runsQ.data?.length ?? 0) >= 200 && (
<p className="text-center text-xs text-muted-foreground">{t("limitHint", { count: 200 })}</p>
)}
</div>
)}

<RunDetailSheet runId={selectedId} run={selectedRun} onOpenChange={(o) => !o && setParam("run", null)} />
</div>
);
}

function LiveCard({ live, agent, onOpen }: { live: LiveRun; agent?: Agent; onOpen: () => void }) {
const reduce = useReduceAnimations();
const { t } = useTranslation(["activity", "common"]);
const now = useNow(true);
return (
<motion.button
type="button"
layout={!reduce}
initial={reduce ? false : { opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, scale: 0.97 }}
onClick={onOpen}
className="full-motion:glow-border flex items-center gap-3 rounded-xl border bg-card p-3.5 text-left shadow-card transition hover:border-foreground/20 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
{agent ? <AgentAvatar agent={agent} size="md" /> : <Orb variant="S3" size={24} />}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-sm font-medium">
<span className="truncate">{agent?.name ?? t("common:words.agent")}</span>
<Orb variant="S3" size={14} label={t("common:status.running")} />
</div>
<div className="text-shimmer truncate text-xs font-medium">{live.activity ?? t("thinking")}</div>
</div>
<div className="flex flex-col items-end gap-1">
{!reduce && <WorkingTicks count={6} className="h-3 text-brand-strong" />}
<span className="font-mono text-[11px] text-muted-foreground tabular-nums">{formatElapsed(now - live.startedAt)}</span>
</div>
</motion.button>
);
}
2 changes: 1 addition & 1 deletion apps/desktop/src/components/agents/agent-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ export function AgentStatus({
const linkClass = cn("relative z-10 inline-flex min-w-0 items-center gap-1.5 rounded-sm text-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none", className);
if (interactive && state === "failed" && agent.failedRunId) {
return (
<Link to={`/activity?run=${agent.failedRunId}`} className={linkClass} title={t("agentActions.status.openFailedRun")} onClick={(e) => e.stopPropagation()}>
<Link to={`/settings/activity?run=${agent.failedRunId}`} className={linkClass} title={t("agentActions.status.openFailedRun")} onClick={(e) => e.stopPropagation()}>
{dot}
{text}
</Link>
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/agents/detail/overview-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function OverviewTab({ agent }: { agent: Agent }) {
<h2 className="text-sm font-medium tracking-[-0.01em]">{easy ? t("overviewTab.recentWork") : t("overviewTab.recentRuns")}</h2>
{activityOn && (
<Button variant="ghost" size="sm" asChild className="text-muted-foreground">
<Link to={`/activity?agent=${agent.id}`}>
<Link to={`/settings/activity?agent=${agent.id}`}>
{t("overviewTab.allActivity")} <ArrowRight />
</Link>
</Button>
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/automations/events-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function EventsSheet({
const conversationId = conversationByRun.get(e.runId);
if (conversationId) return { to: `/chat/${conversationId}`, label: t("events.openConversation") };
if (routine.reuseConversation && routine.conversationId) return { to: `/chat/${routine.conversationId}`, label: t("events.openConversation") };
return { to: `/activity?run=${e.runId}`, label: t("events.viewRun") };
return { to: `/settings/activity?run=${e.runId}`, label: t("events.viewRun") };
};

return (
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/chat/message-blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ function ModNote({ level, text, name }: { level: "info" | "warning" | "success";
<div className={cn("flex flex-wrap items-start gap-x-2 gap-y-1 rounded-lg border px-3 py-2 text-[13px]", NOTICE[level].cls)}>
{mod ? (
<Link
to={`/plugins?plugin=${mod.id}`}
to={`/settings/plugins?plugin=${mod.id}`}
title={t("blocks.pluginNote", { name: mod.title })}
className={cn(chip, "transition outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50")}
>
Expand Down
Loading
Loading