mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user