diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 35551b80..36f74fc7 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -1,8 +1,9 @@ import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import type { LiveEvent } from "@paperclip/shared"; +import type { Issue, LiveEvent } from "@paperclip/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; +import { issuesApi } from "../api/issues"; import { getUIAdapter } from "../adapters"; import type { TranscriptEntry } from "../adapters"; import { queryKeys } from "../lib/queryKeys"; @@ -152,6 +153,20 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { }); const runs = liveRuns ?? []; + const { data: issues } = useQuery({ + queryKey: queryKeys.issues.list(companyId), + queryFn: () => issuesApi.list(companyId), + enabled: runs.length > 0, + }); + + const issueById = useMemo(() => { + const map = new Map(); + for (const issue of issues ?? []) { + map.set(issue.id, issue); + } + return map; + }, [issues]); + const runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]); const activeRunIds = useMemo(() => new Set(runs.map((r) => r.id)), [runs]); @@ -290,6 +305,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { ))} @@ -298,7 +314,15 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { ); } -function AgentRunCard({ run, feed }: { run: LiveRunForIssue; feed: FeedItem[] }) { +function AgentRunCard({ + run, + issue, + feed, +}: { + run: LiveRunForIssue; + issue?: Issue; + feed: FeedItem[]; +}) { const bodyRef = useRef(null); const recent = feed.slice(-20); @@ -331,6 +355,20 @@ function AgentRunCard({ run, feed }: { run: LiveRunForIssue; feed: FeedItem[] }) + {run.issueId && ( +
+ Working on: + + {issue?.identifier ?? run.issueId.slice(0, 8)} + {issue?.title ? ` - ${issue.title}` : ""} + +
+ )} +
{recent.length === 0 && (
Waiting for output...
diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index d0e81a87..4a378928 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -130,7 +130,7 @@ export function NewIssueDialog() { title: `${issue.identifier ?? "Issue"} created`, body: issue.title, tone: "success", - action: { label: "View issue", href: `/issues/${issue.id}` }, + action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.id}` }, }); }, }); diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 5900fef4..1e6c1065 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -21,11 +21,12 @@ import { Minimize2, Target, Calendar, + Plus, + X, } from "lucide-react"; import { cn } from "../lib/utils"; import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; import { StatusBadge } from "./StatusBadge"; -import type { Goal } from "@paperclip/shared"; const projectStatuses = [ { value: "backlog", label: "Backlog" }, @@ -42,7 +43,7 @@ export function NewProjectDialog() { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [status, setStatus] = useState("planned"); - const [goalId, setGoalId] = useState(""); + const [goalIds, setGoalIds] = useState([]); const [targetDate, setTargetDate] = useState(""); const [expanded, setExpanded] = useState(false); @@ -77,7 +78,7 @@ export function NewProjectDialog() { setName(""); setDescription(""); setStatus("planned"); - setGoalId(""); + setGoalIds([]); setTargetDate(""); setExpanded(false); } @@ -88,7 +89,7 @@ export function NewProjectDialog() { name: name.trim(), description: description.trim() || undefined, status, - ...(goalId ? { goalId } : {}), + ...(goalIds.length > 0 ? { goalIds } : {}), ...(targetDate ? { targetDate } : {}), }); } @@ -100,7 +101,8 @@ export function NewProjectDialog() { } } - const currentGoal = (goals ?? []).find((g) => g.id === goalId); + const selectedGoals = (goals ?? []).filter((g) => goalIds.includes(g.id)); + const availableGoals = (goals ?? []).filter((g) => !goalIds.includes(g.id)); return ( - {/* Goal */} + {selectedGoals.map((goal) => ( + + + {goal.title} + + + ))} + - - - - {(goals ?? []).map((g) => ( + + {selectedGoals.length === 0 && ( + + )} + {availableGoals.map((g) => ( ))} + {selectedGoals.length > 0 && availableGoals.length === 0 && ( +
+ All goals already selected. +
+ )}
diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 9b3d8a02..8c211b17 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, type ReactNode } from "react"; import { useQueryClient, type QueryClient } from "@tanstack/react-query"; -import type { Agent, LiveEvent } from "@paperclip/shared"; +import type { Agent, Issue, LiveEvent } from "@paperclip/shared"; import { useCompany } from "./CompanyContext"; import type { ToastInput } from "./ToastContext"; import { useToast } from "./ToastContext"; @@ -39,6 +39,71 @@ function truncate(text: string, max: number): string { return text.slice(0, max - 1) + "\u2026"; } +function looksLikeUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function titleCase(value: string): string { + return value + .split(" ") + .filter((part) => part.length > 0) + .map((part) => part[0]!.toUpperCase() + part.slice(1)) + .join(" "); +} + +function resolveActorLabel( + queryClient: QueryClient, + companyId: string, + actorType: string | null, + actorId: string | null, +): string { + if (actorType === "agent" && actorId) { + return resolveAgentName(queryClient, companyId, actorId) ?? `Agent ${shortId(actorId)}`; + } + if (actorType === "system") return "System"; + if (actorType === "user" && actorId) { + if (looksLikeUuid(actorId)) return `User ${shortId(actorId)}`; + return titleCase(actorId.replace(/[_-]+/g, " ")); + } + return "Someone"; +} + +interface IssueToastContext { + ref: string; + title: string | null; + label: string; + href: string; +} + +function resolveIssueToastContext( + queryClient: QueryClient, + companyId: string, + issueId: string, + details: Record | null, +): IssueToastContext { + const detailIssue = queryClient.getQueryData(queryKeys.issues.detail(issueId)); + const listIssue = queryClient + .getQueryData(queryKeys.issues.list(companyId)) + ?.find((issue) => issue.id === issueId); + const cachedIssue = detailIssue ?? listIssue ?? null; + const ref = + readString(details?.identifier) ?? + readString(details?.issueIdentifier) ?? + cachedIssue?.identifier ?? + `Issue ${shortId(issueId)}`; + const title = + readString(details?.title) ?? + readString(details?.issueTitle) ?? + cachedIssue?.title ?? + null; + return { + ref, + title, + label: title ? `${ref} - ${truncate(title, 72)}` : ref, + href: `/issues/${issueId}`, + }; +} + const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); const AGENT_TOAST_STATUSES = new Set(["running", "idle", "error"]); const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]); @@ -46,17 +111,24 @@ const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "canc function describeIssueUpdate(details: Record | null): string | null { if (!details) return null; const changes: string[] = []; - if (typeof details.status === "string") changes.push(`status \u2192 ${details.status}`); - if (typeof details.priority === "string") changes.push(`priority \u2192 ${details.priority}`); + if (typeof details.status === "string") changes.push(`status -> ${details.status.replace(/_/g, " ")}`); + if (typeof details.priority === "string") changes.push(`priority -> ${details.priority}`); if (typeof details.assigneeAgentId === "string") changes.push("reassigned"); else if (details.assigneeAgentId === null) changes.push("unassigned"); + if (details.reopened === true) { + const from = readString(details.reopenedFrom); + changes.push(from ? `reopened from ${from.replace(/_/g, " ")}` : "reopened"); + } + if (typeof details.title === "string") changes.push("title changed"); + if (typeof details.description === "string") changes.push("description changed"); if (changes.length > 0) return changes.join(", "); return null; } function buildActivityToast( + queryClient: QueryClient, + companyId: string, payload: Record, - nameOf: (id: string) => string | null, ): ToastInput | null { const entityType = readString(payload.entityType); const entityId = readString(payload.entityId); @@ -69,43 +141,43 @@ function buildActivityToast( return null; } - const issueHref = `/issues/${entityId}`; - const issueTitle = details?.title && typeof details.title === "string" - ? truncate(details.title, 60) - : null; - const actorName = actorType === "agent" && actorId ? nameOf(actorId) : null; - const byLine = actorName ? ` by ${actorName}` : ""; + const issue = resolveIssueToastContext(queryClient, companyId, entityId, details); + const actor = resolveActorLabel(queryClient, companyId, actorType, actorId); if (action === "issue.created") { return { - title: `Issue created${byLine}`, - body: issueTitle ?? `Issue ${shortId(entityId)}`, + title: `${actor} created ${issue.ref}`, + body: issue.title ? truncate(issue.title, 96) : undefined, tone: "success", - action: { label: "Open issue", href: issueHref }, + action: { label: `View ${issue.ref}`, href: issue.href }, dedupeKey: `activity:${action}:${entityId}`, }; } if (action === "issue.updated") { const changeDesc = describeIssueUpdate(details); - const label = issueTitle ?? `Issue ${shortId(entityId)}`; - const body = changeDesc ? `${label} \u2014 ${changeDesc}` : label; + const body = changeDesc + ? issue.title + ? `${truncate(issue.title, 64)} - ${changeDesc}` + : changeDesc + : issue.title + ? truncate(issue.title, 96) + : issue.label; return { - title: `Issue updated${byLine}`, + title: `${actor} updated ${issue.ref}`, body: truncate(body, 100), tone: "info", - action: { label: "Open issue", href: issueHref }, + action: { label: `View ${issue.ref}`, href: issue.href }, dedupeKey: `activity:${action}:${entityId}`, }; } const commentId = readString(details?.commentId); - const issueLabel = issueTitle ?? `Issue ${shortId(entityId)}`; return { - title: `New comment${byLine}`, - body: issueLabel, + title: `${actor} posted a comment on ${issue.ref}`, + body: issue.title ? truncate(issue.title, 96) : undefined, tone: "info", - action: { label: "Open issue", href: issueHref }, + action: { label: `View ${issue.ref}`, href: issue.href }, dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`, }; } @@ -324,7 +396,7 @@ function handleLiveEvent( if (event.type === "activity.logged") { invalidateActivityQueries(queryClient, expectedCompanyId, payload); const action = readString(payload.action); - const toast = buildActivityToast(payload, nameOf); + const toast = buildActivityToast(queryClient, expectedCompanyId, payload); if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast); } } diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 7b01f065..2e28d6f2 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -112,6 +112,60 @@ const sourceLabels: Record = { automation: "Automation", }; +const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32; +type ScrollContainer = Window | HTMLElement; + +function isWindowContainer(container: ScrollContainer): container is Window { + return container === window; +} + +function isElementScrollContainer(element: HTMLElement): boolean { + const overflowY = window.getComputedStyle(element).overflowY; + return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay"; +} + +function findScrollContainer(anchor: HTMLElement | null): ScrollContainer { + let parent = anchor?.parentElement ?? null; + while (parent) { + if (isElementScrollContainer(parent)) return parent; + parent = parent.parentElement; + } + return window; +} + +function readScrollMetrics(container: ScrollContainer): { scrollHeight: number; distanceFromBottom: number } { + if (isWindowContainer(container)) { + const pageHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + ); + const viewportBottom = window.scrollY + window.innerHeight; + return { + scrollHeight: pageHeight, + distanceFromBottom: Math.max(0, pageHeight - viewportBottom), + }; + } + + const viewportBottom = container.scrollTop + container.clientHeight; + return { + scrollHeight: container.scrollHeight, + distanceFromBottom: Math.max(0, container.scrollHeight - viewportBottom), + }; +} + +function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBehavior = "auto") { + if (isWindowContainer(container)) { + const pageHeight = Math.max( + document.documentElement.scrollHeight, + document.body.scrollHeight, + ); + window.scrollTo({ top: pageHeight, behavior }); + return; + } + + container.scrollTo({ top: container.scrollHeight, behavior }); +} + type AgentDetailTab = "overview" | "configuration" | "runs" | "issues" | "costs" | "keys"; function parseAgentDetailTab(value: string | null): AgentDetailTab { @@ -1200,9 +1254,15 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin const [logLoading, setLogLoading] = useState(!!run.logRef); const [logError, setLogError] = useState(null); const [logOffset, setLogOffset] = useState(0); - const [isFollowing, setIsFollowing] = useState(true); + const [isFollowing, setIsFollowing] = useState(false); const logEndRef = useRef(null); const pendingLogLineRef = useRef(""); + const scrollContainerRef = useRef(null); + const isFollowingRef = useRef(false); + const lastMetricsRef = useRef<{ scrollHeight: number; distanceFromBottom: number }>({ + scrollHeight: 0, + distanceFromBottom: Number.POSITIVE_INFINITY, + }); const isLive = run.status === "running" || run.status === "queued"; function appendLogContent(content: string, finalize = false) { @@ -1250,39 +1310,86 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin } }, [initialEvents]); - const updateFollowingState = useCallback(() => { - const viewportBottom = window.scrollY + window.innerHeight; - const pageHeight = Math.max( - document.documentElement.scrollHeight, - document.body.scrollHeight, - ); - const distanceFromBottom = pageHeight - viewportBottom; - const isNearBottom = distanceFromBottom <= 32; - setIsFollowing((prev) => (prev === isNearBottom ? prev : isNearBottom)); + const getScrollContainer = useCallback((): ScrollContainer => { + if (scrollContainerRef.current) return scrollContainerRef.current; + const container = findScrollContainer(logEndRef.current); + scrollContainerRef.current = container; + return container; }, []); + const updateFollowingState = useCallback(() => { + const container = getScrollContainer(); + const metrics = readScrollMetrics(container); + lastMetricsRef.current = metrics; + const nearBottom = metrics.distanceFromBottom <= LIVE_SCROLL_BOTTOM_TOLERANCE_PX; + isFollowingRef.current = nearBottom; + setIsFollowing((prev) => (prev === nearBottom ? prev : nearBottom)); + }, [getScrollContainer]); + useEffect(() => { - if (!isLive) return; - setIsFollowing(true); - }, [isLive, run.id]); + scrollContainerRef.current = null; + lastMetricsRef.current = { + scrollHeight: 0, + distanceFromBottom: Number.POSITIVE_INFINITY, + }; + + if (!isLive) { + isFollowingRef.current = false; + setIsFollowing(false); + return; + } + + updateFollowingState(); + }, [isLive, run.id, updateFollowingState]); useEffect(() => { if (!isLive) return; + const container = getScrollContainer(); updateFollowingState(); - window.addEventListener("scroll", updateFollowingState, { passive: true }); + + if (container === window) { + window.addEventListener("scroll", updateFollowingState, { passive: true }); + } else { + container.addEventListener("scroll", updateFollowingState, { passive: true }); + } window.addEventListener("resize", updateFollowingState); return () => { - window.removeEventListener("scroll", updateFollowingState); + if (container === window) { + window.removeEventListener("scroll", updateFollowingState); + } else { + container.removeEventListener("scroll", updateFollowingState); + } window.removeEventListener("resize", updateFollowingState); }; - }, [isLive, updateFollowingState]); + }, [isLive, run.id, getScrollContainer, updateFollowingState]); // Auto-scroll only for live runs when following useEffect(() => { - if (isLive && isFollowing) { - logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + if (!isLive || !isFollowingRef.current) return; + + const container = getScrollContainer(); + const previous = lastMetricsRef.current; + const current = readScrollMetrics(container); + const growth = Math.max(0, current.scrollHeight - previous.scrollHeight); + const expectedDistance = previous.distanceFromBottom + growth; + const movedAwayBy = current.distanceFromBottom - expectedDistance; + + // If user moved away from bottom between updates, release auto-follow immediately. + if (movedAwayBy > LIVE_SCROLL_BOTTOM_TOLERANCE_PX) { + isFollowingRef.current = false; + setIsFollowing(false); + lastMetricsRef.current = current; + return; } - }, [events, logLines, isLive, isFollowing]); + + scrollToContainerBottom(container, "auto"); + const after = readScrollMetrics(container); + lastMetricsRef.current = after; + if (!isFollowingRef.current) { + isFollowingRef.current = true; + } + setIsFollowing((prev) => (prev ? prev : true)); + }, [events.length, logLines.length, isLive, getScrollContainer]); // Fetch persisted shell log useEffect(() => { @@ -1463,8 +1570,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin variant="ghost" size="xs" onClick={() => { + const container = getScrollContainer(); + isFollowingRef.current = true; setIsFollowing(true); - logEndRef.current?.scrollIntoView({ behavior: "smooth" }); + scrollToContainerBottom(container, "auto"); + lastMetricsRef.current = readScrollMetrics(container); }} > Jump to live diff --git a/ui/src/pages/GoalDetail.tsx b/ui/src/pages/GoalDetail.tsx index 155e3bdd..6d36aa58 100644 --- a/ui/src/pages/GoalDetail.tsx +++ b/ui/src/pages/GoalDetail.tsx @@ -64,7 +64,12 @@ export function GoalDetail() { }); const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId); - const linkedProjects = (allProjects ?? []).filter((p) => p.goalId === goalId); + const linkedProjects = (allProjects ?? []).filter((p) => { + if (!goalId) return false; + if (p.goalIds.includes(goalId)) return true; + if (p.goals.some((goalRef) => goalRef.id === goalId)) return true; + return p.goalId === goalId; + }); useEffect(() => { setBreadcrumbs([ diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 5f2ffd9b..57f057ed 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -66,6 +66,11 @@ function usageNumber(usage: Record | null, ...keys: string[]) { return 0; } +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, max - 1) + "\u2026"; +} + function formatAction(action: string, details?: Record | null): string { if (action === "issue.updated" && details) { const previous = (details._previous ?? {}) as Record; @@ -270,10 +275,13 @@ export function IssueDetail() { mutationFn: (data: Record) => issuesApi.update(issueId!, data), onSuccess: (updated) => { invalidateIssue(); + const issueRef = updated.identifier ?? `Issue ${updated.id.slice(0, 8)}`; pushToast({ dedupeKey: `activity:issue.updated:${updated.id}`, - title: "Issue updated", + title: `${issueRef} updated`, + body: truncate(updated.title, 96), tone: "success", + action: { label: `View ${issueRef}`, href: `/issues/${updated.id}` }, }); }, }); @@ -281,13 +289,16 @@ export function IssueDetail() { const addComment = useMutation({ mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) => issuesApi.addComment(issueId!, body, reopen), - onSuccess: () => { + onSuccess: (comment) => { invalidateIssue(); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) }); + const issueRef = issue?.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue"); pushToast({ - dedupeKey: `activity:issue.comment_added:${issueId}`, - title: "Comment posted", + dedupeKey: `activity:issue.comment_added:${issueId}:${comment.id}`, + title: `Comment posted on ${issueRef}`, + body: issue?.title ? truncate(issue.title, 96) : undefined, tone: "success", + action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issueId}` } : undefined, }); }, });