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
4 changes: 3 additions & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -197,6 +198,7 @@ export function App() {
<Route path="/agents/new" element={<AgentNewPage />} />
<Route path="/agents/:agentId/*" element={<AgentDetailPage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/my-tasks" element={<HumanTasksPage />} />
<Route path="/automations" element={<AutomationsPage />} />
<Route path="/routines" element={<Navigate to="/automations" replace />} />
<Route path="/mods" element={<ModsPage />} />
Expand Down
6 changes: 4 additions & 2 deletions apps/desktop/src/components/attention/attention-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -14,6 +14,7 @@ import { WorkspaceTile } from "@/components/workspaces/workspace-tile";

export const ATTENTION_ICON: Record<AttentionItem["kind"], LucideIcon> = {
question: MessageCircleQuestion,
todo: ListTodo,
login: KeyRound,
review: KanbanSquare,
blocked: OctagonAlert,
Expand All @@ -26,6 +27,7 @@ export const ATTENTION_ICON: Record<AttentionItem["kind"], LucideIcon> = {

const TONE: Partial<Record<AttentionItem["kind"], string>> = {
question: "text-warning",
todo: "text-warning",
login: "text-warning",
held: "text-warning",
blocked: "text-rose-600 dark:text-rose-400",
Expand Down Expand Up @@ -113,7 +115,7 @@ function AttentionRow({ item, agent, workspace }: { item: AttentionItem; agent?:
</Button>
</span>
) : (
<Button size="xs" variant={item.kind === "question" || item.kind === "review" ? "default" : "outline"} asChild className="shrink-0">
<Button size="xs" variant={item.kind === "question" || item.kind === "todo" || item.kind === "review" ? "default" : "outline"} asChild className="shrink-0">
<Link to={item.link}>{item.action}</Link>
</Button>
)}
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/components/chat/messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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 <FollowupMarker block={followup} />;
const done = humanTaskBlock(message);
if (done) return <HumanTaskMarker block={done} />;
const retry = message.blocks.find((b) => b.type === "retry");
if (retry) return <RetryMarker block={retry} at={message.createdAt} />;
return (
Expand Down
139 changes: 139 additions & 0 deletions apps/desktop/src/components/human-tasks/human-task-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className={cn("flex min-w-0 items-center gap-1", className)}>
<SquareKanban className="size-3 shrink-0" />
<span className="font-mono tabular-nums">#{task.taskNumber}</span>
</span>
);
}
if (task.conversationTitle) {
return (
<span className={cn("flex min-w-0 items-center gap-1", className)} title={task.conversationTitle}>
<MessagesSquare className="size-3 shrink-0" />
<span className="truncate">{task.conversationTitle}</span>
</span>
);
}
return null;
}

interface CardProps extends HTMLAttributes<HTMLDivElement> {
task: HumanTask;
agent?: Agent;
overlay?: boolean;
ghost?: boolean;
}

export const HumanTaskCard = forwardRef<HTMLDivElement, CardProps>(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 (
<div
ref={ref}
role="button"
tabIndex={0}
aria-label={`${humanTaskRef(task)} ${task.title}`}
className={cn(
"group/card relative rounded-lg border bg-card p-3 text-left shadow-card outline-none select-none",
"transition-[border-color,box-shadow,transform,opacity] duration-150 hover:border-foreground/20 focus-visible:ring-[3px] focus-visible:ring-ring/50",
outcome ? "cursor-pointer" : "cursor-grab touch-none",
task.status === "doing" && "border-brand/30",
overlay && "rotate-[1.5deg] cursor-grabbing border-foreground/25 shadow-xl",
ghost && "opacity-40",
className,
)}
{...rest}
>
<div className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
{outcome ? <outcome.icon className={cn("size-3.5", outcome.tone)} aria-label={outcome.label} /> : null}
<span className="font-mono tabular-nums">{humanTaskRef(task)}</span>
{urgent && (
<span className="flex items-center gap-0.5 rounded-[4px] bg-rose-500/10 px-1 text-[11px] font-medium text-rose-600 dark:text-rose-400">
<Flag className="size-2.5 fill-current" /> Urgent
</span>
)}
{task.status === "doing" && <span className="rounded-[4px] bg-brand-soft px-1 text-[11px] font-medium text-brand-strong">On it</span>}
<span className="ml-auto font-mono text-[11px] tabular-nums">{shortAge(task.closedAt ?? task.createdAt)}</span>
</div>

<p className={cn("mt-1.5 line-clamp-3 text-[13.5px] leading-snug font-medium tracking-[-0.01em]", outcome ? "text-foreground/70" : "text-foreground")}>{task.title}</p>
{preview && <p className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">{preview}</p>}

{task.url && !outcome && (
<span className="mt-2 inline-flex max-w-full items-center gap-1 rounded-[5px] border bg-background px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
<span className="truncate">{hostOf(task.url)}</span>
<ArrowUpRight className="size-3 shrink-0 text-muted-foreground" />
</span>
)}

<div className="mt-3 flex items-center gap-2 text-xs">
{agent ? (
<span className="flex min-w-0 items-center gap-1.5 text-foreground/80">
<AgentAvatar agent={agent} size="sm" still className="size-5 rounded-[5px] text-[11px]" />
<span className="truncate">{agent.name}</span>
</span>
) : (
<span className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
<span className="grid size-5 place-items-center rounded-[5px] border bg-secondary">
<UserRound className="size-3" />
</span>
<span className="truncate">{own ? "Your own" : (task.agentName ?? "Agent removed")}</span>
</span>
)}
<OriginLabel task={task} className="ml-auto max-w-[45%] text-[11px] text-muted-foreground" />
</div>
</div>
);
});
93 changes: 93 additions & 0 deletions apps/desktop/src/components/human-tasks/human-task-chat.tsx
Original file line number Diff line number Diff line change
@@ -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<MessageBlock, { type: "human_task" }>;

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 (
<div role="note" className="flex flex-col items-center gap-1.5 text-center">
<div className="flex w-full items-center gap-3 text-[11px] text-muted-foreground">
<span className="h-px flex-1 bg-border" />
<span className="inline-flex min-w-0 items-center gap-1.5">
<Icon className={done ? "size-3.5 text-emerald-600 dark:text-emerald-400" : "size-3.5 text-rose-600 dark:text-rose-400"} aria-hidden />
<span className="font-medium text-foreground">{done ? "You did" : "You couldn't do"}</span>
<span className="truncate">
{humanTaskRef(block)} · {block.title}
</span>
</span>
<span className="h-px flex-1 bg-border" />
</div>
{block.note && <p className="max-w-[85%] text-[13px] text-balance whitespace-pre-wrap text-muted-foreground">{block.note}</p>}
</div>
);
}

/** 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 (
<div className="mb-2 space-y-1.5">
{data.slice(0, 3).map((t) => (
<Row key={t.id} task={t} agentName={agentName} />
))}
{data.length > 3 && (
<Link to="/my-tasks" className="block px-1 text-xs text-muted-foreground hover:text-foreground">
+{data.length - 3} more under My tasks
</Link>
)}
</div>
);
}

function Row({ task, agentName }: { task: HumanTask; agentName: string }) {
const { close } = useHumanTaskActions();
return (
<div className="flex items-center gap-3 rounded-xl border bg-card py-2 pr-2 pl-2.5 shadow-card">
<span className="grid size-8 shrink-0 place-items-center rounded-lg border border-warning/30 bg-warning/10 text-warning">
<ListTodo className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1 leading-snug">
<p className="truncate text-[13px]">
<span className="font-medium">Waiting for you</span>
<span className="text-muted-foreground"> · {humanTaskRef(task)}</span>
</p>
<p className="truncate text-xs text-muted-foreground" title={task.title}>
{task.title}
</p>
</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>
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button size="xs" variant="outline" disabled={close.isPending} onClick={() => close.mutate({ id: task.id, outcome: "done", note: "", files: [] })}>
{close.isPending ? <Spinner /> : <Check />} Done
</Button>
</TooltipTrigger>
<TooltipContent side="top">Mark it done — {agentName} continues</TooltipContent>
</Tooltip>
</div>
</div>
);
}
Loading
Loading