Files
paperclip/ui/src/pages/IssueDetail.tsx
Dotta bb7d1b2c71 Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master:
  Fix budget incident resolution edge cases
  Fix agent budget tab routing
  Fix budget auth and monthly spend rollups
  Harden budget enforcement and migration startup
  Add budget tabs and sidebar budget indicators
  feat(costs): add billing, quota, and budget control plane
  refactor(quota): move provider quota logic into adapter layer, add unit tests
  fix(costs): replace non-null map assertions with nullish coalescing, clarify weekData guard
  fix(costs): guard byProject against duplicate null keys, memoize ProviderQuotaCard row aggregations
  fix(costs): align byAgent run filter to startedAt, tighten providerTabItems memo deps, stabilize byProject row keys
  feat(costs): add agent model breakdown, harden date validation, sync CostByProject type, fix quota threshold and tab-gated queries
  fix(costs): harden company auth check, fix frozen date memo, hide empty quota rows
  fix(costs): guard routes, fix DST ranges, sync provider state, wire live updates
  feat(costs): consolidate /usage into /costs with Spend + Providers tabs
  feat(usage): add subscription quota windows per provider on /usage page
  address greptile review: per-provider deficit notch, startedAt filter, weekRange refresh, deduplicate providerDisplayName
  feat(ui): add resource and usage dashboard (/usage route)

# Conflicts:
#	packages/db/src/migration-runtime.ts
#	packages/db/src/migrations/meta/0031_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
2026-03-16 17:19:55 -05:00

1418 lines
56 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { activityApi } from "../api/activity";
import { heartbeatsApi } from "../api/heartbeats";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { useExperimentalWorkspacesEnabled } from "../lib/experimentalSettings";
import { readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { InlineEditor } from "../components/InlineEditor";
import { CommentThread } from "../components/CommentThread";
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
import { IssueProperties } from "../components/IssueProperties";
import { LiveRunWidget } from "../components/LiveRunWidget";
import type { MentionOption } from "../components/MarkdownEditor";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { StatusBadge } from "../components/StatusBadge";
import { Identity } from "../components/Identity";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { Separator } from "@/components/ui/separator";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Activity as ActivityIcon,
ChevronDown,
ChevronRight,
EyeOff,
ExternalLink,
FileText,
GitBranch,
GitPullRequest,
Hexagon,
ListTree,
MessageSquare,
MoreHorizontal,
Package,
Paperclip,
Rocket,
SlidersHorizontal,
Trash2,
} from "lucide-react";
import type { ActivityEvent, IssueWorkProduct } from "@paperclipai/shared";
import type { Agent, IssueAttachment } from "@paperclipai/shared";
type CommentReassignment = {
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
const ACTION_LABELS: Record<string, string> = {
"issue.created": "created the issue",
"issue.updated": "updated the issue",
"issue.checked_out": "checked out the issue",
"issue.released": "released the issue",
"issue.comment_added": "added a comment",
"issue.attachment_added": "added an attachment",
"issue.attachment_removed": "removed an attachment",
"issue.document_created": "created a document",
"issue.document_updated": "updated a document",
"issue.document_deleted": "deleted a document",
"issue.deleted": "deleted the issue",
"agent.created": "created an agent",
"agent.updated": "updated the agent",
"agent.paused": "paused the agent",
"agent.resumed": "resumed the agent",
"agent.terminated": "terminated the agent",
"heartbeat.invoked": "invoked a heartbeat",
"heartbeat.cancelled": "cancelled a heartbeat",
"approval.created": "requested approval",
"approval.approved": "approved",
"approval.rejected": "rejected",
};
function humanizeValue(value: unknown): string {
if (typeof value !== "string") return String(value ?? "none");
return value.replace(/_/g, " ");
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function usageNumber(usage: Record<string, unknown> | null, ...keys: string[]) {
if (!usage) return 0;
for (const key of keys) {
const value = usage[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return 0;
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, max - 1) + "\u2026";
}
function isMarkdownFile(file: File) {
const name = file.name.toLowerCase();
return (
name.endsWith(".md") ||
name.endsWith(".markdown") ||
file.type === "text/markdown"
);
}
function fileBaseName(filename: string) {
return filename.replace(/\.[^.]+$/, "");
}
function slugifyDocumentKey(input: string) {
const slug = input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "document";
}
function titleizeFilename(input: string) {
return input
.split(/[-_ ]+/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function formatAction(action: string, details?: Record<string, unknown> | null): string {
if (action === "issue.updated" && details) {
const previous = (details._previous ?? {}) as Record<string, unknown>;
const parts: string[] = [];
if (details.status !== undefined) {
const from = previous.status;
parts.push(
from
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
: `changed the status to ${humanizeValue(details.status)}`
);
}
if (details.priority !== undefined) {
const from = previous.priority;
parts.push(
from
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
: `changed the priority to ${humanizeValue(details.priority)}`
);
}
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
parts.push(
details.assigneeAgentId || details.assigneeUserId
? "assigned the issue"
: "unassigned the issue",
);
}
if (details.title !== undefined) parts.push("updated the title");
if (details.description !== undefined) parts.push("updated the description");
if (parts.length > 0) return parts.join(", ");
}
if (
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
details
) {
const key = typeof details.key === "string" ? details.key : "document";
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
return `${ACTION_LABELS[action] ?? action} ${key}${title}`;
}
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
}
function workProductIcon(product: IssueWorkProduct) {
switch (product.type) {
case "pull_request":
return <GitPullRequest className="h-3.5 w-3.5" />;
case "branch":
case "commit":
return <GitBranch className="h-3.5 w-3.5" />;
case "artifact":
return <Package className="h-3.5 w-3.5" />;
case "document":
return <FileText className="h-3.5 w-3.5" />;
case "runtime_service":
return <Rocket className="h-3.5 w-3.5" />;
default:
return <ExternalLink className="h-3.5 w-3.5" />;
}
}
function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<string, Agent> }) {
const id = evt.actorId;
if (evt.actorType === "agent") {
const agent = agentMap.get(id);
return <Identity name={agent?.name ?? id.slice(0, 8)} size="sm" />;
}
if (evt.actorType === "system") return <Identity name="System" size="sm" />;
if (evt.actorType === "user") return <Identity name="Board" size="sm" />;
return <Identity name={id || "Unknown"} size="sm" />;
}
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
const { enabled: experimentalWorkspacesEnabled } = useExperimentalWorkspacesEnabled();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const [moreOpen, setMoreOpen] = useState(false);
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
const [detailTab, setDetailTab] = useState("comments");
const [secondaryOpen, setSecondaryOpen] = useState({
approvals: false,
cost: false,
});
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [newWorkProductType, setNewWorkProductType] = useState<IssueWorkProduct["type"]>("preview_url");
const [newWorkProductProvider, setNewWorkProductProvider] = useState("paperclip");
const [newWorkProductTitle, setNewWorkProductTitle] = useState("");
const [newWorkProductUrl, setNewWorkProductUrl] = useState("");
const [newWorkProductStatus, setNewWorkProductStatus] = useState<IssueWorkProduct["status"]>("active");
const [newWorkProductReviewState, setNewWorkProductReviewState] = useState<IssueWorkProduct["reviewState"]>("none");
const [newWorkProductSummary, setNewWorkProductSummary] = useState("");
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
const { data: issue, isLoading, error } = useQuery({
queryKey: queryKeys.issues.detail(issueId!),
queryFn: () => issuesApi.get(issueId!),
enabled: !!issueId,
});
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(issueId!),
queryFn: () => issuesApi.listComments(issueId!),
enabled: !!issueId,
});
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId!),
queryFn: () => activityApi.forIssue(issueId!),
enabled: !!issueId,
});
const { data: linkedRuns } = useQuery({
queryKey: queryKeys.issues.runs(issueId!),
queryFn: () => activityApi.runsForIssue(issueId!),
enabled: !!issueId,
refetchInterval: 5000,
});
const { data: linkedApprovals } = useQuery({
queryKey: queryKeys.issues.approvals(issueId!),
queryFn: () => issuesApi.listApprovals(issueId!),
enabled: !!issueId,
});
const { data: attachments } = useQuery({
queryKey: queryKeys.issues.attachments(issueId!),
queryFn: () => issuesApi.listAttachments(issueId!),
enabled: !!issueId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.issues.liveRuns(issueId!),
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
enabled: !!issueId,
refetchInterval: 3000,
});
const { data: activeRun } = useQuery({
queryKey: queryKeys.issues.activeRun(issueId!),
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
enabled: !!issueId,
refetchInterval: 3000,
});
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
const sourceBreadcrumb = useMemo(
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
[location.state],
);
// Filter out runs already shown by the live widget to avoid duplication
const timelineRuns = useMemo(() => {
const liveIds = new Set<string>();
for (const r of liveRuns ?? []) liveIds.add(r.id);
if (activeRun) liveIds.add(activeRun.id);
if (liveIds.size === 0) return linkedRuns ?? [];
return (linkedRuns ?? []).filter((r) => !liveIds.has(r.runId));
}, [linkedRuns, liveRuns, activeRun]);
const { data: allIssues } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
companyId: selectedCompanyId,
userId: currentUserId,
});
const { slots: issuePluginDetailSlots } = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "issue",
companyId: resolvedCompanyId,
enabled: !!resolvedCompanyId,
});
const issuePluginTabItems = useMemo(
() => issuePluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}`,
label: slot.displayName,
slot,
})),
[issuePluginDetailSlots],
);
const activePluginTab = issuePluginTabItems.find((item) => item.value === detailTab) ?? null;
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
for (const a of agents ?? []) map.set(a.id, a);
return map;
}, [agents]);
const mentionOptions = useMemo<MentionOption[]>(() => {
const options: MentionOption[] = [];
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
for (const agent of activeAgents) {
options.push({
id: `agent:${agent.id}`,
name: agent.name,
kind: "agent",
});
}
for (const project of orderedProjects) {
options.push({
id: `project:${project.id}`,
name: project.name,
kind: "project",
projectId: project.id,
projectColor: project.color,
});
}
return options;
}, [agents, orderedProjects]);
const childIssues = useMemo(() => {
if (!allIssues || !issue) return [];
return allIssues
.filter((i) => i.parentId === issue.id)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}, [allIssues, issue]);
const commentReassignOptions = useMemo(() => {
const options: Array<{ id: string; label: string; searchText?: string }> = [];
const activeAgents = [...(agents ?? [])]
.filter((agent) => agent.status !== "terminated")
.sort((a, b) => a.name.localeCompare(b.name));
for (const agent of activeAgents) {
options.push({ id: `agent:${agent.id}`, label: agent.name });
}
if (currentUserId) {
options.push({ id: `user:${currentUserId}`, label: "Me" });
}
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 commentsWithRunMeta = useMemo(() => {
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null }>();
const agentIdByRunId = new Map<string, string>();
for (const run of linkedRuns ?? []) {
agentIdByRunId.set(run.runId, run.agentId);
}
for (const evt of activity ?? []) {
if (evt.action !== "issue.comment_added" || !evt.runId) continue;
const details = evt.details ?? {};
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
if (!commentId || runMetaByCommentId.has(commentId)) continue;
runMetaByCommentId.set(commentId, {
runId: evt.runId,
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
});
}
return (comments ?? []).map((comment) => {
const meta = runMetaByCommentId.get(comment.id);
return meta ? { ...comment, ...meta } : comment;
});
}, [activity, comments, linkedRuns]);
const issueCostSummary = useMemo(() => {
let input = 0;
let output = 0;
let cached = 0;
let cost = 0;
let hasCost = false;
let hasTokens = false;
for (const run of linkedRuns ?? []) {
const usage = asRecord(run.usageJson);
const result = asRecord(run.resultJson);
const runInput = usageNumber(usage, "inputTokens", "input_tokens");
const runOutput = usageNumber(usage, "outputTokens", "output_tokens");
const runCached = usageNumber(
usage,
"cachedInputTokens",
"cached_input_tokens",
"cache_read_input_tokens",
);
const runCost = visibleRunCostUsd(usage, result);
if (runCost > 0) hasCost = true;
if (runInput + runOutput + runCached > 0) hasTokens = true;
input += runInput;
output += runOutput;
cached += runCached;
cost += runCost;
}
return {
input,
output,
cached,
cost,
totalTokens: input + output,
hasCost,
hasTokens,
};
}, [linkedRuns]);
const invalidateIssue = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(issueId!) });
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
}
};
const markIssueRead = useMutation({
mutationFn: (id: string) => issuesApi.markRead(id),
onSuccess: () => {
if (selectedCompanyId) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
}
},
});
const updateIssue = useMutation({
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
onSuccess: () => {
invalidateIssue();
},
});
const addComment = useMutation({
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
issuesApi.addComment(issueId!, body, reopen),
onSuccess: () => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
},
});
const addCommentAndReassign = useMutation({
mutationFn: ({
body,
reopen,
reassignment,
}: {
body: string;
reopen?: boolean;
reassignment: CommentReassignment;
}) =>
issuesApi.update(issueId!, {
comment: body,
assigneeAgentId: reassignment.assigneeAgentId,
assigneeUserId: reassignment.assigneeUserId,
...(reopen ? { status: "todo" } : {}),
}),
onSuccess: () => {
invalidateIssue();
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
},
});
const uploadAttachment = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No company selected");
return issuesApi.uploadAttachment(selectedCompanyId, issueId!, file);
},
onSuccess: () => {
setAttachmentError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
invalidateIssue();
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Upload failed");
},
});
const importMarkdownDocument = useMutation({
mutationFn: async (file: File) => {
const baseName = fileBaseName(file.name);
const key = slugifyDocumentKey(baseName);
const existing = (issue?.documentSummaries ?? []).find((doc) => doc.key === key) ?? null;
const body = await file.text();
const inferredTitle = titleizeFilename(baseName);
const nextTitle = existing?.title ?? inferredTitle ?? null;
return issuesApi.upsertDocument(issueId!, key, {
title: key === "plan" ? null : nextTitle,
format: "markdown",
body,
baseRevisionId: existing?.latestRevisionId ?? null,
});
},
onSuccess: () => {
setAttachmentError(null);
invalidateIssue();
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Document import failed");
},
});
const deleteAttachment = useMutation({
mutationFn: (attachmentId: string) => issuesApi.deleteAttachment(attachmentId),
onSuccess: () => {
setAttachmentError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(issueId!) });
invalidateIssue();
},
onError: (err) => {
setAttachmentError(err instanceof Error ? err.message : "Delete failed");
},
});
const createWorkProduct = useMutation({
mutationFn: () =>
issuesApi.createWorkProduct(issueId!, {
type: newWorkProductType,
provider: newWorkProductProvider,
title: newWorkProductTitle.trim(),
url: newWorkProductUrl.trim() || null,
status: newWorkProductStatus,
reviewState: newWorkProductReviewState,
summary: newWorkProductSummary.trim() || null,
projectId: issue?.projectId ?? null,
executionWorkspaceId: issue?.currentExecutionWorkspace?.id ?? issue?.executionWorkspaceId ?? null,
}),
onSuccess: () => {
setNewWorkProductTitle("");
setNewWorkProductUrl("");
setNewWorkProductSummary("");
setNewWorkProductType("preview_url");
setNewWorkProductProvider("paperclip");
setNewWorkProductStatus("active");
setNewWorkProductReviewState("none");
invalidateIssue();
},
});
const updateWorkProduct = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.updateWorkProduct(id, data),
onSuccess: () => invalidateIssue(),
});
const deleteWorkProduct = useMutation({
mutationFn: (id: string) => issuesApi.deleteWorkProduct(id),
onSuccess: () => invalidateIssue(),
});
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
sourceBreadcrumb,
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
]);
}, [setBreadcrumbs, sourceBreadcrumb, issue, issueId, hasLiveRuns]);
// Redirect to identifier-based URL if navigated via UUID
useEffect(() => {
if (issue?.identifier && issueId !== issue.identifier) {
navigate(`/issues/${issue.identifier}`, { replace: true, state: location.state });
}
}, [issue, issueId, navigate, location.state]);
useEffect(() => {
if (!issue?.id) return;
if (lastMarkedReadIssueIdRef.current === issue.id) return;
lastMarkedReadIssueIdRef.current = issue.id;
markIssueRead.mutate(issue.id);
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (issue) {
openPanel(
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} />
);
}
return () => closePanel();
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
if (isLoading) return <p className="text-sm text-muted-foreground">Loading...</p>;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!issue) return null;
// Ancestors are returned oldest-first from the server (root at end, immediate parent at start)
const ancestors = issue.ancestors ?? [];
const workProducts = issue.workProducts ?? [];
const showOutputsTab =
experimentalWorkspacesEnabled ||
Boolean(issue.currentExecutionWorkspace) ||
workProducts.length > 0;
const handleFilePicked = async (evt: ChangeEvent<HTMLInputElement>) => {
const files = evt.target.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
if (isMarkdownFile(file)) {
await importMarkdownDocument.mutateAsync(file);
} else {
await uploadAttachment.mutateAsync(file);
}
}
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleAttachmentDrop = async (evt: DragEvent<HTMLDivElement>) => {
evt.preventDefault();
setAttachmentDragActive(false);
const files = evt.dataTransfer.files;
if (!files || files.length === 0) return;
for (const file of Array.from(files)) {
if (isMarkdownFile(file)) {
await importMarkdownDocument.mutateAsync(file);
} else {
await uploadAttachment.mutateAsync(file);
}
}
};
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
const attachmentList = attachments ?? [];
const hasAttachments = attachmentList.length > 0;
const attachmentUploadButton = (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"
className="hidden"
onChange={handleFilePicked}
multiple
/>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAttachment.isPending || importMarkdownDocument.isPending}
className={cn(
"shadow-none",
attachmentDragActive && "border-primary bg-primary/5",
)}
>
<Paperclip className="h-3.5 w-3.5 mr-1.5" />
{uploadAttachment.isPending || importMarkdownDocument.isPending ? "Uploading..." : "Upload attachment"}
</Button>
</>
);
return (
<div className="max-w-2xl space-y-6">
{/* Parent chain breadcrumb */}
{ancestors.length > 0 && (
<nav className="flex items-center gap-1 text-xs text-muted-foreground flex-wrap">
{[...ancestors].reverse().map((ancestor, i) => (
<span key={ancestor.id} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
<Link
to={`/issues/${ancestor.identifier ?? ancestor.id}`}
state={location.state}
className="hover:text-foreground transition-colors truncate max-w-[200px]"
title={ancestor.title}
>
{ancestor.title}
</Link>
</span>
))}
<ChevronRight className="h-3 w-3 shrink-0" />
<span className="text-foreground/60 truncate max-w-[200px]">{issue.title}</span>
</nav>
)}
{issue.hiddenAt && (
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<EyeOff className="h-4 w-4 shrink-0" />
This issue is hidden
</div>
)}
<div className="space-y-3">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<StatusIcon
status={issue.status}
onChange={(status) => updateIssue.mutate({ status })}
/>
<PriorityIcon
priority={issue.priority}
onChange={(priority) => updateIssue.mutate({ priority })}
/>
<span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
{hasLiveRuns && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-cyan-500/10 border border-cyan-500/30 px-2 py-0.5 text-[10px] font-medium text-cyan-600 dark:text-cyan-400 shrink-0">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-pulse absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-cyan-400" />
</span>
Live
</span>
)}
{issue.projectId ? (
<Link
to={`/projects/${issue.projectId}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5 min-w-0"
>
<Hexagon className="h-3 w-3 shrink-0" />
<span className="truncate">{(projects ?? []).find((p) => p.id === issue.projectId)?.name ?? issue.projectId.slice(0, 8)}</span>
</Link>
) : (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
<Hexagon className="h-3 w-3 shrink-0" />
No project
</span>
)}
{(issue.labels ?? []).length > 0 && (
<div className="hidden sm:flex items-center gap-1">
{(issue.labels ?? []).slice(0, 4).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: label.color,
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 4 && (
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 4}</span>
)}
</div>
)}
<Button
variant="ghost"
size="icon-xs"
className="ml-auto md:hidden shrink-0"
onClick={() => setMobilePropsOpen(true)}
title="Properties"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<div className="hidden md:flex items-center md:ml-auto shrink-0">
<Button
variant="ghost"
size="icon-xs"
className={cn(
"shrink-0 transition-opacity duration-200",
panelVisible ? "opacity-0 pointer-events-none w-0 overflow-hidden" : "opacity-100",
)}
onClick={() => setPanelVisible(true)}
title="Show properties"
>
<SlidersHorizontal className="h-4 w-4" />
</Button>
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-xs" className="shrink-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
onClick={() => {
updateIssue.mutate(
{ hiddenAt: new Date().toISOString() },
{ onSuccess: () => navigate("/issues/all") },
);
setMoreOpen(false);
}}
>
<EyeOff className="h-3 w-3" />
Hide this Issue
</button>
</PopoverContent>
</Popover>
</div>
</div>
<InlineEditor
value={issue.title}
onSave={(title) => updateIssue.mutateAsync({ title })}
as="h2"
className="text-xl font-bold"
/>
<InlineEditor
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutateAsync({ description })}
as="p"
className="text-[15px] leading-7 text-foreground"
placeholder="Add a description..."
multiline
mentions={mentionOptions}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
/>
</div>
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<PluginLauncherOutlet
placementZones={["toolbarButton"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
/>
<PluginSlotOutlet
slotTypes={["taskDetailView"]}
entityType="issue"
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
className="space-y-3"
itemClassName="rounded-lg border border-border p-3"
missingBehavior="placeholder"
/>
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
mentions={mentionOptions}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
extraActions={!hasAttachments ? attachmentUploadButton : undefined}
/>
{hasAttachments ? (
<div
className={cn(
"space-y-3 rounded-lg transition-colors",
)}
onDragEnter={(evt) => {
evt.preventDefault();
setAttachmentDragActive(true);
}}
onDragOver={(evt) => {
evt.preventDefault();
setAttachmentDragActive(true);
}}
onDragLeave={(evt) => {
if (evt.currentTarget.contains(evt.relatedTarget as Node | null)) return;
setAttachmentDragActive(false);
}}
onDrop={(evt) => void handleAttachmentDrop(evt)}
>
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Attachments</h3>
{attachmentUploadButton}
</div>
{attachmentError && (
<p className="text-xs text-destructive">{attachmentError}</p>
)}
<div className="space-y-2">
{attachmentList.map((attachment) => (
<div key={attachment.id} className="border border-border rounded-md p-2">
<div className="flex items-center justify-between gap-2">
<a
href={attachment.contentPath}
target="_blank"
rel="noreferrer"
className="text-xs hover:underline truncate"
title={attachment.originalFilename ?? attachment.id}
>
{attachment.originalFilename ?? attachment.id}
</a>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => deleteAttachment.mutate(attachment.id)}
disabled={deleteAttachment.isPending}
title="Delete attachment"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[11px] text-muted-foreground">
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
</p>
{isImageAttachment(attachment) && (
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
<img
src={attachment.contentPath}
alt={attachment.originalFilename ?? "attachment"}
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
loading="lazy"
/>
</a>
)}
</div>
))}
</div>
</div>
) : null}
<Separator />
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="comments" className="gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Comments
</TabsTrigger>
{showOutputsTab && (
<TabsTrigger value="outputs" className="gap-1.5">
<Rocket className="h-3.5 w-3.5" />
Outputs
</TabsTrigger>
)}
<TabsTrigger value="subissues" className="gap-1.5">
<ListTree className="h-3.5 w-3.5" />
Sub-issues
</TabsTrigger>
<TabsTrigger value="activity" className="gap-1.5">
<ActivityIcon className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
{issuePluginTabItems.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="comments">
<CommentThread
comments={commentsWithRunMeta}
linkedRuns={timelineRuns}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign
reassignOptions={commentReassignOptions}
currentAssigneeValue={currentAssigneeValue}
mentions={mentionOptions}
onAdd={async (body, reopen, reassignment) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
return;
}
await addComment.mutateAsync({ body, reopen });
}}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onAttachImage={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
liveRunSlot={<LiveRunWidget issueId={issueId!} companyId={issue.companyId} />}
/>
</TabsContent>
{showOutputsTab && (
<TabsContent value="outputs" className="space-y-4">
{issue.currentExecutionWorkspace && (
<div className="rounded-lg border border-border p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-sm font-medium">Execution workspace</div>
<div className="text-xs text-muted-foreground">
{issue.currentExecutionWorkspace.status} · {issue.currentExecutionWorkspace.mode}
</div>
</div>
<Link
to={`/execution-workspaces/${issue.currentExecutionWorkspace.id}`}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
Open
<ExternalLink className="h-3 w-3" />
</Link>
</div>
<div className="text-xs text-muted-foreground">
{issue.currentExecutionWorkspace.branchName ?? issue.currentExecutionWorkspace.cwd ?? "No workspace path recorded."}
</div>
</div>
)}
<div className="rounded-lg border border-border p-3 space-y-3">
<div className="text-sm font-medium">Work product</div>
<div className="grid gap-2 sm:grid-cols-2">
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={newWorkProductType}
onChange={(e) => setNewWorkProductType(e.target.value as IssueWorkProduct["type"])}
>
<option value="preview_url">Preview URL</option>
<option value="runtime_service">Runtime service</option>
<option value="pull_request">Pull request</option>
<option value="branch">Branch</option>
<option value="commit">Commit</option>
<option value="artifact">Artifact</option>
<option value="document">Document</option>
</select>
<input
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={newWorkProductProvider}
onChange={(e) => setNewWorkProductProvider(e.target.value)}
placeholder="Provider"
/>
<input
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2"
value={newWorkProductTitle}
onChange={(e) => setNewWorkProductTitle(e.target.value)}
placeholder="Title"
/>
<input
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2"
value={newWorkProductUrl}
onChange={(e) => setNewWorkProductUrl(e.target.value)}
placeholder="URL"
/>
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={newWorkProductStatus}
onChange={(e) => setNewWorkProductStatus(e.target.value as IssueWorkProduct["status"])}
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="ready_for_review">Ready for review</option>
<option value="approved">Approved</option>
<option value="changes_requested">Changes requested</option>
<option value="merged">Merged</option>
<option value="closed">Closed</option>
<option value="failed">Failed</option>
<option value="archived">Archived</option>
</select>
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={newWorkProductReviewState}
onChange={(e) => setNewWorkProductReviewState(e.target.value as IssueWorkProduct["reviewState"])}
>
<option value="none">No review state</option>
<option value="needs_board_review">Needs board review</option>
<option value="approved">Approved</option>
<option value="changes_requested">Changes requested</option>
</select>
<textarea
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none sm:col-span-2 min-h-20"
value={newWorkProductSummary}
onChange={(e) => setNewWorkProductSummary(e.target.value)}
placeholder="Summary"
/>
</div>
<div className="flex justify-end">
<Button
size="sm"
disabled={!newWorkProductTitle.trim() || createWorkProduct.isPending}
onClick={() => createWorkProduct.mutate()}
>
{createWorkProduct.isPending ? "Adding..." : "Add output"}
</Button>
</div>
</div>
{workProducts.length === 0 ? (
<p className="text-xs text-muted-foreground">No work product yet.</p>
) : (
<div className="space-y-2">
{workProducts.map((product) => (
<div key={product.id} className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium">
{workProductIcon(product)}
<span className="truncate">{product.title}</span>
{product.isPrimary && (
<span className="rounded-full border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground">
Primary
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{product.type.replace(/_/g, " ")} · {product.provider}
</div>
</div>
<button
type="button"
className="text-muted-foreground hover:text-destructive"
onClick={() => {
if (!window.confirm(`Delete "${product.title}"?`)) return;
deleteWorkProduct.mutate(product.id);
}}
disabled={deleteWorkProduct.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{product.url && (
<a
href={product.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{product.url}
<ExternalLink className="h-3 w-3" />
</a>
)}
{product.summary && (
<div className="text-xs text-muted-foreground">{product.summary}</div>
)}
<div className="grid gap-2 sm:grid-cols-3">
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={product.status}
onChange={(e) =>
updateWorkProduct.mutate({ id: product.id, data: { status: e.target.value } })}
>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="ready_for_review">Ready for review</option>
<option value="approved">Approved</option>
<option value="changes_requested">Changes requested</option>
<option value="merged">Merged</option>
<option value="closed">Closed</option>
<option value="failed">Failed</option>
<option value="archived">Archived</option>
</select>
<select
className="rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
value={product.reviewState}
onChange={(e) =>
updateWorkProduct.mutate({ id: product.id, data: { reviewState: e.target.value } })}
>
<option value="none">No review state</option>
<option value="needs_board_review">Needs board review</option>
<option value="approved">Approved</option>
<option value="changes_requested">Changes requested</option>
</select>
<Button
variant={product.isPrimary ? "secondary" : "outline"}
size="sm"
onClick={() => updateWorkProduct.mutate({ id: product.id, data: { isPrimary: true } })}
disabled={product.isPrimary || updateWorkProduct.isPending}
>
{product.isPrimary ? "Primary" : "Make primary"}
</Button>
</div>
</div>
))}
</div>
)}
</TabsContent>
)}
<TabsContent value="subissues">
{childIssues.length === 0 ? (
<p className="text-xs text-muted-foreground">No sub-issues.</p>
) : (
<div className="border border-border rounded-lg divide-y divide-border">
{childIssues.map((child) => (
<Link
key={child.id}
to={`/issues/${child.identifier ?? child.id}`}
state={location.state}
className="flex items-center justify-between px-3 py-2 text-sm hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<StatusIcon status={child.status} />
<PriorityIcon priority={child.priority} />
<span className="font-mono text-muted-foreground shrink-0">
{child.identifier ?? child.id.slice(0, 8)}
</span>
<span className="truncate">{child.title}</span>
</div>
{child.assigneeAgentId && (() => {
const name = agentMap.get(child.assigneeAgentId)?.name;
return name
? <Identity name={name} size="sm" />
: <span className="text-muted-foreground font-mono">{child.assigneeAgentId.slice(0, 8)}</span>;
})()}
</Link>
))}
</div>
)}
</TabsContent>
<TabsContent value="activity">
{!activity || activity.length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p>
) : (
<div className="space-y-1.5">
{activity.slice(0, 20).map((evt) => (
<div key={evt.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ActorIdentity evt={evt} agentMap={agentMap} />
<span>{formatAction(evt.action, evt.details)}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
))}
</div>
)}
</TabsContent>
{activePluginTab && (
<TabsContent value={activePluginTab.value}>
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: issue.companyId,
projectId: issue.projectId ?? null,
entityId: issue.id,
entityType: "issue",
}}
missingBehavior="placeholder"
/>
</TabsContent>
)}
</Tabs>
{linkedApprovals && linkedApprovals.length > 0 && (
<Collapsible
open={secondaryOpen.approvals}
onOpenChange={(open) => setSecondaryOpen((prev) => ({ ...prev, approvals: 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">
Linked Approvals ({linkedApprovals.length})
</span>
<ChevronDown
className={cn("h-4 w-4 text-muted-foreground transition-transform", secondaryOpen.approvals && "rotate-180")}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t border-border divide-y divide-border">
{linkedApprovals.map((approval) => (
<Link
key={approval.id}
to={`/approvals/${approval.id}`}
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-accent/20 transition-colors"
>
<div className="flex items-center gap-2">
<StatusBadge status={approval.status} />
<span className="font-medium">
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
<span className="font-mono text-muted-foreground">{approval.id.slice(0, 8)}</span>
</div>
<span className="text-muted-foreground">{relativeTime(approval.createdAt)}</span>
</Link>
))}
</div>
</CollapsibleContent>
</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}>
<SheetContent side="bottom" className="max-h-[85dvh] pb-[env(safe-area-inset-bottom)]">
<SheetHeader>
<SheetTitle className="text-sm">Properties</SheetTitle>
</SheetHeader>
<ScrollArea className="flex-1 overflow-y-auto">
<div className="px-4 pb-4">
<IssueProperties issue={issue} onUpdate={(data) => updateIssue.mutate(data)} inline />
</div>
</ScrollArea>
</SheetContent>
</Sheet>
<ScrollToBottom />
</div>
);
}