diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 19344931..a62928db 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -29,6 +29,7 @@ const ModsPage = lazy(() => import("@/pages/mods/mods-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 IntegrationsPage = lazy(() => import("@/pages/integrations/integrations-page")); @@ -131,7 +132,7 @@ export function App() { id: n.link ? `question:${n.link}` : undefined, description: n.body || undefined, duration: 20_000, - action: n.link ? { label: "Answer", onClick: () => navigate(n.link!) } : undefined, + action: n.link ? { label: n.link.startsWith("/my-tasks") ? "Open" : "Answer", onClick: () => navigate(n.link!) } : undefined, }); const desktopOn = qc.getQueryData<{ settings?: { general?: { desktopNotifications?: boolean } } }>(qk.bootstrap)?.settings?.general ?.desktopNotifications; @@ -197,6 +198,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/components/attention/attention-list.tsx b/apps/desktop/src/components/attention/attention-list.tsx index fc6b005f..b91e6ddc 100644 --- a/apps/desktop/src/components/attention/attention-list.tsx +++ b/apps/desktop/src/components/attention/attention-list.tsx @@ -2,7 +2,7 @@ import { Link } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { formatDistanceToNowStrict } from "date-fns"; -import { AlertTriangle, Coins, KanbanSquare, KeyRound, MessageCircleQuestion, OctagonAlert, Pause, Play, UserPlus, Workflow, type LucideIcon } from "lucide-react"; +import { AlertTriangle, Coins, KanbanSquare, KeyRound, ListTodo, MessageCircleQuestion, OctagonAlert, Pause, Play, UserPlus, Workflow, type LucideIcon } from "lucide-react"; import type { Agent, AttentionItem, Workspace } from "@godmode/shared"; import { api, errorMessage } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; @@ -14,6 +14,7 @@ import { WorkspaceTile } from "@/components/workspaces/workspace-tile"; export const ATTENTION_ICON: Record = { question: MessageCircleQuestion, + todo: ListTodo, login: KeyRound, review: KanbanSquare, blocked: OctagonAlert, @@ -26,6 +27,7 @@ export const ATTENTION_ICON: Record = { const TONE: Partial> = { question: "text-warning", + todo: "text-warning", login: "text-warning", held: "text-warning", blocked: "text-rose-600 dark:text-rose-400", @@ -113,7 +115,7 @@ function AttentionRow({ item, agent, workspace }: { item: AttentionItem; agent?: ) : ( - )} diff --git a/apps/desktop/src/components/chat/messages.tsx b/apps/desktop/src/components/chat/messages.tsx index b42a06ae..1546f24d 100644 --- a/apps/desktop/src/components/chat/messages.tsx +++ b/apps/desktop/src/components/chat/messages.tsx @@ -22,6 +22,7 @@ import { AttachmentList } from "./attachments"; import { useAllAgents, useBootstrap, useTasks } from "@/lib/hooks"; import { describeTool } from "./tool-meta"; import { FollowupMarker, followupBlock } from "./followup"; +import { HumanTaskMarker, humanTaskBlock } from "@/components/human-tasks/human-task-chat"; import { liveMood } from "./conversation-mood"; const FALLBACK_AGENT = { avatar: "πŸ€–", color: "violet" }; @@ -155,6 +156,8 @@ export function StartedMessage({ message, delegatedFrom }: { message: Message; d export function SystemMessage({ message }: { message: Message }) { const followup = followupBlock(message); if (followup) return ; + const done = humanTaskBlock(message); + if (done) return ; const retry = message.blocks.find((b) => b.type === "retry"); if (retry) return ; return ( diff --git a/apps/desktop/src/components/human-tasks/human-task-card.tsx b/apps/desktop/src/components/human-tasks/human-task-card.tsx new file mode 100644 index 00000000..44dddf67 --- /dev/null +++ b/apps/desktop/src/components/human-tasks/human-task-card.tsx @@ -0,0 +1,139 @@ +import { forwardRef, type HTMLAttributes } from "react"; +import { formatDistanceToNowStrict } from "date-fns"; +import { ArrowUpRight, CircleCheck, CircleSlash, Flag, MessagesSquare, SquareKanban, Undo2, UserRound } from "lucide-react"; +import type { Agent, HumanTask } from "@godmode/shared"; +import { humanTaskRef } from "@godmode/shared"; +import { AgentAvatar } from "@/components/common"; +import { cn } from "@/lib/utils"; + +export function hostOf(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** The first lines of the instructions, without Markdown marks. */ +export function plainPreview(body: string, max = 160): string { + const text = body + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, "") + .replace(/[#>*_`~]/g, "") + .split(/\n+/) + .map((l) => l.trim()) + .filter(Boolean) + .map((l, i, all) => (i < all.length - 1 && !/[.!?:;,]$/.test(l) ? `${l}.` : l)) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +export function shortAge(iso: string): string { + return formatDistanceToNowStrict(new Date(iso), { roundingMethod: "floor" }).replace(/ (\w)\w*$/, "$1"); +} + +const OUTCOME = { + done: { icon: CircleCheck, label: "Done", tone: "text-emerald-600 dark:text-emerald-400" }, + declined: { icon: CircleSlash, label: "Couldn't do it", tone: "text-rose-600 dark:text-rose-400" }, + withdrawn: { icon: Undo2, label: "Taken back", tone: "text-muted-foreground" }, +} as const; + +export function outcomeOf(task: HumanTask) { + return task.status === "done" || task.status === "declined" || task.status === "withdrawn" ? OUTCOME[task.status] : null; +} + +/** Where the task came from: the ticket or chat it is for, or the human's own list. */ +export function OriginLabel({ task, className }: { task: HumanTask; className?: string }) { + if (task.taskNumber != null) { + return ( + + + #{task.taskNumber} + + ); + } + if (task.conversationTitle) { + return ( + + + {task.conversationTitle} + + ); + } + return null; +} + +interface CardProps extends HTMLAttributes { + task: HumanTask; + agent?: Agent; + overlay?: boolean; + ghost?: boolean; +} + +export const HumanTaskCard = forwardRef(function HumanTaskCard({ task, agent, overlay, ghost, className, ...rest }, ref) { + const outcome = outcomeOf(task); + const preview = outcome ? task.response?.text || task.closedReason || "" : plainPreview(task.body); + const urgent = task.priority === "high" && !outcome; + const own = !task.agentId && !task.runId; + return ( +
+
+ {outcome ? : null} + {humanTaskRef(task)} + {urgent && ( + + Urgent + + )} + {task.status === "doing" && On it} + {shortAge(task.closedAt ?? task.createdAt)} +
+ +

{task.title}

+ {preview &&

{preview}

} + + {task.url && !outcome && ( + + {hostOf(task.url)} + + + )} + +
+ {agent ? ( + + + {agent.name} + + ) : ( + + + + + {own ? "Your own" : (task.agentName ?? "Agent removed")} + + )} + +
+
+ ); +}); diff --git a/apps/desktop/src/components/human-tasks/human-task-chat.tsx b/apps/desktop/src/components/human-tasks/human-task-chat.tsx new file mode 100644 index 00000000..ff4a2707 --- /dev/null +++ b/apps/desktop/src/components/human-tasks/human-task-chat.tsx @@ -0,0 +1,93 @@ +import { Link } from "react-router"; +import { useQuery } from "@tanstack/react-query"; +import { Check, CircleCheck, CircleSlash, ListTodo } from "lucide-react"; +import type { HumanTask, Message, MessageBlock } from "@godmode/shared"; +import { humanTaskRef } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { api } from "@/lib/api"; +import { qk } from "@/lib/queryKeys"; +import { useHumanTaskActions } from "./human-task-sheet"; + +type HumanTaskBlock = Extract; + +export function humanTaskBlock(message: Message): HumanTaskBlock | null { + return (message.blocks.find((b) => b.type === "human_task") as HumanTaskBlock | undefined) ?? null; +} + +/** Where the human closed a task the agent gave them, and the chat went on. */ +export function HumanTaskMarker({ block }: { block: HumanTaskBlock }) { + const done = block.outcome === "done"; + const Icon = done ? CircleCheck : CircleSlash; + return ( +
+
+ + + + {done ? "You did" : "You couldn't do"} + + {humanTaskRef(block)} Β· {block.title} + + + +
+ {block.note &&

{block.note}

} +
+ ); +} + +/** Above the composer while the agent waits for the human to do something. */ +export function HumanTaskBar({ conversationId, agentName }: { conversationId: string; agentName: string }) { + const { data = [] } = useQuery({ + queryKey: [...qk.humanTasks, "chat", conversationId], + queryFn: () => api.humanTasks.list({ status: "active", conversationId }), + }); + if (!data.length) return null; + return ( +
+ {data.slice(0, 3).map((t) => ( + + ))} + {data.length > 3 && ( + + +{data.length - 3} more under My tasks + + )} +
+ ); +} + +function Row({ task, agentName }: { task: HumanTask; agentName: string }) { + const { close } = useHumanTaskActions(); + return ( +
+ + + +
+

+ Waiting for you + Β· {humanTaskRef(task)} +

+

+ {task.title} +

+
+
+ + + + + + Mark it done β€” {agentName} continues + +
+
+ ); +} diff --git a/apps/desktop/src/components/human-tasks/human-task-sheet.tsx b/apps/desktop/src/components/human-tasks/human-task-sheet.tsx new file mode 100644 index 00000000..8f8623a7 --- /dev/null +++ b/apps/desktop/src/components/human-tasks/human-task-sheet.tsx @@ -0,0 +1,374 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Link } from "react-router"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { format, formatDistanceToNowStrict } from "date-fns"; +import { toast } from "sonner"; +import { ArrowUpRight, Check, CircleSlash, EllipsisVertical, Flag, MessagesSquare, Paperclip, Play, SquareKanban, Trash2, Undo2 } from "lucide-react"; +import type { Agent, HumanTask, HumanTaskCloseResult, HumanTaskPatch } from "@godmode/shared"; +import { humanTaskRef, isHumanTaskActive } from "@godmode/shared"; +import { AgentAvatar } from "@/components/common"; +import { Markdown } from "@/components/chat/markdown"; +import { AttachmentTray, readAttachments, totalBytes, type PendingAttachment } from "@/components/chat/attachments"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { toastApiError } from "@/components/vault/vault-utils"; +import { api } from "@/lib/api"; +import { modKey, openExternal } from "@/lib/desktop"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { hostOf, outcomeOf } from "./human-task-card"; + +/** Tell the human what happened with the agent once they closed a task. */ +export function toastClosed(result: HumanTaskCloseResult, agentName: string | null) { + const done = result.task.status === "done"; + const title = done ? `${humanTaskRef(result.task)} done` : `${humanTaskRef(result.task)} closed`; + if (result.continued) toast.success(title, { description: `${agentName ?? "The agent"} picks it up from here.` }); + else if (result.notContinued) toast.warning(title, { description: result.notContinued }); + else toast.success(title); +} + +export function useHumanTaskActions() { + const qc = useQueryClient(); + const refresh = () => { + void qc.invalidateQueries({ queryKey: qk.humanTasks }); + void qc.invalidateQueries({ queryKey: qk.bootstrap }); + }; + const update = useMutation({ + mutationFn: ({ id, patch }: { id: string; patch: HumanTaskPatch }) => api.humanTasks.update(id, patch), + onMutate: ({ id, patch }) => { + qc.setQueryData(qk.humanTasks, (list) => list?.map((t) => (t.id === id ? { ...t, ...(patch.status ? { status: patch.status } : {}), ...(patch.priority ? { priority: patch.priority } : {}) } : t))); + }, + onSettled: refresh, + onError: (e) => toastApiError(e, "Couldn't update the task", qc), + }); + const close = useMutation({ + mutationFn: ({ id, outcome, note, files }: { id: string; outcome: "done" | "declined"; note: string; files: PendingAttachment[] }) => + api.humanTasks.close(id, { outcome, note: note || undefined, attachments: files.length ? files.map((f) => ({ name: f.name, mime: f.mime, data: f.data })) : undefined }), + onSuccess: (result) => toastClosed(result, result.task.agentName ?? null), + onSettled: refresh, + onError: (e) => toastApiError(e, "Couldn't close the task", qc), + }); + const remove = useMutation({ + mutationFn: (id: string) => api.humanTasks.delete(id), + onSuccess: () => toast.success("Task deleted"), + onSettled: refresh, + onError: (e) => toastApiError(e, "Couldn't delete the task", qc), + }); + return { update, close, remove }; +} + +export function HumanTaskSheet({ + task, + agent, + focusNote, + onClose, +}: { + task: HumanTask | null; + agent?: Agent; + /** Open with the cursor in the note (dropped on Done). */ + focusNote?: boolean; + onClose: () => void; +}) { + return ( + !open && onClose()}> + { + if (focusNote) return; + e.preventDefault(); + (e.currentTarget as HTMLElement | null)?.focus(); + }} + > + {task && } + + + ); +} + +function Detail({ task, agent, focusNote, onClose }: { task: HumanTask; agent?: Agent; focusNote?: boolean; onClose: () => void }) { + const { update, close, remove } = useHumanTaskActions(); + const active = isHumanTaskActive(task); + const outcome = outcomeOf(task); + const own = !task.agentId && !task.runId; + const name = agent?.name ?? task.agentName ?? null; + + return ( +
+
+ {humanTaskRef(task)} + + {active && task.priority === "high" && ( + + Urgent + + )} +
+ + + + + + {task.conversationId && ( + + + Open chat + + + )} + {task.taskId && ( + + + Open task #{task.taskNumber} + + + )} + {active && ( + update.mutate({ id: task.id, patch: { priority: task.priority === "high" ? "normal" : "high" } })}> + {task.priority === "high" ? "Not urgent" : "Mark urgent"} + + )} + {(own || !active) && ( + <> + + { + remove.mutate(task.id); + onClose(); + }} + > + Delete + + + )} + + + +
+
+ +
+
+
+ {task.title} + +
+ {own ? ( + Your own task + ) : ( + + {agent && } + {name ?? "An agent that was removed"} + + )} + Β· + + {task.taskNumber != null ? ( + <> + Β· + + for task #{task.taskNumber} + + + ) : task.conversationId && task.conversationTitle ? ( + <> + Β· + + {task.conversationTitle} + + + ) : null} +
+
+
+ + {task.url && ( + + )} + + {task.body ? ( +
+

{own ? "Notes" : "What to do"}

+ {task.body} +
+ ) : null} + + {outcome && ( +
+

+ + {task.status === "withdrawn" ? `${name ?? "The agent"} took it back` : outcome.label} + {task.closedAt && Β· {formatDistanceToNowStrict(new Date(task.closedAt), { addSuffix: true })}} +

+ {(task.response?.text || task.closedReason) &&

{task.response?.text || task.closedReason}

} + {!!task.response?.attachments.length && ( +

+ {task.response.attachments.map((a) => a.name).join(", ")} +

+ )} +
+ )} +
+
+ + {active && update.mutate({ id: task.id, patch: { status: "doing" } })} onBack={() => update.mutate({ id: task.id, patch: { status: "open" } })} onClose={(outcome, note, files) => close.mutate({ id: task.id, outcome, note, files }, { onSuccess: onClose })} />} +
+ ); +} + +function StatusPill({ task }: { task: HumanTask }) { + const meta: Record = { + open: { label: "To do", className: "bg-foreground/[0.06] text-foreground/80" }, + doing: { label: "Doing", className: "bg-brand-soft text-brand-strong" }, + done: { label: "Done", className: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" }, + declined: { label: "Couldn't do it", className: "bg-rose-500/10 text-rose-700 dark:text-rose-300" }, + withdrawn: { label: "Taken back", className: "bg-foreground/[0.06] text-muted-foreground" }, + }; + const m = meta[task.status]; + return {m.label}; +} + +function CloseBox({ + task, + agentName, + focus, + pending, + onStart, + onBack, + onClose, +}: { + task: HumanTask; + /** null: the human's own task, nobody continues. */ + agentName: string | null; + focus?: boolean; + pending: boolean; + onStart: () => void; + onBack: () => void; + onClose: (outcome: "done" | "declined", note: string, files: PendingAttachment[]) => void; +}) { + const [note, setNote] = useState(""); + const [files, setFiles] = useState([]); + const input = useRef(null); + const area = useRef(null); + useEffect(() => { + if (focus) setTimeout(() => area.current?.focus(), 120); + }, [focus]); + const add = async (picked: File[]) => { + const read = await readAttachments(picked, totalBytes(files), toast.error); + if (read.length) setFiles((list) => [...list, ...read]); + }; + const drop = (f: PendingAttachment) => { + if (f.previewUrl) URL.revokeObjectURL(f.previewUrl); + setFiles((list) => list.filter((x) => x.id !== f.id)); + }; + const finish = (outcome: "done" | "declined") => !pending && onClose(outcome, note.trim(), files); + + return ( +
+ {agentName && ( +

+ {agentName} continues by itself once you close this. Anything it should know goes in the note. +

+ )} +
+ {files.length > 0 && ( + { + files.forEach((f) => f.previewUrl && URL.revokeObjectURL(f.previewUrl)); + setFiles([]); + }} + /> + )} +
+ {agentName && ( + <> + + { + void add([...(e.target.files ?? [])]); + e.target.value = ""; + }} + /> + + )} +