From 346edb2fa251afa6cb66692160c10ec6a87361e9 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:18:14 +0800 Subject: [PATCH] fix(web): restore sticky positioning on agent live card wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move sticky/top-4/z-10 from individual SingleAgentLiveCard to the parent wrapper div. CSS sticky is constrained by the parent's bounds — when each card was sticky inside a wrapper whose height equaled the cards themselves, there was no room to stick, breaking the behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../issues/components/agent-live-card.tsx | 178 +++++++++++------- .../issues/components/issue-detail.tsx | 75 +++----- apps/web/shared/api/client.ts | 2 +- 3 files changed, 137 insertions(+), 118 deletions(-) diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index a7d2ca34b..ffc0e2de4 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -95,49 +95,51 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { return items.sort((a, b) => a.seq - b.seq); } -// ─── AgentLiveCard (real-time view) ──────────────────────────────────────── +// ─── Per-task state ───────────────────────────────────────────────────────── + +interface TaskState { + task: AgentTask; + items: TimelineItem[]; +} + +// ─── AgentLiveCard (real-time view for multiple agents) ─────────────────── interface AgentLiveCardProps { issueId: string; - agentName?: string; /** Scroll container ref — used to auto-collapse timeline on outer scroll. */ scrollContainerRef?: React.RefObject; } -export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) { +export function AgentLiveCard({ issueId, scrollContainerRef }: AgentLiveCardProps) { const { getActorName } = useActorName(); - const [activeTask, setActiveTask] = useState(null); - const [items, setItems] = useState([]); - const [elapsed, setElapsed] = useState(""); - const [open, setOpen] = useState(false); - const [autoScroll, setAutoScroll] = useState(true); - const [cancelling, setCancelling] = useState(false); - const scrollRef = useRef(null); - const ignoreScrollRef = useRef(false); + const [taskStates, setTaskStates] = useState>(new Map()); const seenSeqs = useRef(new Set()); - // Check for active task on mount + // Fetch active tasks on mount useEffect(() => { let cancelled = false; - api.getActiveTaskForIssue(issueId).then(({ task }) => { - if (!cancelled) { - setActiveTask(task); - if (task) { - api.listTaskMessages(task.id).then((msgs) => { - if (!cancelled) { - const timeline = buildTimeline(msgs); - setItems(timeline); - for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`); - } - }).catch(console.error); + api.getActiveTasksForIssue(issueId).then(({ tasks }) => { + if (cancelled || tasks.length === 0) return; + const newStates = new Map(); + const loadPromises = tasks.map(async (task) => { + try { + const msgs = await api.listTaskMessages(task.id); + const timeline = buildTimeline(msgs); + for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`); + newStates.set(task.id, { task, items: timeline }); + } catch { + newStates.set(task.id, { task, items: [] }); } - } + }); + Promise.all(loadPromises).then(() => { + if (!cancelled) setTaskStates(newStates); + }); }).catch(console.error); return () => { cancelled = true; }; }, [issueId]); - // Handle real-time task messages + // Handle real-time task messages — route by task_id useWSEvent( "task:message", useCallback((payload: unknown) => { @@ -147,64 +149,109 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL if (seenSeqs.current.has(key)) return; seenSeqs.current.add(key); - setItems((prev) => { - const item: TimelineItem = { - seq: msg.seq, - type: msg.type, - tool: msg.tool, - content: msg.content, - input: msg.input, - output: msg.output, - }; - const next = [...prev, item]; - next.sort((a, b) => a.seq - b.seq); + const item: TimelineItem = { + seq: msg.seq, + type: msg.type, + tool: msg.tool, + content: msg.content, + input: msg.input, + output: msg.output, + }; + + setTaskStates((prev) => { + const next = new Map(prev); + const existing = next.get(msg.task_id); + if (existing) { + const items = [...existing.items, item].sort((a, b) => a.seq - b.seq); + next.set(msg.task_id, { ...existing, items }); + } + // If we don't have this task yet, the dispatch handler will pick it up return next; }); }, [issueId]), ); - // Handle task completion/failure/cancellation + // Handle task end events — remove only the specific task const handleTaskEnd = useCallback((payload: unknown) => { - const p = payload as { issue_id: string }; + const p = payload as { task_id: string; issue_id: string }; if (p.issue_id !== issueId) return; - setActiveTask(null); - setItems([]); - seenSeqs.current.clear(); - setCancelling(false); - setOpen(false); + setTaskStates((prev) => { + const next = new Map(prev); + next.delete(p.task_id); + return next; + }); }, [issueId]); useWSEvent("task:completed", handleTaskEnd); useWSEvent("task:failed", handleTaskEnd); useWSEvent("task:cancelled", handleTaskEnd); - // Pick up new tasks + // Pick up newly dispatched tasks useWSEvent( "task:dispatch", useCallback(() => { - if (activeTask) return; - api.getActiveTaskForIssue(issueId).then(({ task }) => { - if (task) { - setActiveTask(task); - setItems([]); - seenSeqs.current.clear(); - setOpen(false); - } + api.getActiveTasksForIssue(issueId).then(({ tasks }) => { + setTaskStates((prev) => { + const next = new Map(prev); + for (const task of tasks) { + if (!next.has(task.id)) { + next.set(task.id, { task, items: [] }); + } + } + return next; + }); }).catch(console.error); - }, [issueId, activeTask]), + }, [issueId]), ); + if (taskStates.size === 0) return null; + + const entries = Array.from(taskStates.values()); + + return ( +
+ {entries.map(({ task, items }) => ( + + ))} +
+ ); +} + +// ─── SingleAgentLiveCard (one card per running task) ────────────────────── + +interface SingleAgentLiveCardProps { + task: AgentTask; + items: TimelineItem[]; + issueId: string; + agentName: string; + scrollContainerRef?: React.RefObject; +} + +function SingleAgentLiveCard({ task, items, issueId, agentName, scrollContainerRef }: SingleAgentLiveCardProps) { + const [elapsed, setElapsed] = useState(""); + const [open, setOpen] = useState(false); + const [autoScroll, setAutoScroll] = useState(true); + const [cancelling, setCancelling] = useState(false); + const scrollRef = useRef(null); + const ignoreScrollRef = useRef(false); + // Elapsed time useEffect(() => { - if (!activeTask?.started_at && !activeTask?.dispatched_at) return; - const startRef = activeTask.started_at ?? activeTask.dispatched_at!; + if (!task.started_at && !task.dispatched_at) return; + const startRef = task.started_at ?? task.dispatched_at!; setElapsed(formatElapsed(startRef)); const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000); return () => clearInterval(interval); - }, [activeTask?.started_at, activeTask?.dispatched_at]); + }, [task.started_at, task.dispatched_at]); // Auto-collapse timeline when outer scroll container scrolls - // (ignoreScrollRef prevents layout-induced scroll from collapsing right after expand) useEffect(() => { const container = scrollContainerRef?.current; if (!container) return; @@ -240,23 +287,20 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL }, [open]); const handleCancel = useCallback(async () => { - if (!activeTask || cancelling) return; + if (cancelling) return; setCancelling(true); try { - await api.cancelTask(issueId, activeTask.id); + await api.cancelTask(issueId, task.id); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to cancel task"); setCancelling(false); } - }, [activeTask, issueId, cancelling]); - - if (!activeTask) return null; + }, [task.id, issueId, cancelling]); const toolCount = items.filter((i) => i.type === "tool_use").length; - const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"; return ( -
+
{/* Header — click to toggle timeline */}
- {activeTask.agent_id ? ( - + {task.agent_id ? ( + ) : (
@@ -280,7 +324,7 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL )}
- {name} is working + {agentName} is working {elapsed} {toolCount > 0 && ( {toolCount} tools diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 43c388850..6f1305877 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -63,10 +63,13 @@ import { StatusIcon, PriorityIcon, DueDatePicker, AssigneePicker, canAssignAgent import { CommentCard } from "./comment-card"; import { CommentInput } from "./comment-input"; import { AgentLiveCard, TaskRunHistory } from "./agent-live-card"; -import { api } from "@/shared/api"; +import { useQuery } from "@tanstack/react-query"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore, useActorName } from "@/features/workspace"; -import { useIssueStore } from "@/features/issues"; +import { useWorkspaceId } from "@core/hooks"; +import { issueListOptions, issueDetailOptions } from "@core/issues/queries"; +import { memberListOptions, agentListOptions } from "@core/workspace/queries"; +import { useUpdateIssue, useDeleteIssue } from "@core/issues/mutations"; import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline"; import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions"; import { useIssueSubscribers } from "@/features/issues/hooks/use-issue-subscribers"; @@ -175,12 +178,13 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const router = useRouter(); const user = useAuthStore((s) => s.user); const workspace = useWorkspaceStore((s) => s.workspace); - const members = useWorkspaceStore((s) => s.members); - const agents = useWorkspaceStore((s) => s.agents); - const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role; - // Issue navigation - const allIssues = useIssueStore((s) => s.issues); + // Issue navigation — read from TQ list cache + const wsId = useWorkspaceId(); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const currentMemberRole = members.find((m) => m.user_id === user?.id)?.role; + const { data: allIssues = [] } = useQuery(issueListOptions(wsId)); const currentIndex = allIssues.findIndex((i) => i.id === id); const prevIssue = currentIndex > 0 ? allIssues[currentIndex - 1] : null; const nextIssue = currentIndex < allIssues.length - 1 ? allIssues[currentIndex + 1] : null; @@ -200,38 +204,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo const [highlightedId, setHighlightedId] = useState(null); const didHighlightRef = useRef(null); - // Single source of truth: read issue directly from global store - const issue = useIssueStore((s) => s.issues.find((i) => i.id === id)) ?? null; - const [issueLoading, setIssueLoading] = useState(!issue); - - // If issue isn't in the store yet, fetch and upsert it. - // loadedIdRef tracks which issue was already loaded — if it disappears - // from the store (workspace switch clears all issues), skip refetch. - const loadedIdRef = useRef(null); - useEffect(() => { - if (issue) { - loadedIdRef.current = id; - setIssueLoading(false); - return; - } - // Issue was loaded for this id but vanished → store cleared (workspace switch) - if (loadedIdRef.current === id) { - loadedIdRef.current = null; - return; - } - // Issue not in store → fetch it - setIssueLoading(true); - api - .getIssue(id) - .then((iss) => { - useIssueStore.getState().addIssue(iss); - }) - .catch((e) => { - console.error(e); - toast.error("Failed to load issue"); - }) - .finally(() => setIssueLoading(false)); - }, [id, !!issue]); + // Issue data from TQ — uses detail query, seeded from list cache if available + const { data: issue = null, isLoading: issueLoading } = useQuery({ + ...issueDetailOptions(wsId, id), + initialData: () => allIssues.find((i) => i.id === id), + }); // Custom hooks — encapsulate timeline, reactions, subscribers const { @@ -283,18 +260,17 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo scrollContainerRef.current?.scrollTo({ top: scrollContainerRef.current.scrollHeight, behavior: "smooth" }); }, []); - // Issue field updates — write directly to the global store (single source of truth) + // Issue field updates via TQ mutation (optimistic update + rollback in mutation hook) + const updateIssueMutation = useUpdateIssue(); const handleUpdateField = useCallback( (updates: Partial) => { if (!issue) return; - const prev = { ...issue }; - useIssueStore.getState().updateIssue(id, updates); - api.updateIssue(id, updates).catch(() => { - useIssueStore.getState().updateIssue(id, prev); - toast.error("Failed to update issue"); - }); + updateIssueMutation.mutate( + { id, ...updates }, + { onError: () => toast.error("Failed to update issue") }, + ); }, - [issue, id], + [issue, id, updateIssueMutation], ); const descEditorRef = useRef(null); @@ -303,11 +279,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo [uploadWithToast, id], ); + const deleteIssueMutation = useDeleteIssue(); const handleDelete = async () => { setDeleting(true); try { - await api.deleteIssue(issue!.id); - useIssueStore.getState().removeIssue(issue!.id); + await deleteIssueMutation.mutateAsync(issue!.id); toast.success("Issue deleted"); if (onDelete) onDelete(); else router.push("/issues"); @@ -783,7 +759,6 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {/* Agent live output */} diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 2c3a42077..900ba8a25 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -380,7 +380,7 @@ export class ApiClient { return this.fetch(`/api/agents/${agentId}/tasks`); } - async getActiveTaskForIssue(issueId: string): Promise<{ task: AgentTask | null }> { + async getActiveTasksForIssue(issueId: string): Promise<{ tasks: AgentTask[] }> { return this.fetch(`/api/issues/${issueId}/active-task`); }