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 };