mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
142
apps/mobile/components/issue/agent-activity-row.tsx
Normal file
142
apps/mobile/components/issue/agent-activity-row.tsx
Normal 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,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
155
apps/mobile/components/issue/run-row.tsx
Normal file
155
apps/mobile/components/issue/run-row.tsx
Normal 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",
|
||||
};
|
||||
104
apps/mobile/components/issue/runs-sheet.tsx
Normal file
104
apps/mobile/components/issue/runs-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
apps/mobile/components/ui/avatar-stack.tsx
Normal file
97
apps/mobile/components/ui/avatar-stack.tsx
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user