diff --git a/apps/mobile/components/issue/agent-activity-row.tsx b/apps/mobile/components/issue/agent-activity-row.tsx
new file mode 100644
index 000000000..36841f8da
--- /dev/null
+++ b/apps/mobile/components/issue/agent-activity-row.tsx
@@ -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 (
+ <>
+
+ {activeCount > 0 ? (
+ ((t) => ({
+ type: "agent",
+ id: t.agent_id,
+ }))}
+ />
+ ) : (
+
+ )}
+
+
+ setSheetOpen(false)}
+ issueId={issueId}
+ activeTasks={activeTasks}
+ pastTasks={pastTasks}
+ />
+ >
+ );
+}
+
+function ActiveContent({ actors }: { actors: StackActor[] }) {
+ return (
+
+
+
+ Working
+
+ );
+}
+
+function IdleContent({ count }: { count: number }) {
+ return (
+
+
+ Runs · {count}
+
+ );
+}
+
+/** 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 (
+
+ );
+}
diff --git a/apps/mobile/components/issue/issue-header-card.tsx b/apps/mobile/components/issue/issue-header-card.tsx
index fa84604ce..78173bd6e 100644
--- a/apps/mobile/components/issue/issue-header-card.tsx
+++ b/apps/mobile/components/issue/issue-header-card.tsx
@@ -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 }) {
{issue.title}
+ {/* 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. */}
+
);
diff --git a/apps/mobile/components/issue/run-row.tsx b/apps/mobile/components/issue/run-row.tsx
new file mode 100644
index 000000000..f314bf3d5
--- /dev/null
+++ b/apps/mobile/components/issue/run-row.tsx
@@ -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 (
+
+
+
+
+ {getName("agent", task.agent_id)}
+ · {summary}
+
+
+
+
+ {timestamp ? timeAgo(timestamp) : ""}
+
+
+
+ {isActive ? : null}
+
+ );
+}
+
+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 (
+
+ {label} · {reasonLabel}
+
+ );
+ }
+ }
+ return {label};
+}
+
+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 (
+
+ Cancel
+
+ );
+}
+
+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 = {
+ queued: "Queued",
+ dispatched: "Starting",
+ running: "Running",
+ completed: "Done",
+ failed: "Failed",
+ cancelled: "Cancelled",
+};
+
+const STATUS_CLASS: Record = {
+ 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 = {
+ agent_error: "Agent error",
+ timeout: "Timeout",
+ runtime_offline: "Runtime offline",
+ runtime_recovery: "Runtime recovery",
+ manual: "Manual",
+};
diff --git a/apps/mobile/components/issue/runs-sheet.tsx b/apps/mobile/components/issue/runs-sheet.tsx
new file mode 100644
index 000000000..19100a560
--- /dev/null
+++ b/apps/mobile/components/issue/runs-sheet.tsx
@@ -0,0 +1,104 @@
+/**
+ * iOS pageSheet for viewing agent runs on the current issue. Uses the
+ * shared `` 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 = {
+ 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 (
+
+
+
+ {active.length > 0 ? (
+
+ {active.map((task) => (
+
+ ))}
+
+ ) : null}
+ {past.length > 0 ? (
+
+ {past.map((task) => (
+
+ ))}
+
+ ) : null}
+
+
+
+ );
+}
+
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+}
diff --git a/apps/mobile/components/ui/avatar-stack.tsx b/apps/mobile/components/ui/avatar-stack.tsx
new file mode 100644
index 000000000..77d087384
--- /dev/null
+++ b/apps/mobile/components/ui/avatar-stack.tsx
@@ -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 (
+
+ {visible.map((actor, i) => (
+
+
+
+ ))}
+ {overflow > 0 ? (
+
+
+
+ +{overflow}
+
+
+
+ ) : null}
+
+ );
+}
+
+/** 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 (
+
+ {children}
+
+ );
+}
+
+function dedupe(actors: StackActor[]): StackActor[] {
+ const seen = new Set();
+ 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;
+}
diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts
index 421a93d7a..ed59dc025 100644
--- a/apps/mobile/data/api.ts
+++ b/apps/mobile/data/api.ts
@@ -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 {
+ const raw = await this.fetch(
+ `/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 {
+ const raw = await this.fetch(
+ `/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,
diff --git a/apps/mobile/data/mutations/issues.ts b/apps/mobile/data/mutations/issues.ts
index 2340d9240..4f0b11076 100644
--- a/apps/mobile/data/mutations/issues.ts
+++ b/apps/mobile/data/mutations/issues.ts
@@ -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(activeKey);
+ qc.setQueryData(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) });
+ },
+ });
+}
diff --git a/apps/mobile/data/queries/issue-keys.ts b/apps/mobile/data/queries/issue-keys.ts
index 9e9d62798..8de8d786b 100644
--- a/apps/mobile/data/queries/issue-keys.ts
+++ b/apps/mobile/data/queries/issue-keys.ts
@@ -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,
};
diff --git a/apps/mobile/data/queries/issues.ts b/apps/mobile/data/queries/issues.ts
index 202993af7..4637bf9b9 100644
--- a/apps/mobile/data/queries/issues.ts
+++ b/apps/mobile/data/queries/issues.ts
@@ -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,
+ });
diff --git a/apps/mobile/data/realtime/use-issue-realtime.ts b/apps/mobile/data/realtime/use-issue-realtime.ts
index 65a0197e8..a2a1adab6 100644
--- a/apps/mobile/data/realtime/use-issue-realtime.ts
+++ b/apps/mobile/data/realtime/use-issue-realtime.ts
@@ -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 () => {
diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts
index c3c1b34b0..2937db762 100644
--- a/apps/mobile/data/schemas.ts
+++ b/apps/mobile/data/schemas.ts
@@ -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 = 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 };