Merge public-gh/master into paperclip-company-import-export

This commit is contained in:
dotta
2026-03-20 06:25:24 -05:00
41 changed files with 11912 additions and 392 deletions

View File

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

View File

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

View File

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

View File

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