fix(web): restore sticky positioning on agent live card wrapper

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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-08 14:18:14 +08:00
parent 7c79611309
commit 346edb2fa2
3 changed files with 137 additions and 118 deletions

View File

@@ -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<HTMLDivElement | null>;
}
export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) {
export function AgentLiveCard({ issueId, scrollContainerRef }: AgentLiveCardProps) {
const { getActorName } = useActorName();
const [activeTask, setActiveTask] = useState<AgentTask | null>(null);
const [items, setItems] = useState<TimelineItem[]>([]);
const [elapsed, setElapsed] = useState("");
const [open, setOpen] = useState(false);
const [autoScroll, setAutoScroll] = useState(true);
const [cancelling, setCancelling] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const ignoreScrollRef = useRef(false);
const [taskStates, setTaskStates] = useState<Map<string, TaskState>>(new Map());
const seenSeqs = useRef(new Set<string>());
// 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<string, TaskState>();
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 (
<div className="mt-4 sticky top-4 z-10 space-y-2">
{entries.map(({ task, items }) => (
<SingleAgentLiveCard
key={task.id}
task={task}
items={items}
issueId={issueId}
agentName={task.agent_id ? getActorName("agent", task.agent_id) : "Agent"}
scrollContainerRef={scrollContainerRef}
/>
))}
</div>
);
}
// ─── SingleAgentLiveCard (one card per running task) ──────────────────────
interface SingleAgentLiveCardProps {
task: AgentTask;
items: TimelineItem[];
issueId: string;
agentName: string;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
}
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<HTMLDivElement>(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 (
<div className="mt-4 sticky top-4 z-10 rounded-lg border border-info/20 bg-info/5 backdrop-blur-sm">
<div className="rounded-lg border border-info/20 bg-info/5 backdrop-blur-sm">
{/* Header — click to toggle timeline */}
<div
className="group flex items-center gap-2 px-3 py-2 cursor-pointer select-none text-muted-foreground hover:text-foreground transition-colors"
@@ -271,8 +315,8 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL
}
}}
>
{activeTask.agent_id ? (
<ActorAvatar actorType="agent" actorId={activeTask.agent_id} size={20} />
{task.agent_id ? (
<ActorAvatar actorType="agent" actorId={task.agent_id} size={20} />
) : (
<div className="flex items-center justify-center h-5 w-5 rounded-full shrink-0 bg-info/10 text-info">
<Bot className="h-3 w-3" />
@@ -280,7 +324,7 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL
)}
<div className="flex items-center gap-1.5 text-xs min-w-0">
<Loader2 className="h-3 w-3 animate-spin text-info shrink-0" />
<span className="font-medium text-foreground truncate">{name} is working</span>
<span className="font-medium text-foreground truncate">{agentName} is working</span>
<span className="text-muted-foreground tabular-nums shrink-0">{elapsed}</span>
{toolCount > 0 && (
<span className="text-muted-foreground shrink-0">{toolCount} tools</span>

View File

@@ -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<string | null>(null);
const didHighlightRef = useRef<string | null>(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<string | null>(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<UpdateIssueRequest>) => {
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<ContentEditorRef>(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 */}
<AgentLiveCard
issueId={id}
agentName={issue.assignee_type === "agent" && issue.assignee_id ? getActorName("agent", issue.assignee_id) : undefined}
scrollContainerRef={scrollContainerRef}
/>

View File

@@ -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`);
}