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:
Forgotten
2026-02-20 15:48:42 -06:00
parent 82da8739c1
commit 65f09a1a9d
7 changed files with 337 additions and 75 deletions

View File

@@ -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

View File

@@ -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([

View File

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