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 { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
|
||||||
|
import { issuesApi } from "../api/issues";
|
||||||
import { getUIAdapter } from "../adapters";
|
import { getUIAdapter } from "../adapters";
|
||||||
import type { TranscriptEntry } from "../adapters";
|
import type { TranscriptEntry } from "../adapters";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
@@ -152,6 +153,20 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const runs = liveRuns ?? [];
|
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 runById = useMemo(() => new Map(runs.map((r) => [r.id, r])), [runs]);
|
||||||
const activeRunIds = useMemo(() => new Set(runs.map((r) => r.id)), [runs]);
|
const activeRunIds = useMemo(() => new Set(runs.map((r) => r.id)), [runs]);
|
||||||
|
|
||||||
@@ -290,6 +305,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
|
|||||||
<AgentRunCard
|
<AgentRunCard
|
||||||
key={run.id}
|
key={run.id}
|
||||||
run={run}
|
run={run}
|
||||||
|
issue={run.issueId ? issueById.get(run.issueId) : undefined}
|
||||||
feed={feedByRun.get(run.id) ?? []}
|
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 bodyRef = useRef<HTMLDivElement>(null);
|
||||||
const recent = feed.slice(-20);
|
const recent = feed.slice(-20);
|
||||||
|
|
||||||
@@ -331,6 +355,20 @@ function AgentRunCard({ run, feed }: { run: LiveRunForIssue; feed: FeedItem[] })
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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">
|
<div ref={bodyRef} className="max-h-[180px] overflow-y-auto p-2 font-mono text-[11px] space-y-1">
|
||||||
{recent.length === 0 && (
|
{recent.length === 0 && (
|
||||||
<div className="text-xs text-muted-foreground">Waiting for output...</div>
|
<div className="text-xs text-muted-foreground">Waiting for output...</div>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function NewIssueDialog() {
|
|||||||
title: `${issue.identifier ?? "Issue"} created`,
|
title: `${issue.identifier ?? "Issue"} created`,
|
||||||
body: issue.title,
|
body: issue.title,
|
||||||
tone: "success",
|
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,
|
Minimize2,
|
||||||
Target,
|
Target,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
import type { Goal } from "@paperclip/shared";
|
|
||||||
|
|
||||||
const projectStatuses = [
|
const projectStatuses = [
|
||||||
{ value: "backlog", label: "Backlog" },
|
{ value: "backlog", label: "Backlog" },
|
||||||
@@ -42,7 +43,7 @@ export function NewProjectDialog() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [status, setStatus] = useState("planned");
|
const [status, setStatus] = useState("planned");
|
||||||
const [goalId, setGoalId] = useState("");
|
const [goalIds, setGoalIds] = useState<string[]>([]);
|
||||||
const [targetDate, setTargetDate] = useState("");
|
const [targetDate, setTargetDate] = useState("");
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export function NewProjectDialog() {
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setStatus("planned");
|
setStatus("planned");
|
||||||
setGoalId("");
|
setGoalIds([]);
|
||||||
setTargetDate("");
|
setTargetDate("");
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
}
|
}
|
||||||
@@ -88,7 +89,7 @@ export function NewProjectDialog() {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
...(goalId ? { goalId } : {}),
|
...(goalIds.length > 0 ? { goalIds } : {}),
|
||||||
...(targetDate ? { targetDate } : {}),
|
...(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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -206,36 +208,60 @@ export function NewProjectDialog() {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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}>
|
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
|
||||||
<PopoverTrigger asChild>
|
<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">
|
<button
|
||||||
<Target className="h-3 w-3 text-muted-foreground" />
|
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"
|
||||||
{currentGoal ? currentGoal.title : "Goal"}
|
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>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-48 p-1" align="start">
|
<PopoverContent className="w-56 p-1" align="start">
|
||||||
<button
|
{selectedGoals.length === 0 && (
|
||||||
className={cn(
|
<button
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
||||||
!goalId && "bg-accent"
|
onClick={() => setGoalOpen(false)}
|
||||||
)}
|
>
|
||||||
onClick={() => { setGoalId(""); setGoalOpen(false); }}
|
No goal
|
||||||
>
|
</button>
|
||||||
No goal
|
)}
|
||||||
</button>
|
{availableGoals.map((g) => (
|
||||||
{(goals ?? []).map((g) => (
|
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={g.id}
|
||||||
className={cn(
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate"
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
onClick={() => {
|
||||||
g.id === goalId && "bg-accent"
|
setGoalIds((prev) => [...prev, g.id]);
|
||||||
)}
|
setGoalOpen(false);
|
||||||
onClick={() => { setGoalId(g.id); setGoalOpen(false); }}
|
}}
|
||||||
>
|
>
|
||||||
{g.title}
|
{g.title}
|
||||||
</button>
|
</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>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
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 { useCompany } from "./CompanyContext";
|
||||||
import type { ToastInput } from "./ToastContext";
|
import type { ToastInput } from "./ToastContext";
|
||||||
import { useToast } from "./ToastContext";
|
import { useToast } from "./ToastContext";
|
||||||
@@ -39,6 +39,71 @@ function truncate(text: string, max: number): string {
|
|||||||
return text.slice(0, max - 1) + "\u2026";
|
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 ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||||
const AGENT_TOAST_STATUSES = new Set(["running", "idle", "error"]);
|
const AGENT_TOAST_STATUSES = new Set(["running", "idle", "error"]);
|
||||||
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
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 {
|
function describeIssueUpdate(details: Record<string, unknown> | null): string | null {
|
||||||
if (!details) return null;
|
if (!details) return null;
|
||||||
const changes: string[] = [];
|
const changes: string[] = [];
|
||||||
if (typeof details.status === "string") changes.push(`status \u2192 ${details.status}`);
|
if (typeof details.status === "string") changes.push(`status -> ${details.status.replace(/_/g, " ")}`);
|
||||||
if (typeof details.priority === "string") changes.push(`priority \u2192 ${details.priority}`);
|
if (typeof details.priority === "string") changes.push(`priority -> ${details.priority}`);
|
||||||
if (typeof details.assigneeAgentId === "string") changes.push("reassigned");
|
if (typeof details.assigneeAgentId === "string") changes.push("reassigned");
|
||||||
else if (details.assigneeAgentId === null) changes.push("unassigned");
|
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(", ");
|
if (changes.length > 0) return changes.join(", ");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildActivityToast(
|
function buildActivityToast(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
companyId: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
nameOf: (id: string) => string | null,
|
|
||||||
): ToastInput | null {
|
): ToastInput | null {
|
||||||
const entityType = readString(payload.entityType);
|
const entityType = readString(payload.entityType);
|
||||||
const entityId = readString(payload.entityId);
|
const entityId = readString(payload.entityId);
|
||||||
@@ -69,43 +141,43 @@ function buildActivityToast(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const issueHref = `/issues/${entityId}`;
|
const issue = resolveIssueToastContext(queryClient, companyId, entityId, details);
|
||||||
const issueTitle = details?.title && typeof details.title === "string"
|
const actor = resolveActorLabel(queryClient, companyId, actorType, actorId);
|
||||||
? truncate(details.title, 60)
|
|
||||||
: null;
|
|
||||||
const actorName = actorType === "agent" && actorId ? nameOf(actorId) : null;
|
|
||||||
const byLine = actorName ? ` by ${actorName}` : "";
|
|
||||||
|
|
||||||
if (action === "issue.created") {
|
if (action === "issue.created") {
|
||||||
return {
|
return {
|
||||||
title: `Issue created${byLine}`,
|
title: `${actor} created ${issue.ref}`,
|
||||||
body: issueTitle ?? `Issue ${shortId(entityId)}`,
|
body: issue.title ? truncate(issue.title, 96) : undefined,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
action: { label: "Open issue", href: issueHref },
|
action: { label: `View ${issue.ref}`, href: issue.href },
|
||||||
dedupeKey: `activity:${action}:${entityId}`,
|
dedupeKey: `activity:${action}:${entityId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "issue.updated") {
|
if (action === "issue.updated") {
|
||||||
const changeDesc = describeIssueUpdate(details);
|
const changeDesc = describeIssueUpdate(details);
|
||||||
const label = issueTitle ?? `Issue ${shortId(entityId)}`;
|
const body = changeDesc
|
||||||
const body = changeDesc ? `${label} \u2014 ${changeDesc}` : label;
|
? issue.title
|
||||||
|
? `${truncate(issue.title, 64)} - ${changeDesc}`
|
||||||
|
: changeDesc
|
||||||
|
: issue.title
|
||||||
|
? truncate(issue.title, 96)
|
||||||
|
: issue.label;
|
||||||
return {
|
return {
|
||||||
title: `Issue updated${byLine}`,
|
title: `${actor} updated ${issue.ref}`,
|
||||||
body: truncate(body, 100),
|
body: truncate(body, 100),
|
||||||
tone: "info",
|
tone: "info",
|
||||||
action: { label: "Open issue", href: issueHref },
|
action: { label: `View ${issue.ref}`, href: issue.href },
|
||||||
dedupeKey: `activity:${action}:${entityId}`,
|
dedupeKey: `activity:${action}:${entityId}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const commentId = readString(details?.commentId);
|
const commentId = readString(details?.commentId);
|
||||||
const issueLabel = issueTitle ?? `Issue ${shortId(entityId)}`;
|
|
||||||
return {
|
return {
|
||||||
title: `New comment${byLine}`,
|
title: `${actor} posted a comment on ${issue.ref}`,
|
||||||
body: issueLabel,
|
body: issue.title ? truncate(issue.title, 96) : undefined,
|
||||||
tone: "info",
|
tone: "info",
|
||||||
action: { label: "Open issue", href: issueHref },
|
action: { label: `View ${issue.ref}`, href: issue.href },
|
||||||
dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`,
|
dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -324,7 +396,7 @@ function handleLiveEvent(
|
|||||||
if (event.type === "activity.logged") {
|
if (event.type === "activity.logged") {
|
||||||
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
||||||
const action = readString(payload.action);
|
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);
|
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,60 @@ const sourceLabels: Record<string, string> = {
|
|||||||
automation: "Automation",
|
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";
|
type AgentDetailTab = "overview" | "configuration" | "runs" | "issues" | "costs" | "keys";
|
||||||
|
|
||||||
function parseAgentDetailTab(value: string | null): AgentDetailTab {
|
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 [logLoading, setLogLoading] = useState(!!run.logRef);
|
||||||
const [logError, setLogError] = useState<string | null>(null);
|
const [logError, setLogError] = useState<string | null>(null);
|
||||||
const [logOffset, setLogOffset] = useState(0);
|
const [logOffset, setLogOffset] = useState(0);
|
||||||
const [isFollowing, setIsFollowing] = useState(true);
|
const [isFollowing, setIsFollowing] = useState(false);
|
||||||
const logEndRef = useRef<HTMLDivElement>(null);
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingLogLineRef = useRef("");
|
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";
|
const isLive = run.status === "running" || run.status === "queued";
|
||||||
|
|
||||||
function appendLogContent(content: string, finalize = false) {
|
function appendLogContent(content: string, finalize = false) {
|
||||||
@@ -1250,39 +1310,86 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
}
|
}
|
||||||
}, [initialEvents]);
|
}, [initialEvents]);
|
||||||
|
|
||||||
const updateFollowingState = useCallback(() => {
|
const getScrollContainer = useCallback((): ScrollContainer => {
|
||||||
const viewportBottom = window.scrollY + window.innerHeight;
|
if (scrollContainerRef.current) return scrollContainerRef.current;
|
||||||
const pageHeight = Math.max(
|
const container = findScrollContainer(logEndRef.current);
|
||||||
document.documentElement.scrollHeight,
|
scrollContainerRef.current = container;
|
||||||
document.body.scrollHeight,
|
return container;
|
||||||
);
|
|
||||||
const distanceFromBottom = pageHeight - viewportBottom;
|
|
||||||
const isNearBottom = distanceFromBottom <= 32;
|
|
||||||
setIsFollowing((prev) => (prev === isNearBottom ? prev : isNearBottom));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return;
|
scrollContainerRef.current = null;
|
||||||
setIsFollowing(true);
|
lastMetricsRef.current = {
|
||||||
}, [isLive, run.id]);
|
scrollHeight: 0,
|
||||||
|
distanceFromBottom: Number.POSITIVE_INFINITY,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isLive) {
|
||||||
|
isFollowingRef.current = false;
|
||||||
|
setIsFollowing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFollowingState();
|
||||||
|
}, [isLive, run.id, updateFollowingState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLive) return;
|
if (!isLive) return;
|
||||||
|
const container = getScrollContainer();
|
||||||
updateFollowingState();
|
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);
|
window.addEventListener("resize", updateFollowingState);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("scroll", updateFollowingState);
|
if (container === window) {
|
||||||
|
window.removeEventListener("scroll", updateFollowingState);
|
||||||
|
} else {
|
||||||
|
container.removeEventListener("scroll", updateFollowingState);
|
||||||
|
}
|
||||||
window.removeEventListener("resize", updateFollowingState);
|
window.removeEventListener("resize", updateFollowingState);
|
||||||
};
|
};
|
||||||
}, [isLive, updateFollowingState]);
|
}, [isLive, run.id, getScrollContainer, updateFollowingState]);
|
||||||
|
|
||||||
// Auto-scroll only for live runs when following
|
// Auto-scroll only for live runs when following
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLive && isFollowing) {
|
if (!isLive || !isFollowingRef.current) return;
|
||||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
|
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
|
// Fetch persisted shell log
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1463,8 +1570,11 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const container = getScrollContainer();
|
||||||
|
isFollowingRef.current = true;
|
||||||
setIsFollowing(true);
|
setIsFollowing(true);
|
||||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
scrollToContainerBottom(container, "auto");
|
||||||
|
lastMetricsRef.current = readScrollMetrics(container);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Jump to live
|
Jump to live
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ export function GoalDetail() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const childGoals = (allGoals ?? []).filter((g) => g.parentId === goalId);
|
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(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([
|
setBreadcrumbs([
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
|
|||||||
return 0;
|
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 {
|
function formatAction(action: string, details?: Record<string, unknown> | null): string {
|
||||||
if (action === "issue.updated" && details) {
|
if (action === "issue.updated" && details) {
|
||||||
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
const previous = (details._previous ?? {}) as Record<string, unknown>;
|
||||||
@@ -270,10 +275,13 @@ export function IssueDetail() {
|
|||||||
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
||||||
onSuccess: (updated) => {
|
onSuccess: (updated) => {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
|
const issueRef = updated.identifier ?? `Issue ${updated.id.slice(0, 8)}`;
|
||||||
pushToast({
|
pushToast({
|
||||||
dedupeKey: `activity:issue.updated:${updated.id}`,
|
dedupeKey: `activity:issue.updated:${updated.id}`,
|
||||||
title: "Issue updated",
|
title: `${issueRef} updated`,
|
||||||
|
body: truncate(updated.title, 96),
|
||||||
tone: "success",
|
tone: "success",
|
||||||
|
action: { label: `View ${issueRef}`, href: `/issues/${updated.id}` },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -281,13 +289,16 @@ export function IssueDetail() {
|
|||||||
const addComment = useMutation({
|
const addComment = useMutation({
|
||||||
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
||||||
issuesApi.addComment(issueId!, body, reopen),
|
issuesApi.addComment(issueId!, body, reopen),
|
||||||
onSuccess: () => {
|
onSuccess: (comment) => {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
|
const issueRef = issue?.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
||||||
pushToast({
|
pushToast({
|
||||||
dedupeKey: `activity:issue.comment_added:${issueId}`,
|
dedupeKey: `activity:issue.comment_added:${issueId}:${comment.id}`,
|
||||||
title: "Comment posted",
|
title: `Comment posted on ${issueRef}`,
|
||||||
|
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
|
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issueId}` } : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user