UI: richer toasts, log viewer scroll fix, multi-goal projects, active panel issue context
Improve activity toasts with actor names, issue identifiers, and action links. Fix LogViewer auto-scroll to work with scrollable parent containers instead of only window. Add issue context display to ActiveAgentsPanel run cards. Support multi-goal selection in NewProjectDialog. Update GoalDetail to match multi-goal project linking. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, Issue>();
|
||||
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) {
|
||||
<AgentRunCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
issue={run.issueId ? issueById.get(run.issueId) : undefined}
|
||||
feed={feedByRun.get(run.id) ?? []}
|
||||
/>
|
||||
))}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const recent = feed.slice(-20);
|
||||
|
||||
@@ -331,6 +355,20 @@ function AgentRunCard({ run, feed }: { run: LiveRunForIssue; feed: FeedItem[] })
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{run.issueId && (
|
||||
<div className="px-3 py-1.5 border-b border-border/40 text-xs flex items-center gap-1 min-w-0">
|
||||
<span className="text-muted-foreground mr-1">Working on:</span>
|
||||
<Link
|
||||
to={`/issues/${run.issueId}`}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline min-w-0 truncate"
|
||||
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
|
||||
>
|
||||
{issue?.identifier ?? run.issueId.slice(0, 8)}
|
||||
{issue?.title ? ` - ${issue.title}` : ""}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bodyRef} className="max-h-[180px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
|
||||
{recent.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">Waiting for output...</div>
|
||||
|
||||
@@ -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}` },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
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 (
|
||||
<Dialog
|
||||
@@ -206,36 +208,60 @@ export function NewProjectDialog() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Goal */}
|
||||
{selectedGoals.map((goal) => (
|
||||
<span
|
||||
key={goal.id}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
|
||||
>
|
||||
<Target className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="max-w-[160px] truncate">{goal.title}</span>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setGoalIds((prev) => prev.filter((id) => id !== goal.id))}
|
||||
aria-label={`Remove goal ${goal.title}`}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
|
||||
<Target className="h-3 w-3 text-muted-foreground" />
|
||||
{currentGoal ? currentGoal.title : "Goal"}
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors disabled:opacity-60"
|
||||
disabled={selectedGoals.length > 0 && availableGoals.length === 0}
|
||||
>
|
||||
{selectedGoals.length > 0 ? <Plus className="h-3 w-3 text-muted-foreground" /> : <Target className="h-3 w-3 text-muted-foreground" />}
|
||||
{selectedGoals.length > 0 ? "+ Goal" : "Goal"}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-48 p-1" align="start">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
!goalId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setGoalId(""); setGoalOpen(false); }}
|
||||
>
|
||||
No goal
|
||||
</button>
|
||||
{(goals ?? []).map((g) => (
|
||||
<PopoverContent className="w-56 p-1" align="start">
|
||||
{selectedGoals.length === 0 && (
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||
onClick={() => setGoalOpen(false)}
|
||||
>
|
||||
No goal
|
||||
</button>
|
||||
)}
|
||||
{availableGoals.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
||||
g.id === goalId && "bg-accent"
|
||||
)}
|
||||
onClick={() => { setGoalId(g.id); setGoalOpen(false); }}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate"
|
||||
onClick={() => {
|
||||
setGoalIds((prev) => [...prev, g.id]);
|
||||
setGoalOpen(false);
|
||||
}}
|
||||
>
|
||||
{g.title}
|
||||
</button>
|
||||
))}
|
||||
{selectedGoals.length > 0 && availableGoals.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
All goals already selected.
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
|
||||
@@ -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<string, unknown> | null,
|
||||
): IssueToastContext {
|
||||
const detailIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId));
|
||||
const listIssue = queryClient
|
||||
.getQueryData<Issue[]>(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<string, unknown> | 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<string, unknown>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,60 @@ const sourceLabels: Record<string, string> = {
|
||||
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<string | null>(null);
|
||||
const [logOffset, setLogOffset] = useState(0);
|
||||
const [isFollowing, setIsFollowing] = useState(true);
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const pendingLogLineRef = useRef("");
|
||||
const scrollContainerRef = useRef<ScrollContainer | null>(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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -66,6 +66,11 @@ function usageNumber(usage: Record<string, unknown> | 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<string, unknown> | null): string {
|
||||
if (action === "issue.updated" && details) {
|
||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||
@@ -270,10 +275,13 @@ export function IssueDetail() {
|
||||
mutationFn: (data: Record<string, unknown>) => 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user