feat(mobile): show agent runs on issue detail

New double-state row inside IssueHeaderCard (between title and
attributes): "[👤👤👤] Working" + pulse dot when ≥1 active task,
"Runs · N" when only past runs exist, hidden otherwise. Tap opens a
pageSheet listing Active + Past runs with status badges and an inline
Cancel button on active rows.

Data layer:
- api.ts: listActiveTasksForIssue (GET /api/issues/:id/active-task)
  and listTasksByIssue (GET /api/issues/:id/task-runs), both run
  through parseWithFallback + a new AgentTaskSchema (lenient enums
  with .catch() for forward-compat)
- queries/issue-keys.ts + queries/issues.ts: activeTasks + tasks
  options, workspace-scoped, signal forwarded
- mutations/issues.ts: useCancelTask with optimistic remove + rollback
- realtime/use-issue-realtime.ts: task:* WS events now invalidate the
  two new task queries (in addition to detail+timeline), so the row
  and sheet update without polling

New components: AgentActivityRow (the row), RunsSheet (built on
SheetShell), RunRow (single task row, cancel action), AvatarStack
(mobile-native overlapping avatars).

Transcript drilldown deferred to a follow-up — past row tap is no-op
in v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-15 15:11:12 +08:00
parent f9cab33d94
commit 11913d18f4
11 changed files with 692 additions and 1 deletions

View File

@@ -0,0 +1,142 @@
/**
* Double-state row that lives inside `IssueHeaderCard`. Opens `RunsSheet`
* on tap.
*
* ≥1 active task → [agent avatars] (pulse) Working
* 0 active, ≥1 past → 🕓 Runs · N
* never run → null (zero space)
*
* Why this lives in IssueHeaderCard and not as a sticky header above the
* timeline: see /Users/qingnaiyuan/.claude/plans/ok-plan-linked-taco.md
* for the design rationale. Short version: HIG "indicators near the
* content they describe" + binary live state doesn't warrant the timeline-
* restructuring cost of `stickyHeaderIndices`.
*/
import { useEffect, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useQuery } from "@tanstack/react-query";
import { Ionicons } from "@expo/vector-icons";
import { Text } from "@/components/ui/text";
import { AvatarStack, type StackActor } from "@/components/ui/avatar-stack";
import {
issueActiveTasksOptions,
issueTasksOptions,
} from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { RunsSheet } from "./runs-sheet";
interface Props {
issueId: string;
}
export function AgentActivityRow({ issueId }: Props) {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const [sheetOpen, setSheetOpen] = useState(false);
const { data: activeTasks = [] } = useQuery(
issueActiveTasksOptions(wsId, issueId),
);
const { data: allTasks = [] } = useQuery(issueTasksOptions(wsId, issueId));
const activeCount = activeTasks.length;
// "Past" = tasks not currently active. The /task-runs endpoint returns the
// full list, so we filter rather than fetching a separate past-only query.
// Memo'd so the array reference is stable across renders — RunsSheet's
// internal useMemo([pastTasks]) only recomputes when the upstream cache
// actually changes, not on every parent render.
const pastTasks = useMemo(
() =>
allTasks.filter(
(t) =>
t.status === "completed" ||
t.status === "failed" ||
t.status === "cancelled",
),
[allTasks],
);
const pastCount = pastTasks.length;
if (activeCount === 0 && pastCount === 0) {
return null;
}
const onPress = () => setSheetOpen(true);
return (
<>
<Pressable
onPress={onPress}
className="flex-row items-center gap-2 -mx-2 px-2 py-2 rounded-lg active:bg-secondary"
>
{activeCount > 0 ? (
<ActiveContent
actors={activeTasks.map<StackActor>((t) => ({
type: "agent",
id: t.agent_id,
}))}
/>
) : (
<IdleContent count={pastCount} />
)}
<Ionicons name="chevron-forward" size={16} color="#a1a1aa" />
</Pressable>
<RunsSheet
visible={sheetOpen}
onClose={() => setSheetOpen(false)}
issueId={issueId}
activeTasks={activeTasks}
pastTasks={pastTasks}
/>
</>
);
}
function ActiveContent({ actors }: { actors: StackActor[] }) {
return (
<View className="flex-1 flex-row items-center gap-2">
<AvatarStack actors={actors} max={3} size={24} />
<PulseDot />
<Text className="text-sm font-medium text-foreground">Working</Text>
</View>
);
}
function IdleContent({ count }: { count: number }) {
return (
<View className="flex-1 flex-row items-center gap-2">
<Ionicons name="time-outline" size={16} color="#a1a1aa" />
<Text className="text-sm text-foreground">Runs · {count}</Text>
</View>
);
}
/** Slow green pulse — 2s opacity oscillation on the UI thread (via
* Reanimated's `withRepeat`). Same library as comment-card.tsx so no new
* animation primitive is introduced. */
function PulseDot() {
const opacity = useSharedValue(0.3);
useEffect(() => {
opacity.value = withRepeat(
withTiming(1, { duration: 1000 }),
-1, // infinite
true, // reverse — yields 0.3 ↔ 1.0 oscillation over 2s
);
}, [opacity]);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
return (
<Animated.View
style={[
{ width: 8, height: 8, borderRadius: 4, backgroundColor: "#22c55e" }, // success
style,
]}
/>
);
}

View File

@@ -15,6 +15,7 @@ import { View } from "react-native";
import type { Issue } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { AttributeRow } from "./attribute-row";
import { AgentActivityRow } from "./agent-activity-row";
export function IssueHeaderCard({ issue }: { issue: Issue }) {
return (
@@ -23,6 +24,11 @@ export function IssueHeaderCard({ issue }: { issue: Issue }) {
<Text className="text-2xl font-bold text-foreground">
{issue.title}
</Text>
{/* Activity row sits between title and attributes — it represents
* "who's doing this issue right now / who has done it" (dynamic),
* which is higher-IA than the static property chips below.
* Conditionally renders null when there are no tasks at all. */}
<AgentActivityRow issueId={issue.id} />
<AttributeRow issue={issue} />
</View>
);

View File

@@ -0,0 +1,155 @@
/**
* Single row inside `RunsSheet`. Same component for active and past tasks —
* the trailing Cancel button is conditional on `status in {queued,
* dispatched, running}`, and the status badge / colour swaps based on the
* AgentTask.status enum.
*
* Tapping a past row is a no-op in v1 — the transcript-detail screen is
* explicitly out of scope per /Users/qingnaiyuan/.claude/plans/
* ok-plan-linked-taco.md.
*/
import { Alert, Pressable, View } from "react-native";
import type { AgentTask, TaskFailureReason } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { useCancelTask } from "@/data/mutations/issues";
import { useActorLookup } from "@/data/use-actor-name";
import { timeAgo } from "@/lib/time-ago";
interface Props {
task: AgentTask;
issueId: string;
}
const ACTIVE_STATUSES: readonly AgentTask["status"][] = [
"queued",
"dispatched",
"running",
];
export function RunRow({ task, issueId }: Props) {
const { getName } = useActorLookup();
const isActive = ACTIVE_STATUSES.includes(task.status);
const summary = task.trigger_summary?.trim() || fallbackSummary(task);
// Past tasks use completed_at when present (server fills it for terminal
// statuses); active tasks fall back to created_at so the user sees how
// long it's been waiting.
const timestamp = task.completed_at || task.created_at;
return (
<View className="flex-row items-start gap-3 py-2">
<ActorAvatar type="agent" id={task.agent_id} size={28} />
<View className="flex-1 gap-1">
<Text
className="text-sm text-foreground"
numberOfLines={2}
>
<Text className="font-medium">{getName("agent", task.agent_id)}</Text>
<Text className="text-muted-foreground"> · {summary}</Text>
</Text>
<View className="flex-row items-center gap-2">
<StatusBadge task={task} />
<Text className="text-xs text-muted-foreground">
{timestamp ? timeAgo(timestamp) : ""}
</Text>
</View>
</View>
{isActive ? <CancelButton taskId={task.id} issueId={issueId} /> : null}
</View>
);
}
function StatusBadge({ task }: { task: AgentTask }) {
const label = STATUS_LABEL[task.status] ?? task.status;
const cls = STATUS_CLASS[task.status] ?? "text-muted-foreground";
// For failed tasks, surface the failure_reason inline so users don't have
// to drill in. Reasons are coarse enums; missing/empty stays as just "Failed".
if (task.status === "failed" && task.failure_reason) {
const reasonLabel = FAILURE_REASON_LABEL[task.failure_reason];
if (reasonLabel) {
return (
<Text className={`text-xs ${cls}`}>
{label} · {reasonLabel}
</Text>
);
}
}
return <Text className={`text-xs ${cls}`}>{label}</Text>;
}
function CancelButton({
taskId,
issueId,
}: {
taskId: string;
issueId: string;
}) {
const mutation = useCancelTask(issueId);
const onPress = () => {
Alert.alert(
"Cancel task?",
"The agent will stop after the current step.",
[
{ text: "Keep running", style: "cancel" },
{
text: "Cancel task",
style: "destructive",
onPress: () => mutation.mutate(taskId),
},
],
);
};
return (
<Pressable
onPress={onPress}
disabled={mutation.isPending}
className="px-3 py-1.5 rounded-md bg-secondary active:opacity-70"
>
<Text className="text-xs font-medium text-foreground">Cancel</Text>
</Pressable>
);
}
function fallbackSummary(task: AgentTask): string {
switch (task.kind) {
case "comment":
return "Comment task";
case "autopilot":
return "Autopilot run";
case "chat":
return "Chat task";
case "quick_create":
return "Quick create";
case "direct":
default:
return "Task";
}
}
const STATUS_LABEL: Record<AgentTask["status"], string> = {
queued: "Queued",
dispatched: "Starting",
running: "Running",
completed: "Done",
failed: "Failed",
cancelled: "Cancelled",
};
const STATUS_CLASS: Record<AgentTask["status"], string> = {
queued: "text-muted-foreground",
dispatched: "text-brand",
running: "text-brand",
completed: "text-muted-foreground",
failed: "text-destructive",
cancelled: "text-muted-foreground",
};
const FAILURE_REASON_LABEL: Record<TaskFailureReason, string> = {
agent_error: "Agent error",
timeout: "Timeout",
runtime_offline: "Runtime offline",
runtime_recovery: "Runtime recovery",
manual: "Manual",
};

View File

@@ -0,0 +1,104 @@
/**
* iOS pageSheet for viewing agent runs on the current issue. Uses the
* shared `<SheetShell>` primitive (components/ui/sheet-shell.tsx) which
* encapsulates pageSheet + safe-area + close-button.
*
* See `apps/mobile/CLAUDE.md` Lesson #6 for the rationale on choosing
* pageSheet over the project's older transparent-fade Modal pattern.
*
* Two sections: Active (queued/dispatched/running, created_at desc) and
* Past (failed → cancelled → completed, completed_at desc within each).
* Empty sections hide entirely.
*
* Past-row tap is a no-op in v1 — transcript drilldown is deferred.
*/
import { useMemo } from "react";
import { ScrollView, View } from "react-native";
import type { AgentTask } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { SheetShell } from "@/components/ui/sheet-shell";
import { RunRow } from "./run-row";
interface Props {
visible: boolean;
onClose: () => void;
issueId: string;
activeTasks: AgentTask[];
pastTasks: AgentTask[];
}
const PAST_STATUS_ORDER: Record<AgentTask["status"], number> = {
failed: 0,
cancelled: 1,
completed: 2,
// Active statuses don't appear in the Past section but the type demands
// exhaustive keys; assign anything beyond the terminal three.
queued: 99,
dispatched: 99,
running: 99,
};
export function RunsSheet({
visible,
onClose,
issueId,
activeTasks,
pastTasks,
}: Props) {
const active = useMemo(
() =>
[...activeTasks].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
),
[activeTasks],
);
const past = useMemo(() => {
return [...pastTasks].sort((a, b) => {
const ord = PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
if (ord !== 0) return ord;
// Within a status group: newest completion first.
return (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
});
}, [pastTasks]);
return (
<SheetShell visible={visible} onClose={onClose} title="Agent Runs">
<ScrollView showsVerticalScrollIndicator={false}>
<View className="px-4 gap-3 pb-4">
{active.length > 0 ? (
<Section title="Active">
{active.map((task) => (
<RunRow key={task.id} task={task} issueId={issueId} />
))}
</Section>
) : null}
{past.length > 0 ? (
<Section title="Past">
{past.map((task) => (
<RunRow key={task.id} task={task} issueId={issueId} />
))}
</Section>
) : null}
</View>
</ScrollView>
</SheetShell>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1">
<Text className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{title}
</Text>
<View>{children}</View>
</View>
);
}

View File

@@ -0,0 +1,97 @@
/**
* Overlapping avatar stack — mobile equivalent of web's
* `packages/ui/components/ui/avatar.tsx` `AvatarGroup`. Mobile cannot import
* the web component (sharing rules in apps/mobile/CLAUDE.md), so this is a
* native re-implementation built on top of ActorAvatar.
*
* Dedupes input by `${type}:${id}` before slicing — multiple active tasks
* from the same agent collapse to a single avatar (otherwise the stack
* misrepresents how many distinct actors are involved).
*/
import { View } from "react-native";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { Text } from "@/components/ui/text";
export interface StackActor {
type: "member" | "agent" | null | undefined;
id: string | null | undefined;
}
interface Props {
actors: StackActor[];
/** Max distinct avatars rendered before collapsing to `+N`. Default 3. */
max?: number;
/** Avatar diameter in pt. Default 24 (tight enough for a header row). */
size?: number;
}
export function AvatarStack({ actors, max = 3, size = 24 }: Props) {
const deduped = dedupe(actors);
const visible = deduped.slice(0, max);
const overflow = deduped.length - visible.length;
return (
<View className="flex-row">
{visible.map((actor, i) => (
<Ring
key={`${actor.type}:${actor.id}:${i}`}
size={size}
offset={i === 0 ? 0 : -size / 3}
>
<ActorAvatar type={actor.type} id={actor.id} size={size} />
</Ring>
))}
{overflow > 0 ? (
<Ring size={size} offset={-size / 3}>
<View
style={{ width: size, height: size, borderRadius: size / 2 }}
className="items-center justify-center bg-muted"
>
<Text className="text-[10px] font-medium text-muted-foreground">
+{overflow}
</Text>
</View>
</Ring>
) : null}
</View>
);
}
/** Wraps each avatar in a ring of `bg-background` so overlaps read clearly
* against the underlying surface. `marginLeft` does the overlap (web uses
* Tailwind's `-space-x-2`; RN doesn't compile that, so we set it inline). */
function Ring({
size,
offset,
children,
}: {
size: number;
offset: number;
children: React.ReactNode;
}) {
return (
<View
style={{
marginLeft: offset,
width: size + 4,
height: size + 4,
borderRadius: (size + 4) / 2,
}}
className="bg-background items-center justify-center"
>
{children}
</View>
);
}
function dedupe(actors: StackActor[]): StackActor[] {
const seen = new Set<string>();
const out: StackActor[] = [];
for (const a of actors) {
const key = `${a.type ?? "none"}:${a.id ?? "none"}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(a);
}
return out;
}

View File

@@ -15,6 +15,7 @@
*/
import type {
Agent,
AgentTask,
Attachment,
ChatMessage,
ChatPendingTask,
@@ -52,11 +53,15 @@ import {
TimelineEntriesSchema,
} from "@multica/core/api/schemas";
import {
ActiveTasksResponseSchema,
AgentTaskListSchema,
AttachmentSchema,
ChatMessageListSchema,
ChatPendingTaskSchema,
ChatSessionListSchema,
ChatSessionSchema,
EMPTY_ACTIVE_TASKS_RESPONSE,
EMPTY_AGENT_TASK_LIST,
EMPTY_CHAT_MESSAGE_LIST,
EMPTY_CHAT_PENDING_TASK,
EMPTY_CHAT_SESSION_LIST,
@@ -409,6 +414,46 @@ class ApiClient {
);
}
// Active tasks for an issue (status in queued/dispatched/running). Returns
// the inner `tasks` array directly — handler wraps it in `{ tasks: [] }`
// (server/internal/handler/daemon.go:1866) so the response object survives
// future field additions without breaking the cache shape.
async listActiveTasksForIssue(
issueId: string,
opts?: { signal?: AbortSignal },
): Promise<AgentTask[]> {
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/active-task`,
{ signal: opts?.signal },
);
const parsed = parseWithFallback(
raw,
ActiveTasksResponseSchema,
EMPTY_ACTIVE_TASKS_RESPONSE,
{ endpoint: "GET /api/issues/:id/active-task" },
);
return parsed.tasks;
}
// All tasks (any status) for an issue — drives the "Runs" history section.
// Path is `/task-runs` (server/cmd/server/router.go:353), NOT `/tasks` —
// the latter doesn't exist on this scope.
async listTasksByIssue(
issueId: string,
opts?: { signal?: AbortSignal },
): Promise<AgentTask[]> {
const raw = await this.fetch<unknown>(
`/api/issues/${issueId}/task-runs`,
{ signal: opts?.signal },
);
return parseWithFallback(
raw,
AgentTaskListSchema,
EMPTY_AGENT_TASK_LIST,
{ endpoint: "GET /api/issues/:id/task-runs" },
);
}
async createComment(
issueId: string,
content: string,

View File

@@ -15,6 +15,7 @@
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type {
AgentTask,
CreateIssueRequest,
Issue,
IssueReaction,
@@ -379,3 +380,35 @@ export function useCreateIssue() {
},
});
}
/**
* Cancel an in-flight agent task. Optimistically removes the task from the
* active-tasks cache so the RunRow disappears immediately; the WS
* `task:cancelled` event then invalidates both task queries (see
* `use-issue-realtime.ts`) so the task reappears in the past list with the
* authoritative server state. On error we restore the snapshot.
*/
export function useCancelTask(issueId: string) {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
return useMutation({
mutationFn: (taskId: string) => api.cancelTaskById(taskId),
onMutate: async (taskId) => {
const activeKey = issueKeys.activeTasks(wsId, issueId);
await qc.cancelQueries({ queryKey: activeKey });
const prev = qc.getQueryData<AgentTask[]>(activeKey);
qc.setQueryData<AgentTask[]>(activeKey, (old) =>
old ? old.filter((t) => t.id !== taskId) : old,
);
return { prev, activeKey };
},
onError: (_err, _taskId, ctx) => {
if (ctx?.prev) qc.setQueryData(ctx.activeKey, ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.activeTasks(wsId, issueId) });
qc.invalidateQueries({ queryKey: issueKeys.tasks(wsId, issueId) });
},
});
}

View File

@@ -29,4 +29,11 @@ export const issueKeys = {
[...issueKeys.all(wsId), "detail", id] as const,
timeline: (wsId: string | null, id: string) =>
[...issueKeys.all(wsId), "timeline", id] as const,
// Currently-running tasks for an issue (queued/dispatched/running). Drives
// the "Working" state of the AgentActivityRow inside IssueHeaderCard.
activeTasks: (wsId: string | null, id: string) =>
[...issueKeys.all(wsId), "active-tasks", id] as const,
// All tasks (any status) for an issue — drives the Runs history sheet.
tasks: (wsId: string | null, id: string) =>
[...issueKeys.all(wsId), "tasks", id] as const,
};

View File

@@ -52,3 +52,28 @@ export const issueTimelineOptions = (wsId: string | null, id: string) =>
queryFn: ({ signal }) => api.listTimeline(id, { signal }),
enabled: !!wsId && !!id,
});
/**
* Currently-running tasks for an issue. WS events (task:queued/dispatch/
* progress/completed/failed/cancelled) patch this cache directly via
* `issue-ws-updaters.ts`, so refetches are rare in practice. The fetch is
* still wired so the initial open + reconnect-invalidate path works.
*/
export const issueActiveTasksOptions = (wsId: string | null, id: string) =>
queryOptions({
queryKey: issueKeys.activeTasks(wsId, id),
queryFn: ({ signal }) => api.listActiveTasksForIssue(id, { signal }),
enabled: !!wsId && !!id,
});
/**
* All tasks (any status) for an issue — drives the Runs sheet history
* section. Same patching strategy as active tasks: WS moves entries between
* the two caches without refetching.
*/
export const issueTasksOptions = (wsId: string | null, id: string) =>
queryOptions({
queryKey: issueKeys.tasks(wsId, id),
queryFn: ({ signal }) => api.listTasksByIssue(id, { signal }),
enabled: !!wsId && !!id,
});

View File

@@ -87,9 +87,25 @@ export function useIssueRealtime(
qc.invalidateQueries({ queryKey: issueKeys.timeline(wsId, issueId) });
};
// Task-query invalidation — separate from detail/timeline so the
// AgentActivityRow + RunsSheet can refresh without forcing a full
// timeline rebuild. WS task payloads only carry { task_id, agent_id,
// issue_id, status } — not the full AgentTask object — so per
// apps/mobile/CLAUDE.md "Patch over invalidate" rule #1 (payload is
// just an id), invalidate is the correct primitive.
const invalidateTaskQueries = () => {
qc.invalidateQueries({ queryKey: issueKeys.activeTasks(wsId, issueId) });
qc.invalidateQueries({ queryKey: issueKeys.tasks(wsId, issueId) });
};
const onTaskEvent = (p: unknown) => {
if ((p as TaskEventPayload).issue_id !== issueId) return;
// Detail + timeline: task state can flip issue.status server-side
// (e.g. agent_finished → in_progress) without an issue:updated event,
// so we refetch the authoritative detail too.
invalidateThisIssue();
// Task lists: drive the AgentActivityRow + RunsSheet.
invalidateTaskQueries();
};
const unsubs: Array<() => void> = [
@@ -204,7 +220,10 @@ export function useIssueRealtime(
ws.on("task:cancelled", onTaskEvent),
// ----- Reconnect -----
ws.onReconnect(invalidateThisIssue),
ws.onReconnect(() => {
invalidateThisIssue();
invalidateTaskQueries();
}),
];
return () => {

View File

@@ -11,6 +11,7 @@
*/
import { z } from "zod";
import type {
AgentTask,
Attachment,
ChatMessage,
ChatPendingTask,
@@ -256,5 +257,62 @@ export const EMPTY_SEARCH_PROJECTS_RESPONSE: SearchProjectsResponse = {
total: 0,
};
// =====================================================
// Agent tasks (per-issue runs, active + history)
// =====================================================
// Mirrors AgentTask in packages/core/types/agent.ts. Backend handlers:
// GET /api/issues/{id}/active-task → { tasks: AgentTask[] } (may be empty)
// GET /api/issues/{id}/task-runs → AgentTask[]
// Lenient on every field — status / kind / failure_reason all use `.catch()`
// so a future server-side enum value renders a generic fallback rather than
// crashing the row (root CLAUDE.md "Enum drift downgrades, not crashes").
export const AgentTaskSchema: z.ZodType<AgentTask> = z.object({
id: z.string(),
agent_id: z.string().default(""),
runtime_id: z.string().default(""),
issue_id: z.string().default(""),
status: z
.enum(["queued", "dispatched", "running", "completed", "failed", "cancelled"])
.catch("queued"),
priority: z.number().default(0),
dispatched_at: z.string().nullable().default(null),
started_at: z.string().nullable().default(null),
completed_at: z.string().nullable().default(null),
result: z.unknown().default(null),
error: z.string().nullable().default(null),
// Backend uses empty string ("") as the "not failed" sentinel (Go
// `omitempty` on a custom string-typed enum). Normalize that to `undefined`
// so downstream truthy checks (`if (task.failure_reason)`) don't have to
// special-case both null/undefined AND "".
failure_reason: z
.enum(["agent_error", "timeout", "runtime_offline", "runtime_recovery", "manual", ""])
.optional()
.catch("")
.transform((v) => (v === "" ? undefined : v)),
created_at: z.string().default(""),
chat_session_id: z.string().optional(),
autopilot_run_id: z.string().optional(),
parent_task_id: z.string().optional(),
attempt: z.number().optional(),
trigger_comment_id: z.string().optional(),
trigger_summary: z.string().optional(),
kind: z.enum(["comment", "autopilot", "chat", "quick_create", "direct"]).optional().catch("direct"),
work_dir: z.string().optional(),
}).loose();
export const AgentTaskListSchema = z.array(AgentTaskSchema).default([]);
export const ActiveTasksResponseSchema = z.object({
tasks: z.array(AgentTaskSchema).default([]),
}).loose();
export interface ActiveTasksResponse {
tasks: AgentTask[];
}
export const EMPTY_AGENT_TASK_LIST: AgentTask[] = [];
export const EMPTY_ACTIVE_TASKS_RESPONSE: ActiveTasksResponse = { tasks: [] };
// Helpers re-exported for ergonomic single-import at the call site.
export type { Label, Project, ProjectResource };