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
23 changes: 16 additions & 7 deletions apps/desktop/src/components/tasks/task-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import {
import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Link } from "react-router";
import { Archive, Check, ChevronsLeftRight, MessageSquareReply, MessagesSquare, PanelRightOpen, Play, Plus, RotateCcw, RotateCw, Trash2 } from "lucide-react";
import { Archive, Check, ChevronsLeftRight, GitMerge, MessageSquareReply, MessagesSquare, PanelRightOpen, Play, Plus, RotateCcw, RotateCw, Trash2 } from "lucide-react";
import type { Agent, Task, TaskStatus, Workspace } from "@godmode/shared";
import { reopenStatus } from "@godmode/shared";
import { mergesOnApprove, reopenStatus } from "@godmode/shared";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuShortcut, ContextMenuTrigger } from "@/components/ui/context-menu";
Expand Down Expand Up @@ -57,7 +57,8 @@ export interface TaskBoardProps {
/** Given when the board spans several workspaces: cards show theirs. */
workspaces?: Map<string, Workspace>;
onOpen: (task: Task) => void;
onMove: (task: Task, status: TaskStatus, beforeId: string | null) => void;
/** merge: Approve — its open pull request is merged first. */
onMove: (task: Task, status: TaskStatus, beforeId: string | null, merge?: boolean) => void;
onQuickAdd: (status: TaskStatus, title: string) => Promise<unknown>;
onArchive: (tasks: Task[]) => void;
onDelete: (task: Task) => void;
Expand Down Expand Up @@ -220,7 +221,7 @@ function Column({
dragging: boolean;
activeId: string | null;
onOpen: (task: Task) => void;
onMove: (task: Task, status: TaskStatus, beforeId: string | null) => void;
onMove: (task: Task, status: TaskStatus, beforeId: string | null, merge?: boolean) => void;
onArchive: (tasks: Task[]) => void;
onDelete: (task: Task) => void;
onCollapse: () => void;
Expand Down Expand Up @@ -346,7 +347,7 @@ function SortableCard({
workspace?: Workspace | null;
ghost: boolean;
onOpen: (task: Task) => void;
onMove: (task: Task, status: TaskStatus, beforeId: string | null) => void;
onMove: (task: Task, status: TaskStatus, beforeId: string | null, merge?: boolean) => void;
onArchive: (tasks: Task[]) => void;
onDelete: (task: Task) => void;
}) {
Expand Down Expand Up @@ -396,8 +397,16 @@ function SortableCard({
{task.status === "in_review" && (
<>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onMove(task, "done", null)}>
<Check /> Approve
<ContextMenuItem onSelect={() => onMove(task, "done", null, true)}>
{mergesOnApprove(task) ? (
<>
<GitMerge /> Approve &amp; merge
</>
) : (
<>
<Check /> Approve
</>
)}
</ContextMenuItem>
{task.agentId && task.conversationId && (
<ContextMenuItem onSelect={reply}>
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/components/tasks/task-meta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ export function waitingLabel(task: Task): string | null {
return isWaiting(task) && task.followup ? `Waiting — continues ${followupWhen(task.followup.dueAt)}` : null;
}

/** The approve mutation (tasks page); its variables are the ticket being approved. */
export const APPROVE_KEY = ["tasks", "approve"] as const;

/** Moving the ticket away from In progress would end something: a run working, standing still, or a follow-up. */
export function needsConfirm(task: Task): boolean {
return task.status === "in_progress" && (isWorking(task) || !!task.pause || !!task.followup);
Expand Down
28 changes: 16 additions & 12 deletions apps/desktop/src/components/tasks/task-sheet.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Link } from "react-router";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useMutationState, useQueryClient } from "@tanstack/react-query";
import { format, formatDistanceToNowStrict } from "date-fns";
import { toast } from "sonner";
import {
Expand All @@ -15,6 +15,7 @@ import {
CornerDownRight,
EllipsisVertical,
GitBranch,
GitMerge,
GitPullRequestCreateArrow,
Hourglass,
ListTree,
Expand All @@ -31,7 +32,7 @@ import {
Trash2,
} from "lucide-react";
import type { Agent, Task, TaskEvent, TaskPatch, TaskStatus, Workspace } from "@godmode/shared";
import { MAX_TASK_TITLE_LENGTH, githubBranchUrl, isWaiting, reopenStatus, waitsForTickets } from "@godmode/shared";
import { MAX_TASK_TITLE_LENGTH, githubBranchUrl, isWaiting, mergesOnApprove, reopenStatus, waitsForTickets } from "@godmode/shared";
import { WorkingTicks } from "@/components/aicss/Motion";
import { Markdown } from "@/components/chat/markdown";
import { ChatFilesScope } from "@/components/chat/local-files";
Expand All @@ -58,7 +59,7 @@ import { AgentSelect, DueDateField, LabelsInput, PrioritySelect, StatusSelect, a
import { TaskTimeline } from "./task-timeline";
import { TaskDialog } from "./task-dialog";
import { PullRequestChip, useTaskActivity } from "./task-card";
import { BLOCKED_META, StatusIcon, TYPE_META, TypeIcon, formatCost, formatWork, isWorking, pauseLabel, repoLabel, taskRepoLabel, workspaceRepos } from "./task-meta";
import { APPROVE_KEY, BLOCKED_META, StatusIcon, TYPE_META, TypeIcon, formatCost, formatWork, isWorking, pauseLabel, repoLabel, taskRepoLabel, workspaceRepos } from "./task-meta";
import { followupWhen, useFollowupActions } from "@/components/chat/followup";
import { useNow } from "@/components/vault/use-now";
import { usePauseActions } from "@/components/chat/pause";
Expand All @@ -78,7 +79,7 @@ export function TaskSheet({
agents: Agent[];
workspaces: Map<string, Workspace>;
onClose: () => void;
onMove: (task: Task, status: TaskStatus) => void;
onMove: (task: Task, status: TaskStatus, merge?: boolean) => void;
onArchive: (task: Task, archived: boolean) => void;
onDelete: (task: Task) => void;
onReassign: (task: Task, agentId: string | null) => void;
Expand Down Expand Up @@ -221,7 +222,7 @@ function TaskDetail({
workspaces: Map<string, Workspace>;
onClose: () => void;
onUploading: (uploading: boolean) => void;
onMove: (task: Task, status: TaskStatus) => void;
onMove: (task: Task, status: TaskStatus, merge?: boolean) => void;
onArchive: (task: Task, archived: boolean) => void;
onDelete: (task: Task) => void;
onReassign: (task: Task, agentId: string | null) => void;
Expand Down Expand Up @@ -620,7 +621,7 @@ function WorkPanel({
}: {
task: Task;
agent?: Agent;
onMove: (task: Task, status: TaskStatus) => void;
onMove: (task: Task, status: TaskStatus, merge?: boolean) => void;
onReason: (reason: string) => void;
/** Drop what it waits for (it starts then). */
onStartWithoutWaiting: () => void;
Expand All @@ -637,9 +638,11 @@ function WorkPanel({
onSuccess: (t) => qc.setQueriesData<Task[]>({ queryKey: qk.tasks }, (list) => list?.map((x) => (x.id === t.id ? t : x))),
onError: (e) => toastApiError(e, "Couldn't publish", qc),
});
const merging = useMutationState({ filters: { mutationKey: APPROVE_KEY, status: "pending" }, select: (m) => (m.state.variables as Task).id }).includes(task.id);
const approve = (t: Task) => {
onMove(t, "done");
toast.success(`#${t.number} approved`, { action: { label: "Undo", onClick: () => onMove(t, "in_review") } });
onMove(t, "done", true);
// Merging can't be undone; the page tells how that went.
if (!mergesOnApprove(t)) toast.success(`#${t.number} approved`, { action: { label: "Undo", onClick: () => onMove(t, "in_review") } });
};
const reopen = (t: Task) => {
const back = reopenStatus(t);
Expand Down Expand Up @@ -900,18 +903,19 @@ function WorkPanel({
</div>
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" onClick={() => approve(task)}>
<Check /> Approve
<Button size="sm" disabled={merging} onClick={() => approve(task)}>
{merging ? <Spinner /> : mergesOnApprove(task) ? <GitMerge /> : <Check />} {merging ? "Merging…" : mergesOnApprove(task) ? "Approve & merge" : "Approve"}
</Button>
{task.agentId && task.conversationId && (
<Button size="sm" variant="outline" onClick={() => focusReply(task.id)}>
<MessageSquareReply /> Request changes
</Button>
)}
</div>
{pr?.number && pr.state === "open" && (
{mergesOnApprove(task) && (
<p className="mt-2.5 text-xs text-muted-foreground">
Approve marks it done here — merging stays on {new URL(pr.url).hostname}. A merged pull request also moves it to Done.
Merges #{pr!.number} into <span className="font-mono text-[11px] text-foreground/80">{task.baseBranch}</span> on {new URL(pr!.url).hostname} and moves the
ticket to Done.
</p>
)}
</Panel>
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/components/workspaces/workspace-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { toastApiError } from "@/components/vault/vault-utils";
import { WorkspaceProfileField } from "@/components/browser/workspace-profile-field";
Expand Down Expand Up @@ -42,6 +43,7 @@ interface WorkspaceForm {
vmId: string | null;
browserProfileId: string | null;
sources: WorkspaceSourceInput[];
autoMerge: boolean;
}

function randomLook() {
Expand All @@ -58,6 +60,7 @@ function formFrom(workspace: Workspace | null | undefined, look: { icon: string;
vmId: workspace?.vmId ?? null,
browserProfileId: workspace?.browserProfileId ?? null,
sources: workspace?.sources.map(toSourceInput) ?? [],
autoMerge: workspace?.autoMerge ?? false,
};
}
const CONTEXT_EXAMPLE = "We are ACME GmbH. Write to clients in German.\nInvoices go to finance@acme.example.\nNever touch the production database.";
Expand Down Expand Up @@ -88,7 +91,7 @@ export function WorkspaceDialog({
const closing = useRef(live);
if (open) closing.current = live;
const form = open ? live : closing.current;
const { name, icon, color, description, instructions, vmId, browserProfileId, sources } = form;
const { name, icon, color, description, instructions, vmId, browserProfileId, sources, autoMerge } = form;
const set =
<K extends keyof WorkspaceForm>(key: K) =>
(value: WorkspaceForm[K]) =>
Expand Down Expand Up @@ -130,6 +133,7 @@ export function WorkspaceDialog({
description: description.trim(),
instructions: instructions.trim(),
sources,
autoMerge,
// Only when the VM control is shown: otherwise leave the assignment as it is.
...(vmChoices.available ? { vmId } : {}),
...(browserProfileId !== (workspace?.browserProfileId ?? null) ? { browserProfileId } : {}),
Expand Down Expand Up @@ -321,6 +325,16 @@ export function WorkspaceDialog({
</div>
<WorkspaceSourcesField workspaceId={workspace?.id ?? null} value={sources} onChange={set("sources")} />
</div>
<div className="flex items-start justify-between gap-4 rounded-xl border bg-card px-4 py-3">
<div className="min-w-0 space-y-0.5">
<Label htmlFor="ws-auto-merge">Merge delivered tickets automatically</Label>
<p className="text-xs text-muted-foreground">
When an agent delivers a ticket with a pull request, Godmode merges it into its base branch and moves the ticket to Done — no
approval needed.
</p>
</div>
<Switch id="ws-auto-merge" className="mt-0.5" checked={autoMerge} onCheckedChange={set("autoMerge")} />
</div>
<WorkspaceProfileField id="ws-browser" workspaceId={workspace?.id ?? null} value={browserProfileId} onChange={set("browserProfileId")} />
{vmChoices.available && (
<VmSelectField
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ export const api = {
/** Follow-up for the agent in the task's conversation (review feedback); the task goes back to work. */
message: (id: string, content: string, attachments?: SendMessageInput["attachments"]) =>
post<Task>(`/api/tasks/${id}/messages`, { content, attachments }),
/** Approve a ticket in review: its open pull request is merged first, then it's done. */
approve: (id: string) => post<Task>(`/api/tasks/${id}/approve`),
/** Push the task's branch (only coding tasks push theirs by themselves). */
push: (id: string) => post<Task>(`/api/tasks/${id}/push`),
/** Push the branch and open its pull request — or, when gh can't, link to the page that opens one. */
Expand Down
35 changes: 29 additions & 6 deletions apps/desktop/src/pages/tasks/tasks-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Archive, FolderGit2, ListFilter, Plus, Search, SquareKanban } from "lucide-react";
import { toast } from "sonner";
import type { Task, TaskPriority, TaskStatus, Workspace } from "@godmode/shared";
import { TASK_PRIORITIES, isOverdue, localDay } from "@godmode/shared";
import { TASK_PRIORITIES, isOverdue, localDay, mergesOnApprove } from "@godmode/shared";
import { AgentAvatar, EmptyState, PageHeader } from "@/components/common";
import {
DropdownMenu,
Expand Down Expand Up @@ -36,11 +36,11 @@ import { useArchiveTasks } from "@/components/tasks/task-actions";
import { TaskBoard } from "@/components/tasks/task-board";
import { TaskDialog } from "@/components/tasks/task-dialog";
import { TaskSheet } from "@/components/tasks/task-sheet";
import { PRIORITY_META, PriorityIcon, STATUS_META, isWorking, needsConfirm, workspaceRepos } from "@/components/tasks/task-meta";
import { APPROVE_KEY, PRIORITY_META, PriorityIcon, STATUS_META, isWorking, needsConfirm, workspaceRepos } from "@/components/tasks/task-meta";
import { followupWhen } from "@/components/chat/followup";
import { toastApiError } from "@/components/vault/vault-utils";
import { WorkspaceDialog } from "@/components/workspaces/workspace-dialog";
import { api, errorMessage } from "@/lib/api";
import { ApiRequestError, api, errorMessage } from "@/lib/api";
import { useAllAgents, useArchivedTasks, useGoals, useTasks, useWorkspaces } from "@/lib/hooks";
import { GoalsStrip } from "@/components/tasks/goals-strip";
import { qk } from "@/lib/queryKeys";
Expand Down Expand Up @@ -218,8 +218,31 @@ export default function TasksPage() {
},
});

const requestMove = (task: Task, status: TaskStatus, beforeId: string | null = null) => {
if (needsConfirm(task) && status !== "in_progress") setStopping({ task, status, beforeId });
// Approving a ticket with an open pull request merges it: it moves to Done once that worked.
const approve = useMutation({
mutationKey: APPROVE_KEY,
mutationFn: (task: Task) => api.tasks.approve(task.id),
onSuccess: (t, task) => {
upsertTask(qc, t);
const pr = task.pullRequest?.number;
if (t.pullRequest?.state === "merged") {
toast.success(`#${t.number} merged into ${t.baseBranch}`, { description: t.status === "done" ? `Pull request #${pr} is merged and the ticket is done.` : `Pull request #${pr} is merged.` });
} else toast.info(`#${pr} is queued to merge`, { description: "The ticket moves to Done once GitHub merges it." });
},
onError: (e, task) => {
void qc.invalidateQueries({ queryKey: qk.tasks });
if (!(e instanceof ApiRequestError && e.code === "merge_failed")) return toastApiError(e, "Could not approve the task", qc);
toast.error(`Couldn't merge #${task.pullRequest?.number}`, {
description: e.message,
duration: 12_000,
action: { label: "Mark done anyway", onClick: () => move.mutate({ task, status: "done", beforeId: null }) },
});
},
});

const requestMove = (task: Task, status: TaskStatus, beforeId: string | null = null, merge = false) => {
if (merge && status === "done" && mergesOnApprove(task)) approve.mutate(task);
else if (needsConfirm(task) && status !== "in_progress") setStopping({ task, status, beforeId });
else move.mutate({ task, status, beforeId });
};

Expand Down Expand Up @@ -487,7 +510,7 @@ export default function TasksPage() {
agents={agents}
workspaces={workspaces}
onClose={() => openTask(null)}
onMove={(task, status) => requestMove(task, status)}
onMove={(task, status, merge) => requestMove(task, status, null, merge)}
onArchive={(task, value) => (value ? archive([task]) : setArchived(task, false))}
onDelete={setDeleting}
onReassign={requestReassign}
Expand Down
25 changes: 21 additions & 4 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isWaiting, reopenStatus, waitsForAnswer, waitsForSubtasks, waitsForTickets, type TaskBlockedKind } from "@godmode/shared";
import { isWaiting, mergesOnApprove, reopenStatus, waitsForAnswer, waitsForSubtasks, waitsForTickets, type TaskBlockedKind } from "@godmode/shared";
import { useMutation, useQuery } from "@tanstack/react-query";
import { router, Stack, useLocalSearchParams } from "expo-router";
import { Alert, Linking, Pressable, ScrollView, StyleSheet, View } from "react-native";
Expand All @@ -10,7 +10,7 @@ import { Markdown } from "@/components/markdown";
import { openChat } from "@/components/rows";
import { STATUS_META, TaskStatusBadge, TYPE_META } from "@/components/task-row";
import { Avatar, Badge, Button, Card, Row, SectionTitle, T, tap } from "@/components/ui";
import { api, errorText } from "@/lib/api";
import { ApiError, api, errorText } from "@/lib/api";
import { encodeFiles, type PendingFile } from "@/lib/attachments";
import { activityText } from "@/lib/format";
import { useAgents } from "@/lib/hooks";
Expand All @@ -35,6 +35,22 @@ export default function TaskScreen() {
onSuccess: onDone,
onError: (err) => Alert.alert("Couldn't change the task", errorText(err)),
});
const approve = useMutation({
mutationFn: () => api.tasks.approve(id),
onSuccess: (next) => {
onDone(next);
if (next.pullRequest?.state === "open") Alert.alert(`#${next.pullRequest.number} is queued to merge`, "The ticket moves to Done once GitHub merges it.");
},
onError: (err) => {
// A computer on an older Godmode: approving only marks it done.
if (err instanceof ApiError && err.status === 404) return update.mutate({ status: "done" });
if (!(err instanceof ApiError && err.code === "merge_failed")) return Alert.alert("Couldn't approve it", errorText(err));
Alert.alert(`Couldn't merge #${task.data?.pullRequest?.number ?? ""}`, errorText(err), [
{ text: "Cancel", style: "cancel" },
{ text: "Mark done anyway", onPress: () => update.mutate({ status: "done" }) },
]);
},
});

const t = task.data;
if (!t) return <Stack.Title>{task.isError ? "Task not found" : "Task"}</Stack.Title>;
Expand All @@ -50,6 +66,7 @@ export default function TaskScreen() {

const move = (status: TaskStatus) => {
tap();
if (status === "done" && mergesOnApprove(t)) return approve.mutate();
if (!(working || waiting) || status === "in_progress") return update.mutate({ status });
Alert.alert(
"Stop the agent?",
Expand Down Expand Up @@ -154,7 +171,7 @@ export default function TaskScreen() {

<Actions
task={t}
busy={update.isPending}
busy={update.isPending || approve.isPending}
hasAgent={!!agent}
onMove={move}
onArchive={archive}
Expand Down Expand Up @@ -271,7 +288,7 @@ function Actions({
buttons.push({ title: "Stop", icon: "stop", status: "backlog" });
break;
case "in_review":
buttons.push({ title: "Approve", icon: "check", status: "done", primary: true });
buttons.push({ title: busy && mergesOnApprove(task) ? "Merging…" : mergesOnApprove(task) ? "Approve & merge" : "Approve", icon: "check", status: "done", primary: true });
break;
case "blocked":
// When the agent asked for something, the answer goes in the message box below.
Expand Down
Loading
Loading