diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 32f97d6c..9c355459 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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")); @@ -230,9 +228,9 @@ export function App() { } /> } /> } /> - } /> + } /> } /> - } /> + } /> } /> } /> } /> @@ -293,7 +291,7 @@ function NotFoundRedirect() { return null; } -/** Links from before Mods were Plugins (notifications, chat notes): /mods?mod= → /plugins?plugin=. */ +/** Links from before Mods were Plugins (notifications, chat notes): /mods?mod= → /settings/plugins?plugin=. */ /** 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 @@ -327,5 +325,5 @@ function LegacyModsRedirect() { params.set("plugin", id); } const query = params.toString(); - return ; + return ; } diff --git a/apps/desktop/src/components/activity/activity-overview.tsx b/apps/desktop/src/components/activity/activity-overview.tsx new file mode 100644 index 00000000..b66dc8da --- /dev/null +++ b/apps/desktop/src/components/activity/activity-overview.tsx @@ -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 ( +
+ + {liveList.length > 0 && statusFilter !== "succeeded" && statusFilter !== "failed" && statusFilter !== "cancelled" && statusFilter !== "paused" && ( + +

+ {t("liveNow")} + {liveList.length} +

+
+ {liveList.map((r) => ( + setParam("run", r.runId)} /> + ))} +
+
+ )} +
+ +
+
+ + setSearch(e.target.value)} placeholder={t("searchPlaceholder")} aria-label={t("searchLabel")} className="pl-9" /> +
+ + + +
+ +
+ {STATUSES.map((s) => ( + + ))} + {runs.length > 0 && ( + + {successRate != null ? t("countWithSuccess", { count: runs.length, rate: successRate }) : t("count", { count: runs.length })} + + )} +
+ + {runsQ.isLoading ? ( +
+ {Array.from({ length: 8 }, (_, i) => ( +
+ + +
+ + +
+ +
+ ))} +
+ ) : runsQ.isError ? ( + } + title={t("loadFailed")} + description={errorMessage(runsQ.error)} + action={ + + } + /> + ) : runs.length === 0 ? ( + } + title={filtered ? t("noMatch") : t("emptyTitle")} + description={filtered ? undefined : t("emptyDescription")} + action={ + filtered ? ( + + ) : undefined + } + /> + ) : ( +
+ {groups.map((g) => ( +
+

{g.label}

+
+ {g.runs.map((r, i) => ( + + setParam("run", run.id)} /> + + ))} +
+
+ ))} + {(runsQ.data?.length ?? 0) >= 200 && ( +

{t("limitHint", { count: 200 })}

+ )} +
+ )} + + !o && setParam("run", null)} /> +
+ ); +} + +function LiveCard({ live, agent, onOpen }: { live: LiveRun; agent?: Agent; onOpen: () => void }) { + const reduce = useReduceAnimations(); + const { t } = useTranslation(["activity", "common"]); + const now = useNow(true); + return ( + + {agent ? : } +
+
+ {agent?.name ?? t("common:words.agent")} + +
+
{live.activity ?? t("thinking")}
+
+
+ {!reduce && } + {formatElapsed(now - live.startedAt)} +
+
+ ); +} diff --git a/apps/desktop/src/components/agents/agent-actions.tsx b/apps/desktop/src/components/agents/agent-actions.tsx index ae05e418..ecca6d5f 100644 --- a/apps/desktop/src/components/agents/agent-actions.tsx +++ b/apps/desktop/src/components/agents/agent-actions.tsx @@ -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 ( - e.stopPropagation()}> + e.stopPropagation()}> {dot} {text} diff --git a/apps/desktop/src/components/agents/detail/overview-tab.tsx b/apps/desktop/src/components/agents/detail/overview-tab.tsx index 559b6248..0c7e76cb 100644 --- a/apps/desktop/src/components/agents/detail/overview-tab.tsx +++ b/apps/desktop/src/components/agents/detail/overview-tab.tsx @@ -79,7 +79,7 @@ export function OverviewTab({ agent }: { agent: Agent }) {

{easy ? t("overviewTab.recentWork") : t("overviewTab.recentRuns")}

{activityOn && ( diff --git a/apps/desktop/src/components/automations/events-sheet.tsx b/apps/desktop/src/components/automations/events-sheet.tsx index ada8daf4..05ac1870 100644 --- a/apps/desktop/src/components/automations/events-sheet.tsx +++ b/apps/desktop/src/components/automations/events-sheet.tsx @@ -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 ( diff --git a/apps/desktop/src/components/chat/message-blocks.tsx b/apps/desktop/src/components/chat/message-blocks.tsx index 135a67b2..893f0ba0 100644 --- a/apps/desktop/src/components/chat/message-blocks.tsx +++ b/apps/desktop/src/components/chat/message-blocks.tsx @@ -323,7 +323,7 @@ function ModNote({ level, text, name }: { level: "info" | "warning" | "success";
{mod ? ( diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx index f0c169d7..bf93c636 100644 --- a/apps/desktop/src/components/layout/app-shell.tsx +++ b/apps/desktop/src/components/layout/app-shell.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import { Link, NavLink, useLocation, useNavigate } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { - Activity, ArrowLeft, Bot, Box, @@ -18,7 +17,6 @@ import { PanelLeftClose, PanelLeftOpen, Phone, - Puzzle, Search, Server, Settings, @@ -27,7 +25,6 @@ import { X, } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { modState, modUpdateAvailable } from "@godmode/shared"; import { Sidebar, SidebarContent, @@ -65,7 +62,8 @@ import { PageScrollContext } from "@/components/layout/page-scroll"; import { Callout } from "@/components/settings/settings-kit"; import { FreeTrialChip, LicenseBanner } from "@/components/license/license-banner"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { useBootstrap, useMods } from "@/lib/hooks"; +import { useBootstrap } from "@/lib/hooks"; +import { usePluginsWaiting } from "@/components/mods/use-plugins-waiting"; import { useEasyMode } from "@/lib/mode"; import { api } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; @@ -132,9 +130,6 @@ export function AppShell({ children }: { children: ReactNode }) { const waiting = attention?.total ?? (boot?.counts.openQuestions ?? 0) + (boot?.counts.openMissingLogins ?? 0); const inboxCount = waiting || (boot?.counts.unreadNotifications ?? 0); const hiddenNav = useUi((s) => s.hiddenNav); - const { data: mods = [] } = useMods(); - // Plugins that wait for the human: an agent's draft or install to review, code the check refuses, an update. - const modsWaiting = mods.filter((m) => ["review", "broken"].includes(modState(m)) || modUpdateAvailable(m)).length; const easy = useEasyMode(); // Notices when the human comes back after a while (Home then sums up what happened). @@ -197,9 +192,7 @@ export function AppShell({ children }: { children: ReactNode }) { { to: "/tasks", label: t("nav.tasks"), icon: , badge: (attention?.review ?? 0) + (attention?.blocked ?? 0) || undefined }, { to: "/agents", label: t("nav.agents"), icon: , badge: runningCount || undefined }, { to: "/automations", label: t("nav.automations"), icon: , badge: attention?.automation || undefined }, - { to: "/activity", label: t("nav.activity"), icon: }, { to: "/calls", label: t("nav.calls"), icon: }, - { to: "/plugins", label: t("nav.plugins"), icon: , badge: modsWaiting || undefined }, { to: "/vault/logins", label: t("nav.passwords"), icon: , also: ["/vault/2fa", "/vault/passkeys"] }, { to: "/vault/cards", label: t("nav.cards"), icon: }, ]; @@ -220,10 +213,8 @@ export function AppShell({ children }: { children: ReactNode }) { easyItem("/vault/logins", t("nav.passwords")), ]; const easyMore: NavItem[] = [ - easyItem("/activity", t("system:easyPages.activity.title")), easyItem("/calls", t("system:easyPages.calls.title")), easyItem("/vault/cards", t("system:easyPages.cards.title")), - easyItem("/plugins", t("system:easyPages.plugins.title")), easyItem("/browser", t("nav.browser")), easyItem("/computer", t("system:easyPages.computer.title")), easyItem("/vms", t("nav.vms")), @@ -687,6 +678,7 @@ function FooterBar() { const { data: boot } = useBootstrap(); // People waiting to talk to a bot: Settings → Messaging shows who. const requests = boot?.settings.messaging.enabled ? boot.counts.messagingRequests : 0; + const plugins = usePluginsWaiting(); const connected = useLive((s) => s.connected); const qc = useQueryClient(); const lock = useMutation({ @@ -729,16 +721,28 @@ function FooterBar() { variant={SETTINGS_PATHS.some((p) => location.pathname.startsWith(p)) ? "secondary" : "ghost"} size="icon" className="relative size-8" - aria-label={requests ? t("footer.settingsWaitingLabel", { count: requests }) : t("common:words.settings")} + aria-label={ + requests + ? t("footer.settingsWaitingLabel", { count: requests }) + : plugins + ? t("footer.settingsPluginsLabel", { count: plugins }) + : t("common:words.settings") + } asChild > - + - {requests > 0 && } + {requests + plugins > 0 && } - {requests ? t("footer.settingsWaiting", { count: requests }) : t("footer.settingsShortcut", { shortcut: `${modKey},` })} + + {requests + ? t("footer.settingsWaiting", { count: requests }) + : plugins + ? t("footer.settingsPlugins", { count: plugins }) + : t("footer.settingsShortcut", { shortcut: `${modKey},` })} +
); diff --git a/apps/desktop/src/components/layout/command-palette.tsx b/apps/desktop/src/components/layout/command-palette.tsx index 1f41965f..0cb1cc33 100644 --- a/apps/desktop/src/components/layout/command-palette.tsx +++ b/apps/desktop/src/components/layout/command-palette.tsx @@ -93,7 +93,7 @@ const SETTINGS = [ { id: "messaging", label: "messaging", icon: MessageCircle, words: "chat apps slack telegram microsoft teams bots feature turn on off" }, { id: "android", label: "android", icon: Smartphone, words: "adb usb debugging emulator device feature turn on off" }, { id: "activity", label: "activity", icon: Activity, words: "history runs log feature turn on off" }, - { id: "plugins", label: "plugins", icon: Puzzle, words: "claude code mods skills hooks add-ons feature turn on off" }, + { id: "plugins", label: "plugins", icon: Puzzle, words: "claude code mods skills hooks guardrails github install add-ons feature turn on off" }, { id: "calls", label: "calls", icon: Phone, words: "elevenlabs twilio number call voice agent inbound feature turn on off" }, { id: "cards", label: "cards", icon: CreditCard, words: "payment credit cards purchases pay feature turn on off" }, { id: "vms", label: "vms", icon: Box, words: "vm tart macos linux feature turn on off" }, @@ -412,16 +412,6 @@ export function CommandPalette() { go("/automations")}> {t("nav.automations")} - {on?.plugins.enabled && ( - go("/plugins")}> - {named("/plugins", t("nav.plugins"))} - - )} - {on?.activity.enabled && ( - go("/activity")}> - {named("/activity", t("nav.activity"))} - - )} go("/inbox")}> {t("nav.inbox")} diff --git a/apps/desktop/src/components/layout/shortcuts-dialog.tsx b/apps/desktop/src/components/layout/shortcuts-dialog.tsx index cd53469f..81ce0370 100644 --- a/apps/desktop/src/components/layout/shortcuts-dialog.tsx +++ b/apps/desktop/src/components/layout/shortcuts-dialog.tsx @@ -14,7 +14,7 @@ export const GO_TO = [ { key: "a", to: "/agents", label: "nav.agents" }, { key: "u", to: "/automations", label: "nav.automations" }, { key: "i", to: "/inbox", label: "nav.inbox" }, - { key: "y", to: "/activity", label: "nav.activity" }, + { key: "y", to: "/settings/activity", label: "nav.activity" }, { key: "l", to: "/vault/logins", label: "nav.passwords" }, { key: "s", to: "/settings/general", label: "nav.settings" }, ] as const; diff --git a/apps/desktop/src/components/mods/plugins-overview.tsx b/apps/desktop/src/components/mods/plugins-overview.tsx new file mode 100644 index 00000000..9b131e7d --- /dev/null +++ b/apps/desktop/src/components/mods/plugins-overview.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router"; +import { AnimatePresence, motion } from "motion/react"; +import { ChevronDown, FilePlus2, FolderInput, Plus, Puzzle, RefreshCw, WandSparkles } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { modState, modUpdateAvailable, type Mod } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Spinner } from "@/components/ui/spinner"; +import { EmptyState } from "@/components/common"; +import { FeaturedPlugins } from "@/components/mods/featured-plugins"; +import { ModCard } from "@/components/mods/mod-card"; +import { DeleteModDialog, ImportModDialog, InstallPluginDialog, NewModDialog } from "@/components/mods/mod-dialogs"; +import { ModGallery } from "@/components/mods/mod-gallery"; +import { GitHubMark, isModTab, type ModTab } from "@/components/mods/mod-parts"; +import { ModSheet } from "@/components/mods/mod-sheet"; +import { useModActions } from "@/components/mods/use-mod-actions"; +import { errorMessage } from "@/lib/api"; +import { useMods } from "@/lib/hooks"; +import { useReduceAnimations } from "@/lib/motion"; + +const STEPS = [ + { title: "page.steps.install.title", text: "page.steps.install.text" }, + { title: "page.steps.check.title", text: "page.steps.check.text" }, + { title: "page.steps.use.title", text: "page.steps.use.text" }, +] as const; + +/** Claude Code plugins: install from GitHub or the gallery, switch on and off, update, set options, choose who runs them. */ +export function PluginsOverview() { + const reduce = useReduceAnimations(); + const { t } = useTranslation(["mods", "common"]); + const [params, setParams] = useSearchParams(); + const navigate = useNavigate(); + const list = useMods(); + const mods = list.data ?? []; + const actions = useModActions(); + const [sheet, setSheet] = useState<{ id: string; tab: ModTab } | null>(null); + const [newOpen, setNewOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [installOpen, setInstallOpen] = useState(false); + const [deleteId, setDeleteId] = useState(null); + + const byId = (id: string | null | undefined) => (id ? (mods.find((m) => m.id === id) ?? null) : null); + const open = (id: string, tab: ModTab = "overview") => setSheet({ id, tab }); + + // Deep links from other screens: /settings/plugins?plugin= opens that plugin, &tab=code|contents|options|overview on that tab. + const wantsMod = params.get("plugin"); + const wantsTab = params.get("tab"); + useEffect(() => { + if (!wantsMod || !list.data) return; + if (list.data.some((m) => m.id === wantsMod)) open(wantsMod, isModTab(wantsTab) ? wantsTab : "overview"); + setParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete("plugin"); + next.delete("tab"); + return next; + }, + { replace: true }, + ); + }, [wantsMod, wantsTab, list.data, setParams]); + + // A plugin freshly created is in the cache before the sheet opens; one that is gone closes it. + const current = byId(sheet?.id); + useEffect(() => { + if (sheet && list.data && !current) setSheet(null); + }, [sheet, list.data, current]); + + const on = mods.filter((m) => modState(m) === "on").length; + const waiting = mods.filter((m) => ["review", "broken"].includes(modState(m))).length; + const fromGit = mods.filter((m) => m.source); + const updates = fromGit.filter(modUpdateAvailable).length; + const lookingAll = actions.checkUpdates.isPending && !actions.checkUpdates.variables; + + return ( +
+
+
+
+

+ {t("page.installed")} +

+ {mods.length > 0 && ( +

+ {t("page.pluginCount", { count: mods.length })} · {t("page.onCount", { n: on })} + {updates > 0 && · {t("page.updatesReady", { count: updates })}} + {waiting > 0 && · {t("page.needLook", { count: waiting })}} +

+ )} +
+
+ {fromGit.length > 0 && ( + + )} + + + + + + + setInstallOpen(true)}> + {t("page.installFromGitHub")} + + + setNewOpen(true)}> + {t("page.writeFromScratch")} + + setImportOpen(true)}> + {t("page.importFolder")} + + + +
+
+ + {list.isLoading ? ( +
+ {[0, 1].map((i) => ( + + ))} +
+ ) : list.isError ? ( + } + title={t("page.loadError")} + description={errorMessage(list.error)} + action={ + + } + /> + ) : mods.length === 0 ? ( + + {STEPS.map((step, i) => ( +
  • + + {i + 1} + +
    +

    {t(step.title)}

    +

    {t(step.text)}

    +
    +
  • + ))} +
    + ) : ( +
    + + {mods.map((mod, i) => ( + + open(mod.id, tab)} onDelete={() => setDeleteId(mod.id)} /> + + ))} + +
    + )} +
    + + open(mod.id)} /> + + open(mod.id)} onAdded={(mod: Mod) => open(mod.id)} /> + + setSheet((s) => (s ? { ...s, tab } : s))} + onClose={() => setSheet(null)} + onDelete={(mod) => setDeleteId(mod.id)} + actions={actions} + /> + open(mod.id)} /> + open(mod.id, "code")} /> + open(mod.id)} /> + setDeleteId(null)} actions={actions} /> +
    + ); +} diff --git a/apps/desktop/src/components/mods/use-plugins-waiting.ts b/apps/desktop/src/components/mods/use-plugins-waiting.ts new file mode 100644 index 00000000..93244c64 --- /dev/null +++ b/apps/desktop/src/components/mods/use-plugins-waiting.ts @@ -0,0 +1,10 @@ +import { modState, modUpdateAvailable } from "@godmode/shared"; +import { useBootstrap, useMods } from "@/lib/hooks"; + +/** Plugins that wait for the human: an agent's draft or install to review, code the check refuses, an update. */ +export function usePluginsWaiting(): number { + const { data: boot } = useBootstrap(); + const { data: mods = [] } = useMods(); + if (!boot?.settings.plugins.enabled) return 0; + return mods.filter((m) => ["review", "broken"].includes(modState(m)) || modUpdateAvailable(m)).length; +} diff --git a/apps/desktop/src/components/settings/activity-section.tsx b/apps/desktop/src/components/settings/activity-section.tsx index 99e571b9..b36454d6 100644 --- a/apps/desktop/src/components/settings/activity-section.tsx +++ b/apps/desktop/src/components/settings/activity-section.tsx @@ -1,28 +1,40 @@ import { Activity, Filter, History, ListChecks } from "lucide-react"; +import { useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import type { Settings } from "@godmode/shared"; +import { ActivityOverview } from "@/components/activity/activity-overview"; +import { RunDetailSheet } from "@/components/runs/run-detail-sheet"; import { useEasyMode } from "@/lib/mode"; -import { FeaturePageLink, FeatureSection } from "./feature-section"; +import { FeatureSection, featureOn } from "./feature-section"; export function ActivitySection({ settings }: { settings: Settings }) { const { t } = useTranslation("settings"); const easy = useEasyMode(); - const title = easy ? t("activity.titleEasy") : t("activity.title"); + const [params, setParams] = useSearchParams(); + const run = params.get("run"); return ( - } - offNote={t("activity.offNote")} - points={[ - { icon: , text: t("activity.pointRuns") }, - { icon: , text: t("activity.pointFilter") }, - { icon: , text: t("activity.pointSteps") }, - ]} - > - } title={t("feature.open", { title })} description={t("activity.linkDescription")} /> - + <> + } + offNote={t("activity.offNote")} + points={[ + { icon: , text: t("activity.pointRuns") }, + { icon: , text: t("activity.pointFilter") }, + { icon: , text: t("activity.pointSteps") }, + ]} + > + + + {!featureOn(settings, "activity") && ( + !open && setParams((p) => (p.delete("run"), p), { replace: true })} + /> + )} + ); } diff --git a/apps/desktop/src/components/settings/feature-section.tsx b/apps/desktop/src/components/settings/feature-section.tsx index 3de378df..4e64dee5 100644 --- a/apps/desktop/src/components/settings/feature-section.tsx +++ b/apps/desktop/src/components/settings/feature-section.tsx @@ -30,9 +30,7 @@ export const FEATURE_SECTION: Record = { /** Pages in the sidebar that belong to a feature: they leave it while the feature is off. */ export const PAGE_FEATURE: Record = { - "/activity": "activity", "/calls": "phone", - "/plugins": "plugins", "/vault/cards": "cards", "/vms": "vm", "/ssh": "ssh", @@ -55,6 +53,7 @@ export function FeatureSection({ icon, points, offNote, + showWhileOff, children, }: { feature: Feature; @@ -64,6 +63,8 @@ export function FeatureSection({ icon: ReactNode; points: { icon: ReactNode; text: string }[]; offNote?: string; + /** Shows the feature itself even while it's off (a link to one of its records). */ + showWhileOff?: boolean; children: ReactNode; }) { const { t } = useTranslation(["settings", "common"]); @@ -89,7 +90,7 @@ export function FeatureSection({ - {on ? ( + {on || showWhileOff ? ( {children} diff --git a/apps/desktop/src/components/settings/plugins-section.tsx b/apps/desktop/src/components/settings/plugins-section.tsx index 1199ef13..c10b2809 100644 --- a/apps/desktop/src/components/settings/plugins-section.tsx +++ b/apps/desktop/src/components/settings/plugins-section.tsx @@ -1,28 +1,34 @@ +import { useState } from "react"; +import { useSearchParams } from "react-router"; import { GitBranch, Puzzle, ShieldCheck, Sparkles } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { Settings } from "@godmode/shared"; +import { PluginsOverview } from "@/components/mods/plugins-overview"; import { useEasyMode } from "@/lib/mode"; -import { FeaturePageLink, FeatureSection } from "./feature-section"; +import { FeatureSection } from "./feature-section"; export function PluginsSection({ settings }: { settings: Settings }) { const { t } = useTranslation("settings"); const easy = useEasyMode(); - const title = easy ? t("plugins.titleEasy") : t("plugins.title"); + // A link to one plugin (an agent's draft to review) opens it even while plugins are off. + const [params] = useSearchParams(); + const [linked] = useState(() => params.has("plugin")); return ( } offNote={t("plugins.offNote")} + showWhileOff={linked} points={[ { icon: , text: t("plugins.pointGit") }, { icon: , text: t("plugins.pointHooks") }, { icon: , text: t("plugins.pointChecked") }, ]} > - } title={t("feature.open", { title })} description={t("plugins.linkDescription")} /> + ); } diff --git a/apps/desktop/src/i18n/locales/de/activity.json b/apps/desktop/src/i18n/locales/de/activity.json index 202e868f..be570486 100644 --- a/apps/desktop/src/i18n/locales/de/activity.json +++ b/apps/desktop/src/i18n/locales/de/activity.json @@ -1,7 +1,5 @@ { "dayFormat": "EEEE, d. MMM", - "title": "Aktivität", - "description": "Jeder Lauf all deiner Agenten.", "runningNow": "Läuft gerade", "liveNow": "Gerade live", "searchPlaceholder": "Prompts & Ergebnisse durchsuchen…", diff --git a/apps/desktop/src/i18n/locales/de/layout.json b/apps/desktop/src/i18n/locales/de/layout.json index f27ff4da..cbb0ded0 100644 --- a/apps/desktop/src/i18n/locales/de/layout.json +++ b/apps/desktop/src/i18n/locales/de/layout.json @@ -28,7 +28,6 @@ "automations": "Automationen", "activity": "Aktivität", "calls": "Anrufe", - "plugins": "Plugins", "passwords": "Passwörter", "cards": "Karten", "browser": "Browser", @@ -97,6 +96,10 @@ "settingsWaitingLabel_other": "Einstellungen ({{count}} warten in Messaging)", "settingsWaiting_one": "Einstellungen · {{count}} wartet in Messaging", "settingsWaiting_other": "Einstellungen · {{count}} warten in Messaging", + "settingsPlugins_one": "Einstellungen · {{count}} Plugin wartet auf dich", + "settingsPlugins_other": "Einstellungen · {{count}} Plugins warten auf dich", + "settingsPluginsLabel_one": "Einstellungen ({{count}} Plugin wartet auf dich)", + "settingsPluginsLabel_other": "Einstellungen ({{count}} Plugins warten auf dich)", "settingsShortcut": "Einstellungen ({{shortcut}})" }, "splash": { diff --git a/apps/desktop/src/i18n/locales/de/mods.json b/apps/desktop/src/i18n/locales/de/mods.json index 9a5bf17f..9919951c 100644 --- a/apps/desktop/src/i18n/locales/de/mods.json +++ b/apps/desktop/src/i18n/locales/de/mods.json @@ -1,7 +1,5 @@ { "page": { - "title": "Plugins", - "description": "Gib deinen Agenten neue Skills, Subagenten, Befehle und Leitplanken. Das sind Claude-Code-Plugins: Installiere sie von GitHub, füge eins von Godmode hinzu oder schreib dein eigenes – und schalte sie jederzeit aus, aktualisiere oder entferne sie.", "askPrompt": "Schreib ein Claude-Code-Plugin für Godmode, das ", "askGodmode": "Godmode schreiben lassen", "addPlugin": "Plugin hinzufügen", diff --git a/apps/desktop/src/i18n/locales/de/settings.json b/apps/desktop/src/i18n/locales/de/settings.json index 15929290..b24d6628 100644 --- a/apps/desktop/src/i18n/locales/de/settings.json +++ b/apps/desktop/src/i18n/locales/de/settings.json @@ -78,11 +78,10 @@ "title": "Aktivität", "titleEasy": "Verlauf", "summary": "Jeder Lauf jedes Agenten an einem Ort: was er gemacht hat, wie lange er gedauert hat und wo er gescheitert ist.", - "offNote": "Solange es aus ist, verschwindet es aus der Seitenleiste. Läufe werden weiter aufgezeichnet und Links zu einem Lauf öffnen ihn weiterhin.", + "offNote": "Solange es aus ist, werden Läufe weiter aufgezeichnet und Links zu einem Lauf öffnen ihn weiterhin.", "pointRuns": "Jeder Lauf, die neuesten zuerst", "pointFilter": "Nach Agent, Auslöser oder Ergebnis filtern", - "pointSteps": "Öffne einen Lauf, um jeden Schritt zu sehen", - "linkDescription": "In der Seitenleiste unter Arbeit." + "pointSteps": "Öffne einen Lauf, um jeden Schritt zu sehen" }, "artifacts": { "title": "Artefakte", @@ -140,8 +139,7 @@ "offNote": "Solange es aus ist, laden Läufe keine Plugins. Deine Plugins und ihre Einstellungen bleiben, wie sie sind.", "pointGit": "Installiere jedes Plugin aus einem Git-Repository", "pointHooks": "Kleine Hooks, die deine Agenten für dich schreiben", - "pointChecked": "Jedes wird geprüft und bleibt aus, bis du es erlaubst", - "linkDescription": "Installieren, einschalten und die Agenten wählen, für die jedes Plugin gedacht ist." + "pointChecked": "Jedes wird geprüft und bleibt aus, bis du es erlaubst" }, "files": { "title": "Dateien", diff --git a/apps/desktop/src/i18n/locales/de/system.json b/apps/desktop/src/i18n/locales/de/system.json index 04475368..525afcde 100644 --- a/apps/desktop/src/i18n/locales/de/system.json +++ b/apps/desktop/src/i18n/locales/de/system.json @@ -22,17 +22,9 @@ "files": { "description": "Was deine Agenten aufbewahren: ihre Dateien, Notizen und ihr Gedächtnis. Sieh es durch, räum auf und hol Gelöschtes zurück." }, - "activity": { - "title": "Verlauf", - "description": "Alles, was deine Agenten gemacht haben." - }, "calls": { "title": "Anrufe" }, - "plugins": { - "title": "Erweiterungen", - "description": "Zusätzliche Fähigkeiten für deine Agenten. Jederzeit hinzufügen, ausschalten oder entfernen." - }, "cards": { "title": "Zahlungskarten" }, diff --git a/apps/desktop/src/i18n/locales/en/activity.json b/apps/desktop/src/i18n/locales/en/activity.json index 81cf2b61..21656746 100644 --- a/apps/desktop/src/i18n/locales/en/activity.json +++ b/apps/desktop/src/i18n/locales/en/activity.json @@ -1,7 +1,5 @@ { "dayFormat": "EEEE, MMM d", - "title": "Activity", - "description": "Every run across your agents.", "runningNow": "Running now", "liveNow": "Live now", "searchPlaceholder": "Search prompts & results…", diff --git a/apps/desktop/src/i18n/locales/en/layout.json b/apps/desktop/src/i18n/locales/en/layout.json index 3393fdba..875e9ac4 100644 --- a/apps/desktop/src/i18n/locales/en/layout.json +++ b/apps/desktop/src/i18n/locales/en/layout.json @@ -28,7 +28,6 @@ "automations": "Automations", "activity": "Activity", "calls": "Calls", - "plugins": "Plugins", "passwords": "Passwords", "cards": "Cards", "browser": "Browser", @@ -97,6 +96,10 @@ "settingsWaitingLabel_other": "Settings ({{count}} waiting in Messaging)", "settingsWaiting_one": "Settings · {{count}} waiting in Messaging", "settingsWaiting_other": "Settings · {{count}} waiting in Messaging", + "settingsPlugins_one": "Settings · {{count}} plugin waits for you", + "settingsPlugins_other": "Settings · {{count}} plugins wait for you", + "settingsPluginsLabel_one": "Settings ({{count}} plugin waits for you)", + "settingsPluginsLabel_other": "Settings ({{count}} plugins wait for you)", "settingsShortcut": "Settings ({{shortcut}})" }, "splash": { diff --git a/apps/desktop/src/i18n/locales/en/mods.json b/apps/desktop/src/i18n/locales/en/mods.json index 775af5bf..db1e658a 100644 --- a/apps/desktop/src/i18n/locales/en/mods.json +++ b/apps/desktop/src/i18n/locales/en/mods.json @@ -1,7 +1,5 @@ { "page": { - "title": "Plugins", - "description": "Give your agents new skills, subagents, commands and guardrails. These are Claude Code plugins: install them from GitHub, add one Godmode made, or write your own — and switch them off, update or remove them any time.", "askPrompt": "Write a Claude Code plugin for Godmode that ", "askGodmode": "Ask Godmode to write one", "addPlugin": "Add plugin", diff --git a/apps/desktop/src/i18n/locales/en/settings.json b/apps/desktop/src/i18n/locales/en/settings.json index 78d26178..1629b84f 100644 --- a/apps/desktop/src/i18n/locales/en/settings.json +++ b/apps/desktop/src/i18n/locales/en/settings.json @@ -78,11 +78,10 @@ "title": "Activity", "titleEasy": "History", "summary": "Every run of every agent in one place: what it did, how long it took and where it failed.", - "offNote": "It leaves the sidebar while it's off. Runs are still recorded, and links to a run still open it.", + "offNote": "Runs are still recorded while it's off, and links to a run still open it.", "pointRuns": "Every run, newest first", "pointFilter": "Filter by agent, trigger or outcome", - "pointSteps": "Open a run to see each step it took", - "linkDescription": "In the sidebar under Work." + "pointSteps": "Open a run to see each step it took" }, "artifacts": { "title": "Artifacts", @@ -140,8 +139,7 @@ "offNote": "Runs load no plugins while it's off. Your plugins and their settings stay as they are.", "pointGit": "Install any plugin from a git repository", "pointHooks": "Small hooks your agents write for you", - "pointChecked": "Each one checked, switched off until you allow it", - "linkDescription": "Install, switch on and pick the agents each plugin is for." + "pointChecked": "Each one checked, switched off until you allow it" }, "files": { "title": "Files", diff --git a/apps/desktop/src/i18n/locales/en/system.json b/apps/desktop/src/i18n/locales/en/system.json index c6f6b21a..f382638f 100644 --- a/apps/desktop/src/i18n/locales/en/system.json +++ b/apps/desktop/src/i18n/locales/en/system.json @@ -22,17 +22,9 @@ "files": { "description": "What your agents keep: their files, notes and memory. Look through it, tidy it up, and put back what you deleted." }, - "activity": { - "title": "History", - "description": "Everything your agents did." - }, "calls": { "title": "Phone calls" }, - "plugins": { - "title": "Add-ons", - "description": "Extra skills for your agents. Add one, switch it off or remove it any time." - }, "cards": { "title": "Payment cards" }, diff --git a/apps/desktop/src/lib/mode.ts b/apps/desktop/src/lib/mode.ts index 06009bde..bdff3246 100644 --- a/apps/desktop/src/lib/mode.ts +++ b/apps/desktop/src/lib/mode.ts @@ -42,27 +42,11 @@ export const EASY_PAGES: Record("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 ( - <> - } - title={t("title")} - description={t("description")} - actions={ - - } - /> - - - {liveList.length > 0 && statusFilter !== "succeeded" && statusFilter !== "failed" && statusFilter !== "cancelled" && statusFilter !== "paused" && ( - -

    - {t("liveNow")} - {liveList.length} -

    -
    - {liveList.map((r) => ( - setParam("run", r.runId)} /> - ))} -
    -
    - )} -
    - -
    -
    - - setSearch(e.target.value)} placeholder={t("searchPlaceholder")} aria-label={t("searchLabel")} className="pl-9" /> -
    - - -
    - -
    - {STATUSES.map((s) => ( - - ))} - {runs.length > 0 && ( - - {successRate != null ? t("countWithSuccess", { count: runs.length, rate: successRate }) : t("count", { count: runs.length })} - - )} -
    - - {runsQ.isLoading ? ( -
    - {Array.from({ length: 8 }, (_, i) => ( -
    - - -
    - - -
    - -
    - ))} -
    - ) : runsQ.isError ? ( - } - title={t("loadFailed")} - description={errorMessage(runsQ.error)} - action={ - - } - /> - ) : runs.length === 0 ? ( - } - title={filtered ? t("noMatch") : t("emptyTitle")} - description={filtered ? undefined : t("emptyDescription")} - action={ - filtered ? ( - - ) : undefined - } - /> - ) : ( -
    - {groups.map((g) => ( -
    -

    {g.label}

    -
    - {g.runs.map((r, i) => ( - - setParam("run", run.id)} /> - - ))} -
    -
    - ))} - {(runsQ.data?.length ?? 0) >= 200 && ( -

    {t("limitHint", { count: 200 })}

    - )} -
    - )} -
    - - !o && setParam("run", null)} /> - - ); -} - -function LiveCard({ live, agent, onOpen }: { live: LiveRun; agent?: Agent; onOpen: () => void }) { - const reduce = useReduceAnimations(); - const { t } = useTranslation(["activity", "common"]); - const now = useNow(true); - return ( - - {agent ? : } -
    -
    - {agent?.name ?? t("common:words.agent")} - -
    -
    {live.activity ?? t("thinking")}
    -
    -
    - {!reduce && } - {formatElapsed(now - live.startedAt)} -
    -
    - ); -} diff --git a/apps/desktop/src/pages/plugins/plugins-page.tsx b/apps/desktop/src/pages/plugins/plugins-page.tsx deleted file mode 100644 index 0d587148..00000000 --- a/apps/desktop/src/pages/plugins/plugins-page.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import { useEffect, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router"; -import { AnimatePresence, motion } from "motion/react"; -import { ChevronDown, FilePlus2, FolderInput, Plus, Puzzle, RefreshCw, WandSparkles } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { modState, modUpdateAvailable, type Mod } from "@godmode/shared"; -import { Button } from "@/components/ui/button"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Spinner } from "@/components/ui/spinner"; -import { EmptyState, PageBody, PageHeader } from "@/components/common"; -import { FeaturedPlugins } from "@/components/mods/featured-plugins"; -import { ModCard } from "@/components/mods/mod-card"; -import { DeleteModDialog, ImportModDialog, InstallPluginDialog, NewModDialog } from "@/components/mods/mod-dialogs"; -import { ModGallery } from "@/components/mods/mod-gallery"; -import { GitHubMark, isModTab, type ModTab } from "@/components/mods/mod-parts"; -import { ModSheet } from "@/components/mods/mod-sheet"; -import { useModActions } from "@/components/mods/use-mod-actions"; -import { errorMessage } from "@/lib/api"; -import { useMods } from "@/lib/hooks"; -import { useReduceAnimations } from "@/lib/motion"; - -const STEPS = [ - { title: "page.steps.install.title", text: "page.steps.install.text" }, - { title: "page.steps.check.title", text: "page.steps.check.text" }, - { title: "page.steps.use.title", text: "page.steps.use.text" }, -] as const; - -/** Claude Code plugins: install from GitHub or the gallery, switch on and off, update, set options, choose who runs them. */ -export default function PluginsPage() { - const reduce = useReduceAnimations(); - const { t } = useTranslation(["mods", "common"]); - const [params, setParams] = useSearchParams(); - const navigate = useNavigate(); - const list = useMods(); - const mods = list.data ?? []; - const actions = useModActions(); - const [sheet, setSheet] = useState<{ id: string; tab: ModTab } | null>(null); - const [newOpen, setNewOpen] = useState(false); - const [importOpen, setImportOpen] = useState(false); - const [installOpen, setInstallOpen] = useState(false); - const [deleteId, setDeleteId] = useState(null); - - const byId = (id: string | null | undefined) => (id ? (mods.find((m) => m.id === id) ?? null) : null); - const open = (id: string, tab: ModTab = "overview") => setSheet({ id, tab }); - - // Deep links from other screens: /plugins?plugin= opens that plugin, &tab=code|contents|options|overview on that tab. - const wantsMod = params.get("plugin"); - const wantsTab = params.get("tab"); - useEffect(() => { - if (!wantsMod || !list.data) return; - if (list.data.some((m) => m.id === wantsMod)) open(wantsMod, isModTab(wantsTab) ? wantsTab : "overview"); - setParams( - (prev) => { - const next = new URLSearchParams(prev); - next.delete("plugin"); - next.delete("tab"); - return next; - }, - { replace: true }, - ); - }, [wantsMod, wantsTab, list.data, setParams]); - - // A plugin freshly created is in the cache before the sheet opens; one that is gone closes it. - const current = byId(sheet?.id); - useEffect(() => { - if (sheet && list.data && !current) setSheet(null); - }, [sheet, list.data, current]); - - const on = mods.filter((m) => modState(m) === "on").length; - const waiting = mods.filter((m) => ["review", "broken"].includes(modState(m))).length; - const fromGit = mods.filter((m) => m.source); - const updates = fromGit.filter(modUpdateAvailable).length; - const lookingAll = actions.checkUpdates.isPending && !actions.checkUpdates.variables; - - return ( -
    - } - title={t("page.title")} - description={t("page.description")} - actions={ - <> - - - - - - - setInstallOpen(true)}> - {t("page.installFromGitHub")} - - - setNewOpen(true)}> - {t("page.writeFromScratch")} - - setImportOpen(true)}> - {t("page.importFolder")} - - - - - } - /> - -
    -
    -
    -

    - {t("page.installed")} -

    - {mods.length > 0 && ( -

    - {t("page.pluginCount", { count: mods.length })} · {t("page.onCount", { n: on })} - {updates > 0 && · {t("page.updatesReady", { count: updates })}} - {waiting > 0 && · {t("page.needLook", { count: waiting })}} -

    - )} -
    - {fromGit.length > 0 && ( - - )} -
    - - {list.isLoading ? ( -
    - {[0, 1].map((i) => ( - - ))} -
    - ) : list.isError ? ( - } - title={t("page.loadError")} - description={errorMessage(list.error)} - action={ - - } - /> - ) : mods.length === 0 ? ( - - {STEPS.map((step, i) => ( -
  • - - {i + 1} - -
    -

    {t(step.title)}

    -

    {t(step.text)}

    -
    -
  • - ))} -
    - ) : ( -
    - - {mods.map((mod, i) => ( - - open(mod.id, tab)} onDelete={() => setDeleteId(mod.id)} /> - - ))} - -
    - )} -
    - - open(mod.id)} /> - - open(mod.id)} onAdded={(mod: Mod) => open(mod.id)} /> -
    - - setSheet((s) => (s ? { ...s, tab } : s))} - onClose={() => setSheet(null)} - onDelete={(mod) => setDeleteId(mod.id)} - actions={actions} - /> - open(mod.id)} /> - open(mod.id, "code")} /> - open(mod.id)} /> - setDeleteId(null)} actions={actions} /> -
    - ); -} diff --git a/apps/desktop/src/pages/settings/settings-page.tsx b/apps/desktop/src/pages/settings/settings-page.tsx index e20f0407..6ac5ad8f 100644 --- a/apps/desktop/src/pages/settings/settings-page.tsx +++ b/apps/desktop/src/pages/settings/settings-page.tsx @@ -43,6 +43,7 @@ import { X, } from "lucide-react"; import { EmptyState } from "@/components/common"; +import { usePluginsWaiting } from "@/components/mods/use-plugins-waiting"; import { AboutSection } from "@/components/settings/about-section"; import { ActivitySection } from "@/components/settings/activity-section"; import { AiSection } from "@/components/settings/ai-section"; @@ -417,19 +418,25 @@ type Marks = Partial>; function useMarks(): Marks { const { t } = useTranslation(["settings", "common"]); const { data: boot } = useBootstrap(); + const plugins = usePluginsWaiting(); if (!boot) return {}; const marks: Marks = {}; for (const feature of FEATURES) if (!featureOn(boot.settings, feature)) marks[FEATURE_SECTION[feature] as SectionId] = {t("common:status.off")}; const requests = featureOn(boot.settings, "messaging") ? boot.counts.messagingRequests : 0; - if (requests) - marks.messaging = ( - - {requests} - - ); + if (requests) marks.messaging = ; + if (plugins) marks.plugins = ; return marks; } +function WaitingMark({ count }: { count: number }) { + const { t } = useTranslation("settings"); + return ( + + {count} + + ); +} + function SectionNav({ active }: { active: SectionId }) { const { t } = useTranslation("settings"); const easy = useEasyMode();