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: 8 additions & 2 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ 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 HumanTasksPage = lazy(() => import("@/pages/human-tasks/human-tasks-page"));
const LoginsPage = lazy(() => import("@/pages/vault/logins-page"));
const TotpPage = lazy(() => import("@/pages/vault/totp-page"));
const CardsPage = lazy(() => import("@/pages/vault/cards-page"));
Expand Down Expand Up @@ -210,7 +209,7 @@ export function App() {
<Route path="/files" element={<Navigate to="/settings/files" replace />} />
<Route path="/files/:agentId" element={<FilesPage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/my-tasks" element={<HumanTasksPage />} />
<Route path="/my-tasks" element={<MyTasksRedirect />} />
<Route path="/automations" element={<AutomationsPage />} />
<Route path="/routines" element={<Navigate to="/automations" replace />} />
<Route path="/plugins" element={<PluginsPage />} />
Expand Down Expand Up @@ -275,6 +274,13 @@ function NotFoundRedirect() {
}

/** Links from before Mods were Plugins (notifications, chat notes): /mods?mod=<id> → /plugins?plugin=<id>. */
/** My tasks became the Inbox's board: old links (notifications, tickets) open the same task there. */
function MyTasksRedirect() {
const params = new URLSearchParams(useLocation().search);
if (!params.has("task")) params.set("view", "board");
return <Navigate to={`/inbox?${params}`} replace />;
}

function LegacyModsRedirect() {
const { search } = useLocation();
const params = new URLSearchParams(search);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useSearchParams } from "react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
DndContext,
Expand All @@ -18,16 +17,16 @@ import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, v
import { CSS } from "@dnd-kit/utilities";
import { Circle, CircleCheck, CircleDashed, ListTodo, Plus, Search, Timer } from "lucide-react";
import type { Agent, HumanTask, HumanTaskLane } from "@godmode/shared";
import { MASCOT_CHARACTER, MASCOT_COLOR, isHumanTaskActive, isHumanTaskWaiting } from "@godmode/shared";
import { EmptyState, PageHeader } from "@/components/common";
import { MASCOT_CHARACTER, MASCOT_COLOR, isHumanTaskActive } from "@godmode/shared";
import { EmptyState } from "@/components/common";
import { Character } from "@/components/character";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HumanTaskCard } from "@/components/human-tasks/human-task-card";
import { HumanTaskSheet, useHumanTaskActions } from "@/components/human-tasks/human-task-sheet";
import { useHumanTaskActions } from "@/components/human-tasks/human-task-sheet";
import { toastApiError } from "@/components/vault/vault-utils";
import { api, errorMessage } from "@/lib/api";
import { useAllAgents, useBootstrap, useHumanTasks } from "@/lib/hooks";
Expand Down Expand Up @@ -70,68 +69,39 @@ function columnOf(id: string, cols: Columns): ColumnId | null {
return (Object.keys(cols) as ColumnId[]).find((c) => cols[c].includes(id)) ?? null;
}

export default function HumanTasksPage() {
/** My tasks as a board (Backlog, To do, Doing, Done): the Inbox's Board view. Opening a card is up to the page (it holds the sheet). */
export function HumanTaskBoard({
adding,
onAdding,
onOpen,
}: {
adding: "backlog" | "open" | null;
onAdding: (to: "backlog" | "open" | null) => void;
onOpen: (t: HumanTask, focusNote?: boolean) => void;
}) {
const qc = useQueryClient();
const [params, setParams] = useSearchParams();
const tasksQ = useHumanTasks();
const { data = [] } = tasksQ;
const { data: boot } = useBootstrap();
const { data: agents = [] } = useAllAgents();
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
const [search, setSearch] = useState("");
const [focusNote, setFocusNote] = useState(false);
const [adding, setAdding] = useState<"backlog" | "open" | null>(null);
const { update } = useHumanTaskActions();

const tasks = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return data;
return data.filter((t) => `H-${t.number} ${t.title} ${t.body} ${t.agentName ?? ""} ${t.conversationTitle ?? ""}`.toLowerCase().includes(q));
}, [data, search]);
const openId = params.get("task");
const selected = openId ? (data.find((t) => t.id === openId) ?? null) : null;
const open = (t: HumanTask, note = false) => {
setFocusNote(note);
const p = new URLSearchParams(params);
p.set("task", t.id);
setParams(p, { replace: true });
};
const closeSheet = () => {
const p = new URLSearchParams(params);
p.delete("task");
setParams(p, { replace: true });
setFocusNote(false);
};

const create = useMutation({
mutationFn: ({ title, status }: { title: string; status: "backlog" | "open" }) => api.humanTasks.create({ title, status }),
onSuccess: () => void qc.invalidateQueries({ queryKey: qk.humanTasks }),
onError: (e) => toastApiError(e, "Couldn't add the task", qc),
});

const waiting = data.filter((t) => t.status === "open").length;
const agentsWaiting = new Set(data.filter((t) => isHumanTaskWaiting(t) && t.agentId).map((t) => t.agentId)).size;
const parked = data.filter((t) => t.status === "backlog").length;

return (
<div className="flex h-full min-h-0 flex-col">
<PageHeader
icon={<ListTodo />}
title="My tasks"
description={
waiting
? `${waiting} waiting for you${agentsWaiting ? ` · ${agentsWaiting} agent${agentsWaiting === 1 ? "" : "s"} on hold until you're done` : ""}${parked ? ` · ${parked} in the backlog` : ""}. Close or answer one and its agent picks up where it left off.`
: parked
? `Nothing waits for you right now · ${parked} in the backlog for later. Close or answer one and its agent picks up where it left off.`
: "What your agents can't settle without you — a passkey, a CAPTCHA, a question, a decision. Close or answer it and the agent picks up where it left off."
}
actions={
<Button onClick={() => setAdding("open")} disabled={tasksQ.isError && !tasksQ.data}>
<Plus /> New task
</Button>
}
/>

<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-2 px-5 pb-4 @2xl:px-8">
<div className="relative w-64">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
Expand Down Expand Up @@ -167,7 +137,7 @@ export default function HumanTasksPage() {
title="Nothing waits for you"
description="When an agent can't go on without you — a passkey, a CAPTCHA, a question, a decision it left open — it lands here and you are told. Once you close or answer it, the agent continues by itself."
action={
<Button variant="outline" onClick={() => setAdding("open")}>
<Button variant="outline" onClick={() => onAdding("open")}>
<Plus /> Add one of your own
</Button>
}
Expand All @@ -179,15 +149,13 @@ export default function HumanTasksPage() {
agents={agentById}
loading={tasksQ.isPending ? { backlog: 0, open: Math.min(boot?.counts.attention?.todo ?? 2, 4), doing: 0, closed: 2 } : undefined}
adding={adding}
onAdding={setAdding}
onAdding={onAdding}
onAdd={(title, status) => create.mutateAsync({ title, status })}
onOpen={(t) => open(t)}
onOpen={(t) => onOpen(t)}
onMove={(t, status, beforeId) => update.mutate({ id: t.id, patch: { status, beforeId } })}
onDropDone={(t) => open(t, true)}
onDropDone={(t) => onOpen(t, true)}
/>
)}

<HumanTaskSheet task={selected} agent={selected?.agentId ? agentById.get(selected.agentId) : undefined} focusNote={focusNote} onClose={closeSheet} />
</div>
);
}
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/components/human-tasks/human-task-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ export function HumanTaskBar({ conversationId, agentName }: { conversationId: st
<Row key={t.id} task={t} agentName={agentName} />
))}
{tasks.length > 3 && (
<Link to="/my-tasks" className="block px-1 text-xs text-muted-foreground hover:text-foreground">
+{tasks.length - 3} more under My tasks
<Link to="/inbox?view=board" className="block px-1 text-xs text-muted-foreground hover:text-foreground">
+{tasks.length - 3} more in your Inbox
</Link>
)}
</div>
Expand Down Expand Up @@ -85,7 +85,7 @@ function Row({ task, agentName }: { task: HumanTask; agentName: string }) {
</div>
<div className="flex shrink-0 items-center gap-0.5">
<Button size="xs" variant="ghost" asChild>
<Link to={`/my-tasks?task=${task.id}`}>Open</Link>
<Link to={`/inbox?task=${task.id}`}>Open</Link>
</Button>
{decision ? (
<Tooltip>
Expand Down
10 changes: 2 additions & 8 deletions apps/desktop/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
MonitorUp,
Inbox,
KeyRound,
ListTodo,
Lock,
MessageCircle,
MessageSquarePlus,
Expand All @@ -36,7 +35,7 @@ import {
Workflow,
X,
} from "lucide-react";
import { isHumanTaskWaiting, modState, modUpdateAvailable } from "@godmode/shared";
import { modState, modUpdateAvailable } from "@godmode/shared";
import {
Sidebar,
SidebarContent,
Expand Down Expand Up @@ -73,7 +72,7 @@ import { PageScrollContext } from "@/components/layout/page-scroll";
import { Callout } from "@/components/settings/settings-kit";
import { LicenseBanner } from "@/components/license/license-banner";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useBootstrap, useHumanTasks, useMods } from "@/lib/hooks";
import { useBootstrap, useMods } from "@/lib/hooks";
import { useEasyMode } from "@/lib/mode";
import { api } from "@/lib/api";
import { qk } from "@/lib/queryKeys";
Expand Down Expand Up @@ -130,9 +129,6 @@ export function AppShell({ children }: { children: ReactNode }) {
const attention = boot?.counts.attention;
const waiting = attention?.total ?? (boot?.counts.openQuestions ?? 0) + (boot?.counts.openMissingLogins ?? 0);
const inboxCount = waiting || (boot?.counts.unreadNotifications ?? 0);
// Counted from the list the board shows, so the two can't disagree (and the board is loaded before it is opened).
const humanTasks = useHumanTasks();
const myTasks = humanTasks.data && !humanTasks.isError ? humanTasks.data.filter(isHumanTaskWaiting).length : attention?.todo;
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;
Expand Down Expand Up @@ -190,7 +186,6 @@ export function AppShell({ children }: { children: ReactNode }) {

const workNav: NavItem[] = [
{ to: "/inbox", label: "Inbox", icon: <Inbox />, badge: inboxCount || undefined },
{ to: "/my-tasks", label: "My tasks", icon: <ListTodo />, badge: myTasks || undefined },
{ to: "/tasks", label: "Tasks", icon: <SquareKanban />, badge: (attention?.review ?? 0) + (attention?.blocked ?? 0) || undefined },
{ to: "/agents", label: "Agents", icon: <Bot />, badge: runningCount || undefined },
{ to: "/artifacts", label: "Artifacts", icon: <AppWindow /> },
Expand Down Expand Up @@ -220,7 +215,6 @@ export function AppShell({ children }: { children: ReactNode }) {
const easyItem = (to: string, label: string): NavItem => ({ ...byPath.get(to)!, label });
const easyNav: NavItem[] = [
easyItem("/inbox", "Inbox"),
easyItem("/my-tasks", "My to-dos"),
easyItem("/agents", "Agents"),
easyItem("/automations", "Automations"),
easyItem("/artifacts", "Dashboards & apps"),
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/layout/shortcuts-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useUi } from "@/stores/ui";
export const GO_TO: readonly { key: string; to: string; label: string }[] = [
{ key: "c", to: "/", label: "Chat" },
{ key: "t", to: "/tasks", label: "Tasks" },
{ key: "m", to: "/my-tasks", label: "My tasks" },
{ key: "m", to: "/inbox?view=board", label: "My tasks board" },
{ key: "a", to: "/agents", label: "Agents" },
{ key: "u", to: "/automations", label: "Automations" },
{ key: "i", to: "/inbox", label: "Inbox" },
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/lib/mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export function useInterfaceMode(): InterfaceMode {

/** Easy mode titles and descriptions for the top-level pages, by path (the sidebar uses the same names). */
export const EASY_PAGES: Record<string, { title?: string; description?: string }> = {
"/my-tasks": { title: "My to-dos" },
"/artifacts": { title: "Dashboards & apps" },
"/tasks": { title: "Task board" },
"/automations": { description: "Let your agents start work by themselves: on a schedule, or when something happens in your apps." },
Expand Down
Loading
Loading