Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useParams, useNavigate, Link, Navigate, useBeforeUnload } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||
import {
|
||||
agentsApi,
|
||||
type AgentKey,
|
||||
type ClaudeLoginResult,
|
||||
type AvailableSkill,
|
||||
type AgentPermissionUpdate,
|
||||
} from "../api/agents";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -74,6 +80,7 @@ import {
|
||||
type Agent,
|
||||
type AgentSkillEntry,
|
||||
type AgentSkillSnapshot,
|
||||
type AgentDetail as AgentDetailRecord,
|
||||
type BudgetPolicySummary,
|
||||
type HeartbeatRun,
|
||||
type HeartbeatRunEvent,
|
||||
@@ -517,7 +524,7 @@ export function AgentDetail() {
|
||||
const setSaveConfigAction = useCallback((fn: (() => void) | null) => { saveConfigActionRef.current = fn; }, []);
|
||||
const setCancelConfigAction = useCallback((fn: (() => void) | null) => { cancelConfigActionRef.current = fn; }, []);
|
||||
|
||||
const { data: agent, isLoading, error } = useQuery({
|
||||
const { data: agent, isLoading, error } = useQuery<AgentDetailRecord>({
|
||||
queryKey: [...queryKeys.agents.detail(routeAgentRef), lookupCompanyId ?? null],
|
||||
queryFn: () => agentsApi.get(routeAgentRef, lookupCompanyId),
|
||||
enabled: canFetchAgent,
|
||||
@@ -705,8 +712,8 @@ export function AgentDetail() {
|
||||
});
|
||||
|
||||
const updatePermissions = useMutation({
|
||||
mutationFn: (canCreateAgents: boolean) =>
|
||||
agentsApi.updatePermissions(agentLookupRef, { canCreateAgents }, resolvedCompanyId ?? undefined),
|
||||
mutationFn: (permissions: AgentPermissionUpdate) =>
|
||||
agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
@@ -1129,7 +1136,7 @@ function AgentOverview({
|
||||
agentId,
|
||||
agentRouteId,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agent: AgentDetailRecord;
|
||||
runs: HeartbeatRun[];
|
||||
assignedIssues: { id: string; title: string; status: string; priority: string; identifier?: string | null; createdAt: Date }[];
|
||||
runtimeState?: AgentRuntimeState;
|
||||
@@ -1286,14 +1293,14 @@ function AgentConfigurePage({
|
||||
onSavingChange,
|
||||
updatePermissions,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agent: AgentDetailRecord;
|
||||
agentId: string;
|
||||
companyId?: string;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
onSaveActionChange: (save: (() => void) | null) => void;
|
||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||
onSavingChange: (saving: boolean) => void;
|
||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [revisionsOpen, setRevisionsOpen] = useState(false);
|
||||
@@ -1397,13 +1404,13 @@ function ConfigurationTab({
|
||||
hidePromptTemplate,
|
||||
hideInstructionsFile,
|
||||
}: {
|
||||
agent: Agent;
|
||||
agent: AgentDetailRecord;
|
||||
companyId?: string;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
onSaveActionChange: (save: (() => void) | null) => void;
|
||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||
onSavingChange: (saving: boolean) => void;
|
||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||
updatePermissions: { mutate: (permissions: AgentPermissionUpdate) => void; isPending: boolean };
|
||||
hidePromptTemplate?: boolean;
|
||||
hideInstructionsFile?: boolean;
|
||||
}) {
|
||||
@@ -1447,6 +1454,19 @@ function ConfigurationTab({
|
||||
onSavingChange(isConfigSaving);
|
||||
}, [onSavingChange, isConfigSaving]);
|
||||
|
||||
const canCreateAgents = Boolean(agent.permissions?.canCreateAgents);
|
||||
const canAssignTasks = Boolean(agent.access?.canAssignTasks);
|
||||
const taskAssignSource = agent.access?.taskAssignSource ?? "none";
|
||||
const taskAssignLocked = agent.role === "ceo" || canCreateAgents;
|
||||
const taskAssignHint =
|
||||
taskAssignSource === "ceo_role"
|
||||
? "Enabled automatically for CEO agents."
|
||||
: taskAssignSource === "agent_creator"
|
||||
? "Enabled automatically while this agent can create new agents."
|
||||
: taskAssignSource === "explicit_grant"
|
||||
? "Enabled via explicit company permission grant."
|
||||
: "Disabled unless explicitly granted.";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<AgentConfigForm
|
||||
@@ -1466,21 +1486,62 @@ function ConfigurationTab({
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Permissions</h3>
|
||||
<div className="border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Can create new agents</span>
|
||||
<div className="border border-border rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div>Can create new agents</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lets this agent create or hire agents and implicitly assign tasks.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={agent.permissions?.canCreateAgents ? "default" : "outline"}
|
||||
variant={canCreateAgents ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() =>
|
||||
updatePermissions.mutate(!Boolean(agent.permissions?.canCreateAgents))
|
||||
updatePermissions.mutate({
|
||||
canCreateAgents: !canCreateAgents,
|
||||
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
|
||||
})
|
||||
}
|
||||
disabled={updatePermissions.isPending}
|
||||
>
|
||||
{agent.permissions?.canCreateAgents ? "Enabled" : "Disabled"}
|
||||
{canCreateAgents ? "Enabled" : "Disabled"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 text-sm">
|
||||
<div className="space-y-1">
|
||||
<div>Can assign tasks</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{taskAssignHint}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={canAssignTasks}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
canAssignTasks
|
||||
? "bg-green-500 focus-visible:ring-green-500/70"
|
||||
: "bg-input/50 focus-visible:ring-ring",
|
||||
)}
|
||||
onClick={() =>
|
||||
updatePermissions.mutate({
|
||||
canCreateAgents,
|
||||
canAssignTasks: !canAssignTasks,
|
||||
})
|
||||
}
|
||||
disabled={updatePermissions.isPending || taskAssignLocked}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 transform rounded-full bg-background transition-transform",
|
||||
canAssignTasks ? "translate-x-6" : "translate-x-1",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { typeLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
|
||||
import { approvalLabel, typeIcon, defaultTypeIcon, ApprovalPayloadRenderer } from "../components/ApprovalPayload";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -203,7 +203,7 @@ export function ApprovalDetail() {
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeIcon className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{typeLabel[approval.type] ?? approval.type.replace(/_/g, " ")}</h2>
|
||||
<h2 className="text-lg font-semibold">{approvalLabel(approval.type, approval.payload as Record<string, unknown> | null)}</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">{approval.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { IssueRow } from "../components/IssueRow";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { defaultTypeIcon, typeIcon, typeLabel } from "../components/ApprovalPayload";
|
||||
import { approvalLabel, defaultTypeIcon, typeIcon } from "../components/ApprovalPayload";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -33,12 +33,10 @@ import {
|
||||
import {
|
||||
Inbox as InboxIcon,
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
XCircle,
|
||||
X,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import type { Approval, HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -64,16 +62,8 @@ type InboxCategoryFilter =
|
||||
type SectionKey =
|
||||
| "work_items"
|
||||
| "join_requests"
|
||||
| "failed_runs"
|
||||
| "alerts";
|
||||
|
||||
const RUN_SOURCE_LABELS: Record<string, string> = {
|
||||
timer: "Scheduled",
|
||||
assignment: "Assignment",
|
||||
on_demand: "Manual",
|
||||
automation: "Automation",
|
||||
};
|
||||
|
||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||
@@ -101,139 +91,102 @@ function readIssueIdFromRun(run: HeartbeatRun): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function FailedRunCard({
|
||||
function FailedRunInboxRow({
|
||||
run,
|
||||
issueById,
|
||||
agentName: linkedAgentName,
|
||||
issueLinkState,
|
||||
onDismiss,
|
||||
onRetry,
|
||||
isRetrying,
|
||||
}: {
|
||||
run: HeartbeatRun;
|
||||
issueById: Map<string, Issue>;
|
||||
agentName: string | null;
|
||||
issueLinkState: unknown;
|
||||
onDismiss: () => void;
|
||||
onRetry: () => void;
|
||||
isRetrying: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const issueId = readIssueIdFromRun(run);
|
||||
const issue = issueId ? issueById.get(issueId) ?? null : null;
|
||||
const sourceLabel = RUN_SOURCE_LABELS[run.invocationSource] ?? "Manual";
|
||||
const displayError = runFailureMessage(run);
|
||||
|
||||
const retryRun = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const context = run.contextSnapshot as Record<string, unknown> | null;
|
||||
if (context) {
|
||||
if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId;
|
||||
if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId;
|
||||
if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey;
|
||||
}
|
||||
const result = await agentsApi.wakeup(run.agentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "retry_failed_run",
|
||||
payload,
|
||||
});
|
||||
if (!("id" in result)) {
|
||||
throw new Error("Retry was skipped because the agent is not currently invokable.");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onSuccess: (newRun) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(run.companyId, run.agentId) });
|
||||
navigate(`/agents/${run.agentId}/runs/${newRun.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-xl border border-red-500/30 bg-gradient-to-br from-red-500/10 via-card to-card p-4">
|
||||
<div className="absolute right-0 top-0 h-24 w-24 rounded-full bg-red-500/10 blur-2xl" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="absolute right-2 top-2 z-10 rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="relative space-y-3">
|
||||
{issue ? (
|
||||
<Link
|
||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||
state={issueLinkState}
|
||||
className="block truncate text-sm font-medium transition-colors hover:text-foreground no-underline text-inherit"
|
||||
>
|
||||
<span className="font-mono text-muted-foreground mr-1.5">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{issue.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{run.errorCode ? `Error code: ${run.errorCode}` : "No linked issue"}
|
||||
<div className="group border-b border-border px-2 py-2.5 last:border-b-0 sm:px-1 sm:pr-3 sm:py-2">
|
||||
<div className="flex items-start gap-2 sm:items-center">
|
||||
<Link
|
||||
to={`/agents/${run.agentId}/runs/${run.id}`}
|
||||
className="flex min-w-0 flex-1 items-start gap-2 no-underline text-inherit transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<span className="hidden h-2 w-2 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||
<span className="hidden h-3.5 w-3.5 shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||
<span className="mt-0.5 shrink-0 rounded-md bg-red-500/20 p-1.5 sm:mt-0">
|
||||
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-md bg-red-500/20 p-1.5">
|
||||
<XCircle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
</span>
|
||||
{linkedAgentName ? (
|
||||
<Identity name={linkedAgentName} size="sm" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="line-clamp-2 text-sm font-medium sm:truncate sm:line-clamp-none">
|
||||
{issue ? (
|
||||
<>
|
||||
<span className="font-mono text-muted-foreground mr-1.5">
|
||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||
</span>
|
||||
{issue.title}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm font-medium">Agent {run.agentId.slice(0, 8)}</span>
|
||||
<>Failed run{linkedAgentName ? ` — ${linkedAgentName}` : ""}</>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{sourceLabel} run failed {timeAgo(run.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center gap-2 sm:w-auto sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2.5"
|
||||
onClick={() => retryRun.mutate()}
|
||||
disabled={retryRun.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{retryRun.isPending ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2.5"
|
||||
asChild
|
||||
>
|
||||
<Link to={`/agents/${run.agentId}/runs/${run.id}`}>
|
||||
Open run
|
||||
<ArrowUpRight className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{linkedAgentName && issue ? <span>{linkedAgentName}</span> : null}
|
||||
<span className="truncate max-w-[300px]">{displayError}</span>
|
||||
<span>{timeAgo(run.createdAt)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
<div className="hidden shrink-0 items-center gap-2 sm:flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2.5"
|
||||
onClick={onRetry}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isRetrying ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm">
|
||||
{displayError}
|
||||
</div>
|
||||
|
||||
<div className="text-xs">
|
||||
<span className="font-mono text-muted-foreground">run {run.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
{retryRun.isError && (
|
||||
<div className="text-xs text-destructive">
|
||||
{retryRun.error instanceof Error ? retryRun.error.message : "Failed to retry run"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2 sm:hidden">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2.5"
|
||||
onClick={onRetry}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{isRetrying ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -253,7 +206,7 @@ function ApprovalInboxRow({
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const Icon = typeIcon[approval.type] ?? defaultTypeIcon;
|
||||
const label = typeLabel[approval.type] ?? approval.type;
|
||||
const label = approvalLabel(approval.type, approval.payload as Record<string, unknown> | null);
|
||||
const showResolutionButtons =
|
||||
approval.type !== "budget_override_required" &&
|
||||
ACTIONABLE_APPROVAL_STATUSES.has(approval.status);
|
||||
@@ -473,13 +426,19 @@ export function Inbox() {
|
||||
const showFailedRunsCategory =
|
||||
allCategoryFilter === "everything" || allCategoryFilter === "failed_runs";
|
||||
const showAlertsCategory = allCategoryFilter === "everything" || allCategoryFilter === "alerts";
|
||||
const failedRunsForTab = useMemo(() => {
|
||||
if (tab === "all" && !showFailedRunsCategory) return [];
|
||||
return failedRuns;
|
||||
}, [failedRuns, tab, showFailedRunsCategory]);
|
||||
|
||||
const workItemsToRender = useMemo(
|
||||
() =>
|
||||
getInboxWorkItems({
|
||||
issues: tab === "all" && !showTouchedCategory ? [] : issuesToRender,
|
||||
approvals: tab === "all" && !showApprovalsCategory ? [] : approvalsToRender,
|
||||
failedRuns: failedRunsForTab,
|
||||
}),
|
||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab],
|
||||
[approvalsToRender, issuesToRender, showApprovalsCategory, showTouchedCategory, tab, failedRunsForTab],
|
||||
);
|
||||
|
||||
const agentName = (id: string | null) => {
|
||||
@@ -538,6 +497,46 @@ export function Inbox() {
|
||||
},
|
||||
});
|
||||
|
||||
const [retryingRunIds, setRetryingRunIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const retryRunMutation = useMutation({
|
||||
mutationFn: async (run: HeartbeatRun) => {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const context = run.contextSnapshot as Record<string, unknown> | null;
|
||||
if (context) {
|
||||
if (typeof context.issueId === "string" && context.issueId) payload.issueId = context.issueId;
|
||||
if (typeof context.taskId === "string" && context.taskId) payload.taskId = context.taskId;
|
||||
if (typeof context.taskKey === "string" && context.taskKey) payload.taskKey = context.taskKey;
|
||||
}
|
||||
const result = await agentsApi.wakeup(run.agentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "retry_failed_run",
|
||||
payload,
|
||||
});
|
||||
if (!("id" in result)) {
|
||||
throw new Error("Retry was skipped because the agent is not currently invokable.");
|
||||
}
|
||||
return { newRun: result, originalRun: run };
|
||||
},
|
||||
onMutate: (run) => {
|
||||
setRetryingRunIds((prev) => new Set(prev).add(run.id));
|
||||
},
|
||||
onSuccess: ({ newRun, originalRun }) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(originalRun.companyId, originalRun.agentId) });
|
||||
navigate(`/agents/${originalRun.agentId}/runs/${newRun.id}`);
|
||||
},
|
||||
onSettled: (_data, _error, run) => {
|
||||
if (!run) return;
|
||||
setRetryingRunIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(run.id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [fadingOutIssues, setFadingOutIssues] = useState<Set<string>>(new Set());
|
||||
|
||||
const invalidateInboxIssueQueries = () => {
|
||||
@@ -607,13 +606,6 @@ export function Inbox() {
|
||||
const showWorkItemsSection = workItemsToRender.length > 0;
|
||||
const showJoinRequestsSection =
|
||||
tab === "all" ? showJoinRequestsCategory && hasJoinRequests : tab === "unread" && hasJoinRequests;
|
||||
const showFailedRunsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasRunFailures,
|
||||
showOnRecent: hasRunFailures,
|
||||
showOnUnread: hasRunFailures,
|
||||
showOnAll: showFailedRunsCategory && hasRunFailures,
|
||||
});
|
||||
const showAlertsSection = shouldShowInboxSection({
|
||||
tab,
|
||||
hasItems: hasAlerts,
|
||||
@@ -623,7 +615,6 @@ export function Inbox() {
|
||||
});
|
||||
|
||||
const visibleSections = [
|
||||
showFailedRunsSection ? "failed_runs" : null,
|
||||
showAlertsSection ? "alerts" : null,
|
||||
showJoinRequestsSection ? "join_requests" : null,
|
||||
showWorkItemsSection ? "work_items" : null,
|
||||
@@ -751,6 +742,21 @@ export function Inbox() {
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === "failed_run") {
|
||||
return (
|
||||
<FailedRunInboxRow
|
||||
key={`run:${item.run.id}`}
|
||||
run={item.run}
|
||||
issueById={issueById}
|
||||
agentName={agentName(item.run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
onDismiss={() => dismiss(`run:${item.run.id}`)}
|
||||
onRetry={() => retryRunMutation.mutate(item.run)}
|
||||
isRetrying={retryingRunIds.has(item.run.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const issue = item.issue;
|
||||
const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id);
|
||||
const isFading = fadingOutIssues.has(issue.id);
|
||||
@@ -857,28 +863,6 @@ export function Inbox() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{showFailedRunsSection && (
|
||||
<>
|
||||
{showSeparatorBefore("failed_runs") && <Separator />}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Failed Runs
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{failedRuns.map((run) => (
|
||||
<FailedRunCard
|
||||
key={run.id}
|
||||
run={run}
|
||||
issueById={issueById}
|
||||
agentName={agentName(run.agentId)}
|
||||
issueLinkState={issueLinkState}
|
||||
onDismiss={() => dismiss(`run:${run.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showAlertsSection && (
|
||||
<>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useCompany } from "../context/CompanyContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
@@ -206,7 +207,6 @@ export function IssueDetail() {
|
||||
const [detailTab, setDetailTab] = useState("comments");
|
||||
const [secondaryOpen, setSecondaryOpen] = useState({
|
||||
approvals: false,
|
||||
cost: false,
|
||||
});
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
||||
@@ -375,11 +375,15 @@ export function IssueDetail() {
|
||||
return options;
|
||||
}, [agents, currentUserId]);
|
||||
|
||||
const currentAssigneeValue = useMemo(() => {
|
||||
if (issue?.assigneeAgentId) return `agent:${issue.assigneeAgentId}`;
|
||||
if (issue?.assigneeUserId) return `user:${issue.assigneeUserId}`;
|
||||
return "";
|
||||
}, [issue?.assigneeAgentId, issue?.assigneeUserId]);
|
||||
const actualAssigneeValue = useMemo(
|
||||
() => assigneeValueFromSelection(issue ?? {}),
|
||||
[issue],
|
||||
);
|
||||
|
||||
const suggestedAssigneeValue = useMemo(
|
||||
() => suggestedCommentAssigneeValue(issue ?? {}, comments, currentUserId),
|
||||
[issue, comments, currentUserId],
|
||||
);
|
||||
|
||||
const commentsWithRunMeta = useMemo(() => {
|
||||
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
|
||||
@@ -1002,7 +1006,8 @@ export function IssueDetail() {
|
||||
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
||||
enableReassign
|
||||
reassignOptions={commentReassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
currentAssigneeValue={actualAssigneeValue}
|
||||
suggestedAssigneeValue={suggestedAssigneeValue}
|
||||
mentions={mentionOptions}
|
||||
onAdd={async (body, reopen, reassignment) => {
|
||||
if (reassignment) {
|
||||
@@ -1055,6 +1060,30 @@ export function IssueDetail() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
|
||||
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
|
||||
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
|
||||
{issueCostSummary.hasCost && (
|
||||
<span className="font-medium text-foreground">
|
||||
${issueCostSummary.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
{issueCostSummary.hasTokens && (
|
||||
<span>
|
||||
Tokens {formatTokens(issueCostSummary.totalTokens)}
|
||||
{issueCostSummary.cached > 0
|
||||
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
|
||||
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!activity || activity.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
@@ -1123,43 +1152,6 @@ export function IssueDetail() {
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{linkedRuns && linkedRuns.length > 0 && (
|
||||
<Collapsible
|
||||
open={secondaryOpen.cost}
|
||||
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, cost: open }))}
|
||||
className="rounded-lg border border-border"
|
||||
>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-2 text-left">
|
||||
<span className="text-sm font-medium text-muted-foreground">Cost Summary</span>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.cost && "rotate-180")}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t border-border px-3 py-2">
|
||||
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
|
||||
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
|
||||
{issueCostSummary.hasCost && (
|
||||
<span className="font-medium text-foreground">
|
||||
${issueCostSummary.cost.toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
{issueCostSummary.hasTokens && (
|
||||
<span>
|
||||
Tokens {formatTokens(issueCostSummary.totalTokens)}
|
||||
{issueCostSummary.cached > 0
|
||||
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
|
||||
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Mobile properties drawer */}
|
||||
<Sheet open={mobilePropsOpen} onOpenChange={setMobilePropsOpen}>
|
||||
|
||||
Reference in New Issue
Block a user