forked from farhoodlabs/paperclip
5f45712846
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The board depends on issue, inbox, cost, and company-skill surfaces to stay accurate and fast while agents are actively working > - The PAP-1497 follow-up branch exposed a few rough edges in those surfaces: stale active-run state on completed issues, missing creator filters, oversized issue payload scans, and placeholder issue-route parsing > - Those gaps make the control plane harder to trust because operators can see misleading run state, miss the right subset of work, or pay extra query/render cost on large issue records > - This pull request tightens those follow-ups across server and UI code, and adds regression coverage for the affected paths > - The benefit is a more reliable issue workflow, safer high-volume cost aggregation, and clearer board/operator navigation ## What Changed - Added the `v2026.415.0` release changelog entry. - Fixed stale issue-run presentation after completion and reused the shared issue-path parser so literal route placeholders no longer become issue links. - Added creator filters to the Issues page and Inbox, including persisted filter-state normalization and regression coverage. - Bounded issue detail/list project-mention scans and trimmed large issue-list payload fields to keep issue reads lighter. - Hardened company-skill list projection and cost/finance aggregation so large markdown blobs and large summed values do not leak into list responses or overflow 32-bit casts. - Added targeted server/UI regression tests for company skills, costs/finance, issue mention scanning, creator filters, inbox normalization, and issue reference parsing. ## Verification - `pnpm exec vitest run server/src/__tests__/company-skills-service.test.ts server/src/__tests__/costs-service.test.ts server/src/__tests__/issues-goal-context-routes.test.ts server/src/__tests__/issues-service.test.ts ui/src/lib/inbox.test.ts ui/src/lib/issue-filters.test.ts ui/src/lib/issue-reference.test.ts` - `gh pr checks 3779` Current pass set on the PR head: `policy`, `verify`, `e2e`, `security/snyk (cryppadotta)`, `Greptile Review` ## Risks - Creator filter options are derived from the currently loaded issue/agent data, so very sparse result sets may not surface every historical creator until they appear in the active dataset. - Cost/finance aggregate casts now use `double precision`; that removes the current overflow risk, but future schema changes should keep large-value aggregation behavior under review. - Issue detail mention scanning now skips comment-body scans on the detail route, so any consumer that relied on comment-only project mentions there would need to fetch them separately. ## Model Used - OpenAI Codex, GPT-5-based coding agent with terminal tool use and local code execution in the Paperclip workspace. Exact internal model ID/context-window exposure is not surfaced in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
2702 lines
98 KiB
TypeScript
2702 lines
98 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type Ref } from "react";
|
|
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
|
import { Link, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
|
|
import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type InfiniteData, type QueryClient } from "@tanstack/react-query";
|
|
import { issuesApi } from "../api/issues";
|
|
import { approvalsApi } from "../api/approvals";
|
|
import { activityApi, type RunForIssue } from "../api/activity";
|
|
import { heartbeatsApi, type ActiveRunForIssue, type LiveRunForIssue } from "../api/heartbeats";
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
import { agentsApi } from "../api/agents";
|
|
import { authApi } from "../api/auth";
|
|
import { projectsApi } from "../api/projects";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useDialog } from "../context/DialogContext";
|
|
import { usePanel } from "../context/PanelContext";
|
|
import { useSidebar } from "../context/SidebarContext";
|
|
import { useToastActions } from "../context/ToastContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
|
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
|
import {
|
|
hasLegacyIssueDetailQuery,
|
|
createIssueDetailPath,
|
|
readIssueDetailLocationState,
|
|
readIssueDetailBreadcrumb,
|
|
readIssueDetailHeaderSeed,
|
|
rememberIssueDetailLocationState,
|
|
} from "../lib/issueDetailBreadcrumb";
|
|
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
|
|
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
|
|
import {
|
|
hasBlockingShortcutDialog,
|
|
resolveIssueDetailGoKeyAction,
|
|
resolveInboxQuickArchiveKeyAction,
|
|
} from "../lib/keyboardShortcuts";
|
|
import {
|
|
applyOptimisticIssueFieldUpdate,
|
|
applyOptimisticIssueFieldUpdateToCollection,
|
|
applyOptimisticIssueCommentUpdate,
|
|
createOptimisticIssueComment,
|
|
flattenIssueCommentPages,
|
|
getNextIssueCommentPageParam,
|
|
isQueuedIssueComment,
|
|
matchesIssueRef,
|
|
mergeIssueComments,
|
|
removeIssueCommentFromPages,
|
|
takeOptimisticIssueComment,
|
|
upsertIssueCommentInPages,
|
|
type IssueCommentReassignment,
|
|
type OptimisticIssueComment,
|
|
} from "../lib/optimistic-issue-comments";
|
|
import { removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
|
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
|
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
|
import { ApprovalCard } from "../components/ApprovalCard";
|
|
import { InlineEditor } from "../components/InlineEditor";
|
|
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
|
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
|
import { IssuesList } from "../components/IssuesList";
|
|
import { IssueProperties } from "../components/IssueProperties";
|
|
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
|
import type { MentionOption } from "../components/MarkdownEditor";
|
|
import { ImageGalleryModal } from "../components/ImageGalleryModal";
|
|
import { ScrollToBottom } from "../components/ScrollToBottom";
|
|
import { StatusIcon } from "../components/StatusIcon";
|
|
import { PriorityIcon } from "../components/PriorityIcon";
|
|
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 { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { formatIssueActivityAction } from "@/lib/activity-format";
|
|
import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key";
|
|
import { shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
|
|
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
|
import {
|
|
Activity as ActivityIcon,
|
|
Archive,
|
|
ArrowLeft,
|
|
Check,
|
|
ChevronRight,
|
|
Copy,
|
|
EyeOff,
|
|
Hexagon,
|
|
MessageSquare,
|
|
MoreHorizontal,
|
|
MoreVertical,
|
|
Paperclip,
|
|
Plus,
|
|
Repeat,
|
|
SlidersHorizontal,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import {
|
|
getClosedIsolatedExecutionWorkspaceMessage,
|
|
isClosedIsolatedExecutionWorkspace,
|
|
type ActivityEvent,
|
|
type Agent,
|
|
type FeedbackVote,
|
|
type Issue,
|
|
type IssueAttachment,
|
|
type IssueComment,
|
|
} from "@paperclipai/shared";
|
|
|
|
type CommentReassignment = IssueCommentReassignment;
|
|
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
|
runId?: string | null;
|
|
runAgentId?: string | null;
|
|
interruptedRunId?: string | null;
|
|
queueState?: "queued";
|
|
queueTargetRunId?: string | null;
|
|
};
|
|
|
|
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
|
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
|
|
|
function resolveRunningIssueRun(
|
|
activeRun: ActiveRunForIssue | null | undefined,
|
|
liveRuns: readonly LiveRunForIssue[] | undefined,
|
|
) {
|
|
return activeRun?.status === "running"
|
|
? activeRun
|
|
: (liveRuns ?? []).find((run) => run.status === "running") ?? null;
|
|
}
|
|
|
|
function readIssueRunStateFromCache(queryClient: QueryClient, issueId: string) {
|
|
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(
|
|
queryKeys.issues.liveRuns(issueId),
|
|
);
|
|
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(
|
|
queryKeys.issues.activeRun(issueId),
|
|
);
|
|
return {
|
|
liveRuns,
|
|
activeRun,
|
|
runningIssueRun: resolveRunningIssueRun(activeRun, liveRuns),
|
|
};
|
|
}
|
|
|
|
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 mergeOptimisticFeedbackVote(
|
|
previousVotes: FeedbackVote[] | undefined,
|
|
nextVote: {
|
|
issueId: string;
|
|
targetType: "issue_comment" | "issue_document_revision";
|
|
targetId: string;
|
|
vote: "up" | "down";
|
|
reason?: string;
|
|
},
|
|
currentUserId: string | null,
|
|
): FeedbackVote[] {
|
|
const now = new Date();
|
|
const existingVotes = previousVotes ?? [];
|
|
const existingIndex = existingVotes.findIndex(
|
|
(feedbackVote) =>
|
|
feedbackVote.targetType === nextVote.targetType &&
|
|
feedbackVote.targetId === nextVote.targetId &&
|
|
(!currentUserId || feedbackVote.authorUserId === currentUserId),
|
|
);
|
|
|
|
if (existingIndex >= 0) {
|
|
const existingVote = existingVotes[existingIndex]!;
|
|
const updatedVote: FeedbackVote = {
|
|
...existingVote,
|
|
vote: nextVote.vote,
|
|
reason:
|
|
nextVote.reason !== undefined
|
|
? nextVote.reason.trim() || null
|
|
: existingVote.reason,
|
|
updatedAt: now,
|
|
};
|
|
const nextVotes = [...existingVotes];
|
|
nextVotes[existingIndex] = updatedVote;
|
|
return nextVotes;
|
|
}
|
|
|
|
return [
|
|
...existingVotes,
|
|
{
|
|
id: `optimistic:${nextVote.targetType}:${nextVote.targetId}`,
|
|
companyId: "",
|
|
issueId: nextVote.issueId,
|
|
targetType: nextVote.targetType,
|
|
targetId: nextVote.targetId,
|
|
authorUserId: currentUserId ?? "current-user",
|
|
vote: nextVote.vote,
|
|
reason: nextVote.reason?.trim() || null,
|
|
sharedWithLabs: false,
|
|
sharedAt: null,
|
|
consentVersion: null,
|
|
redactionSummary: null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
},
|
|
];
|
|
}
|
|
|
|
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" />;
|
|
}
|
|
|
|
function IssueSectionSkeleton({
|
|
titleWidth = "w-28",
|
|
rows = 3,
|
|
}: {
|
|
titleWidth?: string;
|
|
rows?: number;
|
|
}) {
|
|
return (
|
|
<div className="space-y-3 rounded-lg border border-border p-3">
|
|
<Skeleton className={cn("h-4", titleWidth)} />
|
|
<div className="space-y-2">
|
|
{Array.from({ length: rows }).map((_, index) => (
|
|
<Skeleton key={index} className="h-12 w-full rounded-md" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IssueChatSkeleton() {
|
|
return (
|
|
<div className="space-y-3 rounded-lg border border-border p-3">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-3 w-24" />
|
|
<Skeleton className="h-3 w-16" />
|
|
</div>
|
|
</div>
|
|
<Skeleton className="h-20 w-full rounded-xl" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<div className="space-y-2 text-right">
|
|
<Skeleton className="ml-auto h-3 w-20" />
|
|
<Skeleton className="ml-auto h-3 w-14" />
|
|
</div>
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
|
</div>
|
|
<Skeleton className="ml-auto h-16 w-[85%] rounded-xl" />
|
|
</div>
|
|
<div className="space-y-2 border-t border-border pt-3">
|
|
<Skeleton className="h-3 w-28" />
|
|
<Skeleton className="h-24 w-full rounded-xl" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function IssueDetailLoadingState({
|
|
headerSeed,
|
|
}: {
|
|
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
|
|
}) {
|
|
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
|
|
|
|
return (
|
|
<div className="max-w-2xl space-y-6">
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-3 w-40" />
|
|
|
|
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
|
{headerSeed ? (
|
|
<>
|
|
<StatusIcon status={headerSeed.status} />
|
|
<PriorityIcon priority={headerSeed.priority} />
|
|
{identifier ? (
|
|
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
|
|
) : null}
|
|
{headerSeed.originKind === "routine_execution" && headerSeed.originId ? (
|
|
<span className="inline-flex items-center gap-1 rounded-full border border-violet-500/30 bg-violet-500/10 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0">
|
|
<Repeat className="h-3 w-3" />
|
|
Routine
|
|
</span>
|
|
) : null}
|
|
{headerSeed.projectId ? (
|
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground rounded px-1 -mx-1 py-0.5 min-w-0">
|
|
<Hexagon className="h-3 w-3 shrink-0" />
|
|
<span className="truncate">
|
|
{headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)}
|
|
</span>
|
|
</span>
|
|
) : (
|
|
<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>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Skeleton className="h-6 w-6" />
|
|
<Skeleton className="h-6 w-6" />
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-4 w-28" />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{headerSeed ? (
|
|
<>
|
|
<h2 className="text-xl font-bold leading-tight">{headerSeed.title}</h2>
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-full max-w-xl" />
|
|
<Skeleton className="h-4 w-[72%]" />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Skeleton className="h-8 w-[min(100%,22rem)]" />
|
|
<Skeleton className="h-16 w-full" />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<Skeleton className="h-28 w-full rounded-lg border border-border" />
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton className="h-8 w-20" />
|
|
<Skeleton className="h-8 w-20" />
|
|
</div>
|
|
<IssueChatSkeleton />
|
|
</div>
|
|
|
|
<IssueSectionSkeleton titleWidth="w-24" rows={3} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface InboxMobileToolbarProps {
|
|
backHref: string;
|
|
issueId: string | undefined;
|
|
issueHidden: boolean;
|
|
onArchive: () => void;
|
|
archivePending: boolean;
|
|
onCopy: () => void;
|
|
onProperties: () => void;
|
|
onHide: () => void;
|
|
}
|
|
|
|
function InboxMobileToolbar({
|
|
backHref,
|
|
issueId: issueIdProp,
|
|
issueHidden,
|
|
onArchive,
|
|
archivePending,
|
|
onCopy,
|
|
onProperties,
|
|
onHide,
|
|
}: InboxMobileToolbarProps) {
|
|
const navigate = useNavigate();
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="flex items-center w-full">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => {
|
|
// Use browser back when we have real history so the inbox
|
|
// restores its scroll position. Fall back to a PUSH to
|
|
// backHref when there's no prior entry (e.g. deep-link).
|
|
if (window.history.length > 1) {
|
|
navigate(-1);
|
|
} else {
|
|
navigate(backHref);
|
|
}
|
|
}}
|
|
aria-label="Back to inbox"
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Button>
|
|
|
|
<div className="ml-auto flex items-center gap-0.5">
|
|
{issueIdProp && !issueHidden && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={onArchive}
|
|
disabled={archivePending}
|
|
aria-label="Archive from inbox"
|
|
>
|
|
<Archive className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
|
|
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost" size="icon-sm" aria-label="More actions">
|
|
<MoreVertical className="h-5 w-5" />
|
|
</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"
|
|
onClick={() => { onCopy(); setMenuOpen(false); }}
|
|
>
|
|
<Copy className="h-3 w-3" />
|
|
Copy as markdown
|
|
</button>
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
onClick={() => { onProperties(); setMenuOpen(false); }}
|
|
>
|
|
<SlidersHorizontal className="h-3 w-3" />
|
|
Properties
|
|
</button>
|
|
{issueIdProp && (
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
|
onClick={() => { onHide(); setMenuOpen(false); }}
|
|
>
|
|
<EyeOff className="h-3 w-3" />
|
|
Hide this issue
|
|
</button>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type IssueDetailChatTabProps = {
|
|
issueId: string;
|
|
companyId: string;
|
|
projectId: string | null;
|
|
issueStatus: Issue["status"];
|
|
executionRunId: string | null;
|
|
comments: IssueDetailComment[];
|
|
hasOlderComments: boolean;
|
|
commentsLoadingOlder: boolean;
|
|
onLoadOlderComments: () => void;
|
|
composerRef: Ref<IssueChatComposerHandle>;
|
|
feedbackVotes?: FeedbackVote[];
|
|
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
|
feedbackTermsUrl: string | null;
|
|
agentMap: Map<string, Agent>;
|
|
currentUserId: string | null;
|
|
draftKey: string;
|
|
reassignOptions: Array<{ id: string; label: string; searchText?: string }>;
|
|
currentAssigneeValue: string;
|
|
suggestedAssigneeValue: string;
|
|
mentions: MentionOption[];
|
|
composerDisabledReason: string | null;
|
|
onVote: (
|
|
commentId: string,
|
|
vote: "up" | "down",
|
|
options?: { allowSharing?: boolean; reason?: string },
|
|
) => Promise<void>;
|
|
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
|
|
onImageUpload: (file: File) => Promise<string>;
|
|
onAttachImage: (file: File) => Promise<void>;
|
|
onInterruptQueued: (runId: string) => Promise<void>;
|
|
onCancelQueued: (commentId: string) => void;
|
|
interruptingQueuedRunId: string | null;
|
|
onImageClick: (src: string) => void;
|
|
};
|
|
|
|
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|
issueId,
|
|
companyId,
|
|
projectId,
|
|
issueStatus,
|
|
executionRunId,
|
|
comments,
|
|
hasOlderComments,
|
|
commentsLoadingOlder,
|
|
onLoadOlderComments,
|
|
composerRef,
|
|
feedbackVotes,
|
|
feedbackDataSharingPreference,
|
|
feedbackTermsUrl,
|
|
agentMap,
|
|
currentUserId,
|
|
draftKey,
|
|
reassignOptions,
|
|
currentAssigneeValue,
|
|
suggestedAssigneeValue,
|
|
mentions,
|
|
composerDisabledReason,
|
|
onVote,
|
|
onAdd,
|
|
onImageUpload,
|
|
onAttachImage,
|
|
onInterruptQueued,
|
|
onCancelQueued,
|
|
interruptingQueuedRunId,
|
|
onImageClick,
|
|
}: IssueDetailChatTabProps) {
|
|
const { data: activity } = useQuery({
|
|
queryKey: queryKeys.issues.activity(issueId),
|
|
queryFn: () => activityApi.forIssue(issueId),
|
|
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
|
|
});
|
|
const { data: liveRuns } = useQuery({
|
|
queryKey: queryKeys.issues.liveRuns(issueId),
|
|
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId),
|
|
refetchInterval: 3000,
|
|
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId),
|
|
});
|
|
const resolvedLiveRuns = liveRuns ?? [];
|
|
const liveRunCount = resolvedLiveRuns.length;
|
|
const { data: activeRun = null } = useQuery({
|
|
queryKey: queryKeys.issues.activeRun(issueId),
|
|
queryFn: () => heartbeatsApi.activeRunForIssue(issueId),
|
|
enabled: !!executionRunId || issueStatus === "in_progress",
|
|
refetchInterval: liveRunCount > 0 ? false : 3000,
|
|
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId),
|
|
});
|
|
const resolvedActiveRun = useMemo(
|
|
() => resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun),
|
|
[activeRun, executionRunId, issueStatus],
|
|
);
|
|
const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun;
|
|
const { data: linkedRuns } = useQuery({
|
|
queryKey: queryKeys.issues.runs(issueId),
|
|
queryFn: () => activityApi.runsForIssue(issueId),
|
|
refetchInterval: hasLiveRuns ? 5000 : false,
|
|
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
|
});
|
|
const resolvedActivity = activity ?? [];
|
|
const resolvedLinkedRuns = linkedRuns ?? [];
|
|
|
|
const runningIssueRun = useMemo(
|
|
() => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns),
|
|
[resolvedActiveRun, resolvedLiveRuns],
|
|
);
|
|
const timelineRuns = useMemo(() => {
|
|
const liveIds = new Set<string>();
|
|
for (const run of resolvedLiveRuns) liveIds.add(run.id);
|
|
if (activeRun) liveIds.add(activeRun.id);
|
|
const historicalRuns = liveIds.size === 0
|
|
? resolvedLinkedRuns
|
|
: resolvedLinkedRuns.filter((run) => !liveIds.has(run.runId));
|
|
return historicalRuns.map((run) => ({
|
|
...run,
|
|
adapterType: run.adapterType,
|
|
hasStoredOutput: (run.logBytes ?? 0) > 0,
|
|
}));
|
|
}, [activeRun, resolvedLinkedRuns, resolvedLiveRuns]);
|
|
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
|
|
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
|
|
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
|
|
const agentIdByRunId = new Map<string, string>();
|
|
|
|
for (const run of resolvedLinkedRuns) {
|
|
agentIdByRunId.set(run.runId, run.agentId);
|
|
}
|
|
for (const evt of resolvedActivity) {
|
|
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;
|
|
const interruptedRunId =
|
|
typeof details["interruptedRunId"] === "string" ? details["interruptedRunId"] : null;
|
|
runMetaByCommentId.set(commentId, {
|
|
runId: evt.runId,
|
|
runAgentId: evt.agentId ?? agentIdByRunId.get(evt.runId) ?? null,
|
|
interruptedRunId,
|
|
});
|
|
}
|
|
|
|
return comments.map((comment) => {
|
|
const meta = runMetaByCommentId.get(comment.id);
|
|
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
|
|
if (
|
|
isQueuedIssueComment({
|
|
comment: nextComment,
|
|
activeRunStartedAt,
|
|
activeRunAgentId: runningIssueRun?.agentId ?? null,
|
|
runId: meta?.runId ?? nextComment.runId ?? null,
|
|
interruptedRunId: meta?.interruptedRunId ?? nextComment.interruptedRunId ?? null,
|
|
})
|
|
) {
|
|
return {
|
|
...nextComment,
|
|
queueState: "queued" as const,
|
|
queueTargetRunId: runningIssueRun?.id ?? nextComment.queueTargetRunId ?? null,
|
|
};
|
|
}
|
|
return nextComment;
|
|
});
|
|
}, [comments, resolvedActivity, resolvedLinkedRuns, runningIssueRun]);
|
|
const timelineEvents = useMemo(
|
|
() => extractIssueTimelineEvents(resolvedActivity),
|
|
[resolvedActivity],
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{hasOlderComments ? (
|
|
<div className="flex justify-center">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={commentsLoadingOlder}
|
|
onClick={onLoadOlderComments}
|
|
>
|
|
{commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
<IssueChatThread
|
|
composerRef={composerRef}
|
|
comments={commentsWithRunMeta}
|
|
feedbackVotes={feedbackVotes}
|
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
|
feedbackTermsUrl={feedbackTermsUrl}
|
|
linkedRuns={timelineRuns}
|
|
timelineEvents={timelineEvents}
|
|
liveRuns={resolvedLiveRuns}
|
|
activeRun={resolvedActiveRun}
|
|
companyId={companyId}
|
|
projectId={projectId}
|
|
issueStatus={issueStatus}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
draftKey={draftKey}
|
|
enableReassign
|
|
reassignOptions={reassignOptions}
|
|
currentAssigneeValue={currentAssigneeValue}
|
|
suggestedAssigneeValue={suggestedAssigneeValue}
|
|
mentions={mentions}
|
|
composerDisabledReason={composerDisabledReason}
|
|
onVote={onVote}
|
|
onAdd={onAdd}
|
|
imageUploadHandler={onImageUpload}
|
|
onAttachImage={onAttachImage}
|
|
onInterruptQueued={onInterruptQueued}
|
|
onCancelQueued={onCancelQueued}
|
|
interruptingQueuedRunId={interruptingQueuedRunId}
|
|
stoppingRunId={interruptingQueuedRunId}
|
|
onStopRun={onInterruptQueued}
|
|
onCancelRun={runningIssueRun
|
|
? async () => {
|
|
await onInterruptQueued(runningIssueRun.id);
|
|
}
|
|
: undefined}
|
|
onImageClick={onImageClick}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
type IssueDetailActivityTabProps = {
|
|
issueId: string;
|
|
agentMap: Map<string, Agent>;
|
|
currentUserId: string | null;
|
|
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
|
|
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
|
|
};
|
|
|
|
function IssueDetailActivityTab({
|
|
issueId,
|
|
agentMap,
|
|
currentUserId,
|
|
pendingApprovalAction,
|
|
onApprovalAction,
|
|
}: IssueDetailActivityTabProps) {
|
|
const { data: activity, isLoading: activityLoading } = useQuery({
|
|
queryKey: queryKeys.issues.activity(issueId),
|
|
queryFn: () => activityApi.forIssue(issueId),
|
|
placeholderData: keepPreviousDataForSameQueryTail<ActivityEvent[]>(issueId),
|
|
});
|
|
const { data: linkedRuns, isLoading: linkedRunsLoading } = useQuery({
|
|
queryKey: queryKeys.issues.runs(issueId),
|
|
queryFn: () => activityApi.runsForIssue(issueId),
|
|
placeholderData: keepPreviousDataForSameQueryTail<RunForIssue[]>(issueId),
|
|
});
|
|
const { data: linkedApprovals } = useQuery({
|
|
queryKey: queryKeys.issues.approvals(issueId),
|
|
queryFn: () => issuesApi.listApprovals(issueId),
|
|
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.listApprovals>>>(issueId),
|
|
});
|
|
const initialLoading =
|
|
(activityLoading && activity === undefined)
|
|
|| (linkedRunsLoading && linkedRuns === undefined);
|
|
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]);
|
|
|
|
if (initialLoading) {
|
|
return <IssueSectionSkeleton titleWidth="w-20" rows={4} />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{linkedApprovals && linkedApprovals.length > 0 && (
|
|
<div className="mb-3 space-y-3">
|
|
{linkedApprovals.map((approval) => (
|
|
<ApprovalCard
|
|
key={approval.id}
|
|
approval={approval}
|
|
requesterAgent={approval.requestedByAgentId ? agentMap.get(approval.requestedByAgentId) ?? null : null}
|
|
onApprove={() => onApprovalAction(approval.id, "approve")}
|
|
onReject={() => onApprovalAction(approval.id, "reject")}
|
|
detailLink={`/approvals/${approval.id}`}
|
|
isPending={pendingApprovalAction?.approvalId === approval.id}
|
|
pendingAction={
|
|
pendingApprovalAction?.approvalId === approval.id
|
|
? pendingApprovalAction.action
|
|
: null
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{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>
|
|
) : (
|
|
<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>{formatIssueActivityAction(evt.action, evt.details, { agentMap, currentUserId })}</span>
|
|
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function IssueDetail() {
|
|
const { issueId } = useParams<{ issueId: string }>();
|
|
const { selectedCompanyId } = useCompany();
|
|
const { openNewIssue } = useDialog();
|
|
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
|
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
|
|
const queryClient = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const navigationType = useNavigationType();
|
|
const location = useLocation();
|
|
const { pushToast } = useToastActions();
|
|
const { isMobile } = useSidebar();
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
|
const [detailTab, setDetailTab] = useState("chat");
|
|
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
|
approvalId: string;
|
|
action: "approve" | "reject";
|
|
} | null>(null);
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
|
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
|
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
|
const [galleryIndex, setGalleryIndex] = useState(0);
|
|
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
|
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
|
|
const commentComposerRef = useRef<IssueChatComposerHandle | null>(null);
|
|
const cancelledQueuedOptimisticCommentIdsRef = useRef(new Set<string>());
|
|
const resolvedIssueDetailState = useMemo(
|
|
() => readIssueDetailLocationState(issueId, location.state, location.search),
|
|
[issueId, location.state, location.search],
|
|
);
|
|
const issueHeaderSeed = useMemo(
|
|
() => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState),
|
|
[location.state, resolvedIssueDetailState],
|
|
);
|
|
|
|
const { data: issue, isLoading, error } = useQuery({
|
|
...getIssueDetailQueryOptions(queryClient, issueId!, {
|
|
placeholderIssue: issueHeaderSeed ? {
|
|
id: issueHeaderSeed.id,
|
|
identifier: issueHeaderSeed.identifier,
|
|
} : null,
|
|
}),
|
|
enabled: !!issueId,
|
|
});
|
|
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
|
|
const commentComposerDisabledReason = useMemo(() => {
|
|
if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) {
|
|
return null;
|
|
}
|
|
return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace);
|
|
}, [issue?.currentExecutionWorkspace]);
|
|
|
|
const {
|
|
data: commentPages,
|
|
isLoading: commentsLoading,
|
|
isFetchingNextPage: commentsLoadingOlder,
|
|
hasNextPage: hasOlderComments,
|
|
fetchNextPage: fetchOlderComments,
|
|
} = useInfiniteQuery({
|
|
queryKey: queryKeys.issues.comments(issueId!),
|
|
queryFn: ({ pageParam }) =>
|
|
issuesApi.listComments(issueId!, {
|
|
order: "desc",
|
|
limit: ISSUE_COMMENT_PAGE_SIZE,
|
|
...(pageParam ? { after: pageParam } : {}),
|
|
}),
|
|
enabled: !!issueId,
|
|
initialPageParam: null as string | null,
|
|
getNextPageParam: (lastPage) =>
|
|
getNextIssueCommentPageParam(lastPage, ISSUE_COMMENT_PAGE_SIZE),
|
|
placeholderData: keepPreviousDataForSameQueryTail<InfiniteData<IssueComment[], string | null>>(issueId ?? "pending"),
|
|
});
|
|
const comments = useMemo(
|
|
() => flattenIssueCommentPages(commentPages?.pages),
|
|
[commentPages?.pages],
|
|
);
|
|
|
|
const { data: attachments, isLoading: attachmentsLoading } = useQuery({
|
|
queryKey: queryKeys.issues.attachments(issueId!),
|
|
queryFn: () => issuesApi.listAttachments(issueId!),
|
|
enabled: !!issueId,
|
|
placeholderData: keepPreviousDataForSameQueryTail<IssueAttachment[]>(issueId ?? "pending"),
|
|
});
|
|
|
|
const { data: liveRunCount = 0 } = useQuery<LiveRunForIssue[], Error, number>({
|
|
queryKey: queryKeys.issues.liveRuns(issueId!),
|
|
queryFn: () => heartbeatsApi.liveRunsForIssue(issueId!),
|
|
enabled: !!issueId,
|
|
refetchInterval: 3000,
|
|
select: (runs) => runs.length,
|
|
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(issueId ?? "pending"),
|
|
});
|
|
|
|
const { data: hasActiveRun = false } = useQuery<ActiveRunForIssue | null, Error, boolean>({
|
|
queryKey: queryKeys.issues.activeRun(issueId!),
|
|
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
|
|
enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
|
|
refetchInterval: liveRunCount > 0 ? false : 3000,
|
|
select: (run) => !!run,
|
|
placeholderData: keepPreviousDataForSameQueryTail<ActiveRunForIssue | null>(issueId ?? "pending"),
|
|
});
|
|
const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun;
|
|
const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun;
|
|
const sourceBreadcrumb = useMemo(
|
|
() => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" },
|
|
[issueId, location.state, location.search],
|
|
);
|
|
|
|
const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({
|
|
queryKey:
|
|
issue?.id && resolvedCompanyId
|
|
? queryKeys.issues.listByParent(resolvedCompanyId, issue.id)
|
|
: ["issues", "parent", "pending"],
|
|
queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.id }),
|
|
enabled: !!resolvedCompanyId && !!issue?.id,
|
|
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(issue?.id ?? "pending"),
|
|
});
|
|
|
|
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 { data: feedbackVotes } = useQuery({
|
|
queryKey: queryKeys.issues.feedbackVotes(issueId!),
|
|
queryFn: () => issuesApi.listFeedbackVotes(issueId!),
|
|
enabled: !!issueId && !!currentUserId,
|
|
});
|
|
const { data: instanceGeneralSettings } = useQuery({
|
|
queryKey: queryKeys.instance.generalSettings,
|
|
queryFn: () => instanceSettingsApi.getGeneral(),
|
|
enabled: !!issueId,
|
|
retry: false,
|
|
});
|
|
const keyboardShortcutsEnabled = instanceGeneralSettings?.keyboardShortcuts === true;
|
|
const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt";
|
|
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",
|
|
agentId: agent.id,
|
|
agentIcon: agent.icon,
|
|
});
|
|
}
|
|
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 resolvedProject = useMemo(
|
|
() => (issue?.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? issue.project ?? null : null),
|
|
[issue?.project, issue?.projectId, orderedProjects],
|
|
);
|
|
const childIssues = useMemo(
|
|
() => [...rawChildIssues].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
|
[rawChildIssues],
|
|
);
|
|
const issuePanelKey = useMemo(
|
|
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
|
|
[childIssues, issue],
|
|
);
|
|
const panelIssue = useMemo(
|
|
() => issue ?? null,
|
|
[issue?.id, issuePanelKey],
|
|
);
|
|
const panelChildIssues = useMemo(
|
|
() => childIssues,
|
|
[issuePanelKey],
|
|
);
|
|
const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length);
|
|
const openNewSubIssue = useCallback(() => {
|
|
if (!issue) return;
|
|
openNewIssue(buildSubIssueDefaultsForViewer(issue, currentUserId));
|
|
}, [
|
|
currentUserId,
|
|
issue,
|
|
openNewIssue,
|
|
]);
|
|
|
|
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 actualAssigneeValue = useMemo(
|
|
() => assigneeValueFromSelection(issue ?? {}),
|
|
[issue],
|
|
);
|
|
|
|
const suggestedAssigneeValue = useMemo(
|
|
() =>
|
|
suggestedCommentAssigneeValue(
|
|
issue ?? {},
|
|
mergeIssueComments(comments ?? [], optimisticComments),
|
|
currentUserId,
|
|
),
|
|
[issue, comments, optimisticComments, currentUserId],
|
|
);
|
|
|
|
const threadComments = useMemo(
|
|
() => mergeIssueComments(comments ?? [], optimisticComments),
|
|
[comments, optimisticComments],
|
|
);
|
|
const breadcrumbTitle = issue?.title ?? issueId ?? "Issue";
|
|
|
|
const invalidateIssueDetail = useCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
|
}, [issueId, queryClient]);
|
|
const invalidateIssueThreadLazily = useCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" });
|
|
}, [issueId, queryClient]);
|
|
|
|
const invalidateIssueRunState = useCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
|
}, [issueId, queryClient]);
|
|
|
|
const removeCommentFromCache = useCallback((commentId: string) => {
|
|
queryClient.setQueryData<InfiniteData<IssueComment[], string | null> | undefined>(
|
|
queryKeys.issues.comments(issueId!),
|
|
(current) => {
|
|
if (!current) return current;
|
|
return {
|
|
...current,
|
|
pages: removeIssueCommentFromPages(current.pages, commentId),
|
|
};
|
|
},
|
|
);
|
|
}, [issueId, queryClient]);
|
|
|
|
const restoreQueuedCommentDraft = useCallback((body: string) => {
|
|
commentComposerRef.current?.restoreDraft(body);
|
|
}, []);
|
|
|
|
const invalidateIssueCollections = useCallback(() => {
|
|
if (selectedCompanyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(selectedCompanyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(selectedCompanyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(selectedCompanyId) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId) });
|
|
}
|
|
}, [queryClient, selectedCompanyId]);
|
|
|
|
const applyOptimisticIssueCacheUpdate = useCallback((refs: Iterable<string>, data: Record<string, unknown>) => {
|
|
queryClient.setQueriesData<Issue>(
|
|
{ queryKey: ["issues", "detail"] },
|
|
(cached) => (cached && matchesIssueRef(cached, refs) ? applyOptimisticIssueFieldUpdate(cached, data) : cached),
|
|
);
|
|
|
|
if (!selectedCompanyId) return;
|
|
queryClient.setQueryData<Issue[] | undefined>(
|
|
queryKeys.issues.list(selectedCompanyId),
|
|
(cached) => applyOptimisticIssueFieldUpdateToCollection(cached, refs, data),
|
|
);
|
|
}, [queryClient, selectedCompanyId]);
|
|
|
|
const mergeIssueResponseIntoCaches = useCallback((refs: Iterable<string>, nextIssue: Issue) => {
|
|
queryClient.setQueriesData<Issue>(
|
|
{ queryKey: ["issues", "detail"] },
|
|
(cached) => (cached && matchesIssueRef(cached, refs) ? { ...cached, ...nextIssue } : cached),
|
|
);
|
|
|
|
if (!selectedCompanyId) return;
|
|
queryClient.setQueryData<Issue[] | undefined>(
|
|
queryKeys.issues.list(selectedCompanyId),
|
|
(cached) => cached?.map((item) => (matchesIssueRef(item, refs) ? { ...item, ...nextIssue } : item)),
|
|
);
|
|
}, [queryClient, selectedCompanyId]);
|
|
|
|
const markIssueRead = useMutation({
|
|
mutationFn: (id: string) => issuesApi.markRead(id),
|
|
onSuccess: () => {
|
|
if (selectedCompanyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(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),
|
|
onMutate: async (data) => {
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
|
if (selectedCompanyId) {
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
|
}
|
|
|
|
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
|
const issueRefs = new Set<string>([issueId!]);
|
|
if (previousIssue?.id) issueRefs.add(previousIssue.id);
|
|
if (previousIssue?.identifier) issueRefs.add(previousIssue.identifier);
|
|
|
|
const previousDetailQueries = queryClient
|
|
.getQueriesData<Issue>({ queryKey: ["issues", "detail"] })
|
|
.filter(([, cachedIssue]) => cachedIssue && matchesIssueRef(cachedIssue, issueRefs));
|
|
const previousList = selectedCompanyId
|
|
? queryClient.getQueryData<Issue[]>(queryKeys.issues.list(selectedCompanyId))
|
|
: undefined;
|
|
|
|
applyOptimisticIssueCacheUpdate(issueRefs, data);
|
|
|
|
return { previousDetailQueries, previousList, selectedCompanyId };
|
|
},
|
|
onSuccess: ({ comment: _comment, ...nextIssue }) => {
|
|
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
|
|
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
|
|
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
|
|
invalidateIssueCollections();
|
|
},
|
|
onError: (err, _variables, context) => {
|
|
for (const [queryKey, previousIssue] of context?.previousDetailQueries ?? []) {
|
|
queryClient.setQueryData(queryKey, previousIssue);
|
|
}
|
|
if (context?.selectedCompanyId) {
|
|
queryClient.setQueryData(queryKeys.issues.list(context.selectedCompanyId), context.previousList);
|
|
}
|
|
pushToast({
|
|
title: "Issue update failed",
|
|
body: err instanceof Error ? err.message : "Unable to save issue changes",
|
|
tone: "error",
|
|
});
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
|
if (selectedCompanyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
|
}
|
|
},
|
|
});
|
|
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
|
|
updateIssue.mutate(data);
|
|
}, [updateIssue.mutate]);
|
|
|
|
const updateChildIssue = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
|
|
onSuccess: () => {
|
|
if (resolvedCompanyId) {
|
|
queryClient.invalidateQueries({ queryKey: ["issues", resolvedCompanyId] });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(resolvedCompanyId) });
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Issue update failed",
|
|
body: err instanceof Error ? err.message : "Unable to save sub-issue changes",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
const handleChildIssueUpdate = useCallback((id: string, data: Record<string, unknown>) => {
|
|
updateChildIssue.mutate({ id, data });
|
|
}, [updateChildIssue]);
|
|
|
|
const approvalDecision = useMutation({
|
|
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
|
|
if (action === "approve") {
|
|
return approvalsApi.approve(approvalId);
|
|
}
|
|
return approvalsApi.reject(approvalId);
|
|
},
|
|
onMutate: ({ approvalId, action }) => {
|
|
setPendingApprovalAction({ approvalId, action });
|
|
},
|
|
onSuccess: (_approval, variables) => {
|
|
invalidateIssueDetail();
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(issueId!) });
|
|
invalidateIssueCollections();
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(variables.approvalId) });
|
|
if (resolvedCompanyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(resolvedCompanyId) });
|
|
}
|
|
pushToast({
|
|
title: variables.action === "approve" ? "Approval approved" : "Approval rejected",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err, variables) => {
|
|
pushToast({
|
|
title: variables.action === "approve" ? "Approval failed" : "Rejection failed",
|
|
body: err instanceof Error ? err.message : "Unable to update approval",
|
|
tone: "error",
|
|
});
|
|
},
|
|
onSettled: () => {
|
|
setPendingApprovalAction(null);
|
|
},
|
|
});
|
|
|
|
const addComment = useMutation({
|
|
mutationFn: ({ body, reopen, interrupt }: { body: string; reopen?: boolean; interrupt?: boolean }) =>
|
|
issuesApi.addComment(issueId!, body, reopen, interrupt),
|
|
onMutate: async ({ body, reopen, interrupt }) => {
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
|
|
|
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
|
const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!).runningIssueRun : null;
|
|
const optimisticComment = issue
|
|
? createOptimisticIssueComment({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
body,
|
|
authorUserId: currentUserId,
|
|
clientStatus: queuedComment ? "queued" : "pending",
|
|
queueTargetRunId: queuedComment?.id ?? null,
|
|
})
|
|
: null;
|
|
|
|
if (optimisticComment) {
|
|
setOptimisticComments((current) => [...current, optimisticComment]);
|
|
}
|
|
if (previousIssue) {
|
|
queryClient.setQueryData(
|
|
queryKeys.issues.detail(issueId!),
|
|
applyOptimisticIssueCommentUpdate(previousIssue, { reopen }),
|
|
);
|
|
}
|
|
|
|
return {
|
|
optimisticCommentId: optimisticComment?.clientId ?? null,
|
|
previousIssue,
|
|
};
|
|
},
|
|
onSuccess: async (comment, _variables, context) => {
|
|
if (context?.optimisticCommentId) {
|
|
setOptimisticComments((current) =>
|
|
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
|
);
|
|
}
|
|
if (context?.optimisticCommentId && cancelledQueuedOptimisticCommentIdsRef.current.has(context.optimisticCommentId)) {
|
|
cancelledQueuedOptimisticCommentIdsRef.current.delete(context.optimisticCommentId);
|
|
try {
|
|
await issuesApi.cancelComment(issueId!, comment.id);
|
|
invalidateIssueDetail();
|
|
invalidateIssueThreadLazily();
|
|
invalidateIssueCollections();
|
|
return;
|
|
} catch (err) {
|
|
pushToast({
|
|
title: "Cancel failed",
|
|
body: err instanceof Error ? err.message : "Unable to cancel the queued comment",
|
|
tone: "error",
|
|
});
|
|
}
|
|
}
|
|
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
|
queryKeys.issues.comments(issueId!),
|
|
(current) => current ? {
|
|
...current,
|
|
pages: upsertIssueCommentInPages(current.pages, comment),
|
|
} : {
|
|
pageParams: [null],
|
|
pages: upsertIssueCommentInPages(undefined, comment),
|
|
},
|
|
);
|
|
},
|
|
onError: (err, _variables, context) => {
|
|
if (context?.optimisticCommentId) {
|
|
setOptimisticComments((current) =>
|
|
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
|
);
|
|
}
|
|
if (context?.previousIssue) {
|
|
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
|
}
|
|
pushToast({
|
|
title: "Comment failed",
|
|
body: err instanceof Error ? err.message : "Unable to post comment",
|
|
tone: "error",
|
|
});
|
|
},
|
|
onSettled: (_result, _error, variables) => {
|
|
invalidateIssueThreadLazily();
|
|
if (variables.interrupt) {
|
|
invalidateIssueRunState();
|
|
}
|
|
if (variables.reopen) {
|
|
invalidateIssueCollections();
|
|
}
|
|
},
|
|
});
|
|
|
|
const addCommentAndReassign = useMutation({
|
|
mutationFn: ({
|
|
body,
|
|
reopen,
|
|
interrupt,
|
|
reassignment,
|
|
}: {
|
|
body: string;
|
|
reopen?: boolean;
|
|
interrupt?: boolean;
|
|
reassignment: CommentReassignment;
|
|
}) =>
|
|
issuesApi.update(issueId!, {
|
|
comment: body,
|
|
assigneeAgentId: reassignment.assigneeAgentId,
|
|
assigneeUserId: reassignment.assigneeUserId,
|
|
...(reopen ? { status: "todo" } : {}),
|
|
...(interrupt ? { interrupt } : {}),
|
|
}),
|
|
onMutate: async ({ body, reopen, reassignment, interrupt }) => {
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
|
|
|
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
|
|
const queuedComment = !interrupt ? readIssueRunStateFromCache(queryClient, issueId!).runningIssueRun : null;
|
|
const optimisticComment = issue
|
|
? createOptimisticIssueComment({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
body,
|
|
authorUserId: currentUserId,
|
|
clientStatus: queuedComment ? "queued" : "pending",
|
|
queueTargetRunId: queuedComment?.id ?? null,
|
|
})
|
|
: null;
|
|
|
|
if (optimisticComment) {
|
|
setOptimisticComments((current) => [...current, optimisticComment]);
|
|
}
|
|
if (previousIssue) {
|
|
queryClient.setQueryData(
|
|
queryKeys.issues.detail(issueId!),
|
|
applyOptimisticIssueCommentUpdate(previousIssue, { reopen, reassignment }),
|
|
);
|
|
}
|
|
|
|
return {
|
|
optimisticCommentId: optimisticComment?.clientId ?? null,
|
|
previousIssue,
|
|
};
|
|
},
|
|
onSuccess: async (result, _variables, context) => {
|
|
if (context?.optimisticCommentId) {
|
|
setOptimisticComments((current) =>
|
|
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
|
);
|
|
}
|
|
|
|
const { comment, ...nextIssue } = result;
|
|
queryClient.setQueryData(queryKeys.issues.detail(issueId!), nextIssue);
|
|
if (comment && context?.optimisticCommentId && cancelledQueuedOptimisticCommentIdsRef.current.has(context.optimisticCommentId)) {
|
|
cancelledQueuedOptimisticCommentIdsRef.current.delete(context.optimisticCommentId);
|
|
try {
|
|
await issuesApi.cancelComment(issueId!, comment.id);
|
|
invalidateIssueDetail();
|
|
invalidateIssueThreadLazily();
|
|
invalidateIssueCollections();
|
|
return;
|
|
} catch (err) {
|
|
pushToast({
|
|
title: "Cancel failed",
|
|
body: err instanceof Error ? err.message : "Unable to cancel the queued comment",
|
|
tone: "error",
|
|
});
|
|
}
|
|
}
|
|
if (comment) {
|
|
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
|
queryKeys.issues.comments(issueId!),
|
|
(current) => current ? {
|
|
...current,
|
|
pages: upsertIssueCommentInPages(current.pages, comment),
|
|
} : {
|
|
pageParams: [null],
|
|
pages: upsertIssueCommentInPages(undefined, comment),
|
|
},
|
|
);
|
|
}
|
|
},
|
|
onError: (err, _variables, context) => {
|
|
if (context?.optimisticCommentId) {
|
|
setOptimisticComments((current) =>
|
|
current.filter((entry) => entry.clientId !== context.optimisticCommentId),
|
|
);
|
|
}
|
|
if (context?.previousIssue) {
|
|
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context.previousIssue);
|
|
}
|
|
pushToast({
|
|
title: "Comment failed",
|
|
body: err instanceof Error ? err.message : "Unable to post comment",
|
|
tone: "error",
|
|
});
|
|
},
|
|
onSettled: (_result, _error, variables) => {
|
|
invalidateIssueThreadLazily();
|
|
if (variables.interrupt) {
|
|
invalidateIssueRunState();
|
|
}
|
|
invalidateIssueCollections();
|
|
},
|
|
});
|
|
|
|
const interruptQueuedComment = useMutation({
|
|
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
|
|
onMutate: async (runId) => {
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) });
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
|
|
|
|
const previousRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueId!));
|
|
const previousLiveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueId!));
|
|
const previousActiveRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueId!));
|
|
const liveRunList = previousLiveRuns ?? [];
|
|
const cachedActiveRun = previousActiveRun ?? null;
|
|
const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList);
|
|
const targetRun =
|
|
cachedActiveRun?.id === runId
|
|
? cachedActiveRun
|
|
: liveRunList?.find((run) => run.id === runId) ?? runningIssueRun ?? null;
|
|
|
|
if (targetRun) {
|
|
const interruptedAt = new Date().toISOString();
|
|
queryClient.setQueryData<RunForIssue[] | undefined>(
|
|
queryKeys.issues.runs(issueId!),
|
|
(current) => upsertInterruptedRun(current, targetRun, interruptedAt),
|
|
);
|
|
}
|
|
|
|
queryClient.setQueryData(
|
|
queryKeys.issues.liveRuns(issueId!),
|
|
(current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId),
|
|
);
|
|
queryClient.setQueryData(
|
|
queryKeys.issues.activeRun(issueId!),
|
|
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
|
|
);
|
|
|
|
return {
|
|
previousRuns,
|
|
previousLiveRuns,
|
|
previousActiveRun,
|
|
};
|
|
},
|
|
onSuccess: () => {
|
|
invalidateIssueDetail();
|
|
invalidateIssueRunState();
|
|
pushToast({
|
|
title: "Interrupt requested",
|
|
body: "The active run is stopping so queued comments can continue next.",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err, _runId, context) => {
|
|
queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns);
|
|
queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns);
|
|
queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun);
|
|
pushToast({
|
|
title: "Interrupt failed",
|
|
body: err instanceof Error ? err.message : "Unable to interrupt the active run",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
const cancelQueuedComment = useMutation({
|
|
mutationFn: async ({ commentId }: { commentId: string }) => issuesApi.cancelComment(issueId!, commentId),
|
|
onSuccess: (comment) => {
|
|
removeCommentFromCache(comment.id);
|
|
restoreQueuedCommentDraft(comment.body);
|
|
invalidateIssueDetail();
|
|
invalidateIssueThreadLazily();
|
|
invalidateIssueCollections();
|
|
pushToast({
|
|
title: "Queued comment canceled",
|
|
body: "The queued message was restored to the composer.",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Cancel failed",
|
|
body: err instanceof Error ? err.message : "Unable to cancel the queued comment",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleCancelQueuedComment = useCallback((commentId: string) => {
|
|
if (commentId.startsWith("optimistic-")) {
|
|
cancelledQueuedOptimisticCommentIdsRef.current.add(commentId);
|
|
let cancelledCommentBody: string | null = null;
|
|
setOptimisticComments((current) => {
|
|
const next = takeOptimisticIssueComment(current, commentId);
|
|
cancelledCommentBody = next.comment?.body ?? null;
|
|
return next.comments;
|
|
});
|
|
if (cancelledCommentBody) {
|
|
restoreQueuedCommentDraft(cancelledCommentBody);
|
|
pushToast({
|
|
title: "Queued comment canceled",
|
|
body: "The queued message was restored to the composer.",
|
|
tone: "success",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
void cancelQueuedComment.mutateAsync({ commentId });
|
|
}, [cancelQueuedComment, restoreQueuedCommentDraft, pushToast]);
|
|
|
|
const feedbackVoteMutation = useMutation({
|
|
mutationFn: (variables: {
|
|
targetType: "issue_comment" | "issue_document_revision";
|
|
targetId: string;
|
|
vote: "up" | "down";
|
|
reason?: string;
|
|
allowSharing?: boolean;
|
|
sharingPreferenceAtSubmit: "allowed" | "not_allowed" | "prompt";
|
|
}) =>
|
|
issuesApi.upsertFeedbackVote(issueId!, {
|
|
targetType: variables.targetType,
|
|
targetId: variables.targetId,
|
|
vote: variables.vote,
|
|
...(variables.reason ? { reason: variables.reason } : {}),
|
|
...(variables.allowSharing ? { allowSharing: true } : {}),
|
|
}),
|
|
onMutate: async (variables) => {
|
|
await queryClient.cancelQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
|
|
const previousVotes = queryClient.getQueryData<FeedbackVote[]>(
|
|
queryKeys.issues.feedbackVotes(issueId!),
|
|
);
|
|
queryClient.setQueryData<FeedbackVote[]>(
|
|
queryKeys.issues.feedbackVotes(issueId!),
|
|
mergeOptimisticFeedbackVote(
|
|
previousVotes,
|
|
{
|
|
issueId: issueId!,
|
|
targetType: variables.targetType,
|
|
targetId: variables.targetId,
|
|
vote: variables.vote,
|
|
reason: variables.reason,
|
|
},
|
|
currentUserId,
|
|
),
|
|
);
|
|
return { previousVotes };
|
|
},
|
|
onSuccess: (_savedVote, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.feedbackVotes(issueId!) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
|
|
pushToast({
|
|
title:
|
|
variables.sharingPreferenceAtSubmit === "prompt"
|
|
? variables.allowSharing
|
|
? "Feedback saved. Future votes will share"
|
|
: "Feedback saved. Future votes will stay local"
|
|
: variables.allowSharing
|
|
? "Feedback saved and sharing enabled"
|
|
: "Feedback saved",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err, _variables, context) => {
|
|
if (context?.previousVotes) {
|
|
queryClient.setQueryData(queryKeys.issues.feedbackVotes(issueId!), context.previousVotes);
|
|
}
|
|
pushToast({
|
|
title: "Failed to save feedback",
|
|
body: err instanceof Error ? err.message : "Unknown error",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
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!) });
|
|
invalidateIssueDetail();
|
|
},
|
|
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);
|
|
invalidateIssueDetail();
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(issueId!) });
|
|
},
|
|
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!) });
|
|
invalidateIssueDetail();
|
|
},
|
|
onError: (err) => {
|
|
setAttachmentError(err instanceof Error ? err.message : "Delete failed");
|
|
},
|
|
});
|
|
|
|
const archiveFromInbox = useMutation({
|
|
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
|
|
onSuccess: () => {
|
|
invalidateIssueCollections();
|
|
navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true });
|
|
pushToast({ title: "Issue archived from inbox", tone: "success" });
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Archive failed",
|
|
body: err instanceof Error ? err.message : "Unable to archive this issue from the inbox",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
setBreadcrumbs([
|
|
sourceBreadcrumb,
|
|
{ label: hasLiveRuns ? `🔵 ${breadcrumbTitle}` : breadcrumbTitle },
|
|
]);
|
|
}, [
|
|
breadcrumbTitle,
|
|
hasLiveRuns,
|
|
setBreadcrumbs,
|
|
sourceBreadcrumb.href,
|
|
sourceBreadcrumb.label,
|
|
]);
|
|
|
|
const isFromInbox = resolvedIssueDetailState?.issueDetailSource === "inbox";
|
|
|
|
// Scroll to top on forward navigation (PUSH/REPLACE) so issue doesn't
|
|
// inherit the inbox/issues-list scroll position on mobile.
|
|
useEffect(() => {
|
|
if (navigationType === "POP") return;
|
|
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
const main = document.getElementById("main-content");
|
|
if (main) main.scrollTop = 0;
|
|
}, [issueId, navigationType]);
|
|
|
|
// Redirect to identifier-based URL if navigated via UUID
|
|
useEffect(() => {
|
|
const nextState = resolvedIssueDetailState ?? location.state;
|
|
if (issue?.identifier && issueId !== issue.identifier) {
|
|
rememberIssueDetailLocationState(issue.identifier, nextState, location.search);
|
|
navigate(createIssueDetailPath(issue.identifier), {
|
|
replace: true,
|
|
state: nextState,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (issueId && hasLegacyIssueDetailQuery(location.search)) {
|
|
rememberIssueDetailLocationState(issueId, nextState, location.search);
|
|
navigate(createIssueDetailPath(issueId), {
|
|
replace: true,
|
|
state: nextState,
|
|
});
|
|
}
|
|
}, [issue, issueId, navigate, location.state, location.search, resolvedIssueDetailState]);
|
|
|
|
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 (!panelIssue) {
|
|
closePanel();
|
|
return;
|
|
}
|
|
openPanel(
|
|
<IssueProperties
|
|
issue={panelIssue}
|
|
childIssues={panelChildIssues}
|
|
onAddSubIssue={openNewSubIssue}
|
|
onUpdate={handleIssuePropertiesUpdate}
|
|
/>
|
|
);
|
|
return () => closePanel();
|
|
}, [
|
|
closePanel,
|
|
handleIssuePropertiesUpdate,
|
|
issuePanelKey,
|
|
openNewSubIssue,
|
|
openPanel,
|
|
panelChildIssues,
|
|
panelIssue,
|
|
]);
|
|
|
|
const goToInboxShortcutArmedRef = useRef(false);
|
|
const goToInboxShortcutTimeoutRef = useRef<number | null>(null);
|
|
const canQuickArchiveFromInbox =
|
|
keyboardShortcutsEnabled &&
|
|
!issue?.hiddenAt;
|
|
|
|
useEffect(() => {
|
|
if (!issue?.id || !canQuickArchiveFromInbox) return;
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const action = resolveInboxQuickArchiveKeyAction({
|
|
armed: canQuickArchiveFromInbox,
|
|
defaultPrevented: event.defaultPrevented,
|
|
key: event.key,
|
|
metaKey: event.metaKey,
|
|
ctrlKey: event.ctrlKey,
|
|
altKey: event.altKey,
|
|
target: event.target,
|
|
hasOpenDialog: hasBlockingShortcutDialog(document),
|
|
});
|
|
|
|
if (action !== "archive") return;
|
|
|
|
event.preventDefault();
|
|
if (!archiveFromInbox.isPending) {
|
|
archiveFromInbox.mutate(issue.id);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown, true);
|
|
return () => {
|
|
document.removeEventListener("keydown", handleKeyDown, true);
|
|
};
|
|
}, [archiveFromInbox, canQuickArchiveFromInbox, issue?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!keyboardShortcutsEnabled) {
|
|
goToInboxShortcutArmedRef.current = false;
|
|
if (goToInboxShortcutTimeoutRef.current !== null) {
|
|
window.clearTimeout(goToInboxShortcutTimeoutRef.current);
|
|
goToInboxShortcutTimeoutRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const clearArmTimeout = () => {
|
|
if (goToInboxShortcutTimeoutRef.current !== null) {
|
|
window.clearTimeout(goToInboxShortcutTimeoutRef.current);
|
|
goToInboxShortcutTimeoutRef.current = null;
|
|
}
|
|
};
|
|
|
|
const disarm = () => {
|
|
goToInboxShortcutArmedRef.current = false;
|
|
clearArmTimeout();
|
|
};
|
|
|
|
const arm = () => {
|
|
goToInboxShortcutArmedRef.current = true;
|
|
clearArmTimeout();
|
|
goToInboxShortcutTimeoutRef.current = window.setTimeout(() => {
|
|
goToInboxShortcutArmedRef.current = false;
|
|
goToInboxShortcutTimeoutRef.current = null;
|
|
}, 1200);
|
|
};
|
|
|
|
const handlePointerDown = () => {
|
|
disarm();
|
|
};
|
|
|
|
const handleFocusIn = (event: FocusEvent) => {
|
|
if (event.target instanceof HTMLElement && event.target !== document.body) {
|
|
disarm();
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const action = resolveIssueDetailGoKeyAction({
|
|
armed: goToInboxShortcutArmedRef.current,
|
|
defaultPrevented: event.defaultPrevented,
|
|
key: event.key,
|
|
metaKey: event.metaKey,
|
|
ctrlKey: event.ctrlKey,
|
|
altKey: event.altKey,
|
|
target: event.target,
|
|
hasOpenDialog: hasBlockingShortcutDialog(document),
|
|
});
|
|
|
|
if (action === "ignore") return;
|
|
if (action === "arm") {
|
|
arm();
|
|
return;
|
|
}
|
|
|
|
disarm();
|
|
if (action === "navigate_inbox") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox");
|
|
return;
|
|
}
|
|
if (action === "focus_comment") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setDetailTab("chat");
|
|
setPendingCommentComposerFocusKey((current) => current + 1);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("pointerdown", handlePointerDown, true);
|
|
document.addEventListener("focusin", handleFocusIn, true);
|
|
document.addEventListener("keydown", handleKeyDown, true);
|
|
return () => {
|
|
disarm();
|
|
document.removeEventListener("pointerdown", handlePointerDown, true);
|
|
document.removeEventListener("focusin", handleFocusIn, true);
|
|
document.removeEventListener("keydown", handleKeyDown, true);
|
|
};
|
|
}, [keyboardShortcutsEnabled, navigate, sourceBreadcrumb.href]);
|
|
|
|
useEffect(() => {
|
|
if (pendingCommentComposerFocusKey === 0) return;
|
|
if (detailTab !== "chat") return;
|
|
commentComposerRef.current?.focus();
|
|
}, [detailTab, pendingCommentComposerFocusKey]);
|
|
|
|
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
|
|
const attachmentList = attachments ?? [];
|
|
const imageAttachments = attachmentList.filter(isImageAttachment);
|
|
const nonImageAttachments = attachmentList.filter((a) => !isImageAttachment(a));
|
|
|
|
const handleChatImageClick = useCallback(
|
|
(src: string) => {
|
|
// Try exact contentPath match first
|
|
let idx = imageAttachments.findIndex((a) => a.contentPath === src);
|
|
if (idx < 0) {
|
|
// Try matching by asset ID extracted from /api/assets/{assetId}/content URLs
|
|
const assetMatch = src.match(/\/api\/assets\/([^/]+)\/content/);
|
|
if (assetMatch) {
|
|
idx = imageAttachments.findIndex((a) => a.assetId === assetMatch[1]);
|
|
}
|
|
}
|
|
if (idx >= 0) {
|
|
setGalleryIndex(idx);
|
|
setGalleryOpen(true);
|
|
} else {
|
|
// Image not in attachment list — open in new tab
|
|
window.open(src, "_blank");
|
|
}
|
|
},
|
|
[imageAttachments],
|
|
);
|
|
|
|
const copyIssueToClipboard = async () => {
|
|
if (!issue) return;
|
|
const decodeEntities = (text: string) => {
|
|
const el = document.createElement("textarea");
|
|
el.innerHTML = text;
|
|
return el.value;
|
|
};
|
|
const title = decodeEntities(issue.title);
|
|
const body = decodeEntities(issue.description ?? "");
|
|
const md = `# ${issue.identifier}: ${title}\n\n${body}`.trimEnd();
|
|
await navigator.clipboard.writeText(md);
|
|
setCopied(true);
|
|
pushToast({ title: "Copied to clipboard", tone: "success" });
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
// Gmail-style mobile toolbar when viewing an issue from inbox.
|
|
// Callbacks are stored in a ref so the effect deps stay stable and
|
|
// don't trigger an infinite render loop (useMutation results and
|
|
// non-memoized functions change identity every render).
|
|
const inboxToolbarCallbacksRef = useRef({
|
|
onArchive: () => {
|
|
if (!archiveFromInbox.isPending && issue?.id) archiveFromInbox.mutate(issue.id);
|
|
},
|
|
onCopy: () => copyIssueToClipboard(),
|
|
onProperties: () => setMobilePropsOpen(true),
|
|
onHide: () => {
|
|
updateIssue.mutate(
|
|
{ hiddenAt: new Date().toISOString() },
|
|
{ onSuccess: () => navigate("/issues/all") },
|
|
);
|
|
},
|
|
});
|
|
inboxToolbarCallbacksRef.current = {
|
|
onArchive: () => {
|
|
if (!archiveFromInbox.isPending && issue?.id) archiveFromInbox.mutate(issue.id);
|
|
},
|
|
onCopy: () => copyIssueToClipboard(),
|
|
onProperties: () => setMobilePropsOpen(true),
|
|
onHide: () => {
|
|
updateIssue.mutate(
|
|
{ hiddenAt: new Date().toISOString() },
|
|
{ onSuccess: () => navigate("/issues/all") },
|
|
);
|
|
},
|
|
};
|
|
|
|
const backHref = sourceBreadcrumb.href ?? "/inbox";
|
|
const showInboxToolbar = isMobile && isFromInbox;
|
|
const archivePending = archiveFromInbox.isPending;
|
|
const issueHidden = !!issue?.hiddenAt;
|
|
|
|
useEffect(() => {
|
|
if (!showInboxToolbar) {
|
|
setMobileToolbar(null);
|
|
return;
|
|
}
|
|
|
|
setMobileToolbar(
|
|
<InboxMobileToolbar
|
|
backHref={backHref}
|
|
issueId={issue?.id}
|
|
issueHidden={issueHidden}
|
|
archivePending={archivePending}
|
|
onArchive={() => inboxToolbarCallbacksRef.current.onArchive()}
|
|
onCopy={() => inboxToolbarCallbacksRef.current.onCopy()}
|
|
onProperties={() => inboxToolbarCallbacksRef.current.onProperties()}
|
|
onHide={() => inboxToolbarCallbacksRef.current.onHide()}
|
|
/>,
|
|
);
|
|
|
|
return () => setMobileToolbar(null);
|
|
}, [showInboxToolbar, backHref, issue?.id, issueHidden, archivePending, setMobileToolbar]);
|
|
|
|
const attachmentsInitialLoading = attachmentsLoading && attachments === undefined;
|
|
const loadOlderComments = useCallback(() => {
|
|
void fetchOlderComments();
|
|
}, [fetchOlderComments]);
|
|
const handleCommentVote = useCallback(async (commentId: string, vote: "up" | "down", options?: { allowSharing?: boolean; reason?: string }) => {
|
|
await feedbackVoteMutation.mutateAsync({
|
|
targetType: "issue_comment",
|
|
targetId: commentId,
|
|
vote,
|
|
reason: options?.reason,
|
|
allowSharing: options?.allowSharing,
|
|
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
|
});
|
|
}, [feedbackDataSharingPreference, feedbackVoteMutation]);
|
|
const handleChatAdd = useCallback(async (body: string, reopen?: boolean, reassignment?: CommentReassignment) => {
|
|
if (reassignment) {
|
|
await addCommentAndReassign.mutateAsync({ body, reopen, reassignment });
|
|
return;
|
|
}
|
|
await addComment.mutateAsync({ body, reopen });
|
|
}, [addComment, addCommentAndReassign]);
|
|
const handleCommentImageUpload = useCallback(async (file: File) => {
|
|
const attachment = await uploadAttachment.mutateAsync(file);
|
|
return attachment.contentPath;
|
|
}, [uploadAttachment]);
|
|
const handleCommentAttachImage = useCallback(async (file: File) => {
|
|
await uploadAttachment.mutateAsync(file);
|
|
}, [uploadAttachment]);
|
|
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
|
|
await interruptQueuedComment.mutateAsync(runId);
|
|
}, [interruptQueuedComment]);
|
|
|
|
if (isLoading) return <IssueDetailLoadingState headerSeed={issueHeaderSeed} />;
|
|
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 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 hasAttachments = attachmentList.length > 0;
|
|
const attachmentUploadButton = (
|
|
<>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
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..." : (
|
|
<>
|
|
<span className="hidden sm:inline">Upload attachment</span>
|
|
<span className="sm:hidden">Upload</span>
|
|
</>
|
|
)}
|
|
</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={createIssueDetailPath(ancestor.identifier ?? ancestor.id)}
|
|
state={resolvedIssueDetailState ?? location.state}
|
|
onClickCapture={() =>
|
|
rememberIssueDetailLocationState(
|
|
ancestor.identifier ?? ancestor.id,
|
|
resolvedIssueDetailState ?? location.state,
|
|
location.search,
|
|
)}
|
|
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.originKind === "routine_execution" && issue.originId && (
|
|
<Link
|
|
to={`/routines/${issue.originId}`}
|
|
className="inline-flex items-center gap-1 rounded-full bg-violet-500/10 border border-violet-500/30 px-2 py-0.5 text-[10px] font-medium text-violet-600 dark:text-violet-400 shrink-0 hover:bg-violet-500/20 transition-colors"
|
|
>
|
|
<Repeat className="h-3 w-3" />
|
|
Routine
|
|
</Link>
|
|
)}
|
|
|
|
{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">{resolvedProject?.name ?? issue.project?.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: pickTextColorForPillBg(label.color, 0.12),
|
|
backgroundColor: `${label.color}1f`,
|
|
}}
|
|
>
|
|
{label.name}
|
|
</span>
|
|
))}
|
|
{(issue.labels ?? []).length > 4 && (
|
|
<span className="text-[10px] text-muted-foreground">+{(issue.labels ?? []).length - 4}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!(isMobile && isFromInbox) && (
|
|
<div className="ml-auto flex items-center gap-0.5 md:hidden shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={copyIssueToClipboard}
|
|
title="Copy issue as markdown"
|
|
>
|
|
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => setMobilePropsOpen(true)}
|
|
title="Properties"
|
|
>
|
|
<SlidersHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="hidden md:flex items-center md:ml-auto shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={copyIssueToClipboard}
|
|
title="Copy issue as markdown"
|
|
>
|
|
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
|
</Button>
|
|
<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;
|
|
}}
|
|
onDropFile={async (file) => {
|
|
await uploadAttachment.mutateAsync(file);
|
|
}}
|
|
/>
|
|
</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"
|
|
/>
|
|
|
|
{showRichSubIssuesSection ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h3 className="text-sm font-medium text-muted-foreground">Sub-issues</h3>
|
|
</div>
|
|
<IssuesList
|
|
issues={childIssues}
|
|
isLoading={childIssuesLoading}
|
|
agents={agents}
|
|
projects={projects}
|
|
projectId={issue.projectId ?? undefined}
|
|
viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`}
|
|
issueLinkState={resolvedIssueDetailState ?? location.state}
|
|
searchFilters={{ parentId: issue.id }}
|
|
baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)}
|
|
createIssueLabel="Sub-issue"
|
|
onUpdateIssue={handleChildIssueUpdate}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap items-center justify-end gap-2 min-w-0">
|
|
<Button variant="outline" size="sm" onClick={openNewSubIssue} className="shrink-0 shadow-none">
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
New Sub-issue
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<IssueDocumentsSection
|
|
issue={issue}
|
|
canDeleteDocuments={Boolean(session?.user?.id)}
|
|
feedbackVotes={feedbackVotes}
|
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
|
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
|
mentions={mentionOptions}
|
|
imageUploadHandler={async (file) => {
|
|
const attachment = await uploadAttachment.mutateAsync(file);
|
|
return attachment.contentPath;
|
|
}}
|
|
onVote={async (revisionId, vote, options) => {
|
|
await feedbackVoteMutation.mutateAsync({
|
|
targetType: "issue_document_revision",
|
|
targetId: revisionId,
|
|
vote,
|
|
reason: options?.reason,
|
|
allowSharing: options?.allowSharing,
|
|
sharingPreferenceAtSubmit: feedbackDataSharingPreference,
|
|
});
|
|
}}
|
|
extraActions={!hasAttachments ? attachmentUploadButton : null}
|
|
/>
|
|
|
|
{attachmentsInitialLoading ? (
|
|
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
|
|
) : 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>
|
|
)}
|
|
|
|
{imageAttachments.length > 0 && (
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{imageAttachments.map((attachment) => (
|
|
<div
|
|
key={attachment.id}
|
|
className="group relative aspect-square rounded-lg overflow-hidden border border-border bg-accent/10 cursor-pointer"
|
|
onClick={() => {
|
|
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
|
|
setGalleryIndex(idx >= 0 ? idx : 0);
|
|
setGalleryOpen(true);
|
|
}}
|
|
>
|
|
<img
|
|
src={attachment.contentPath}
|
|
alt={attachment.originalFilename ?? "attachment"}
|
|
className="h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors" />
|
|
{confirmDeleteId === attachment.id ? (
|
|
<div
|
|
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<p className="text-xs text-white font-medium">Delete?</p>
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
type="button"
|
|
className="rounded bg-destructive px-2 py-0.5 text-xs text-white hover:bg-destructive/80"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
deleteAttachment.mutate(attachment.id);
|
|
setConfirmDeleteId(null);
|
|
}}
|
|
disabled={deleteAttachment.isPending}
|
|
>
|
|
Yes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded bg-muted px-2 py-0.5 text-xs hover:bg-muted/80"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setConfirmDeleteId(null);
|
|
}}
|
|
>
|
|
No
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="absolute top-1.5 right-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setConfirmDeleteId(attachment.id);
|
|
}}
|
|
title="Delete attachment"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{nonImageAttachments.length > 0 && (
|
|
<div className="space-y-2">
|
|
{nonImageAttachments.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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
<ImageGalleryModal
|
|
images={imageAttachments}
|
|
initialIndex={galleryIndex}
|
|
open={galleryOpen}
|
|
onOpenChange={setGalleryOpen}
|
|
/>
|
|
|
|
<IssueWorkspaceCard
|
|
issue={issue}
|
|
project={resolvedProject}
|
|
onUpdate={(data) => updateIssue.mutate(data)}
|
|
/>
|
|
|
|
<Separator />
|
|
|
|
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
|
|
<TabsList variant="line" className="w-full justify-start gap-1">
|
|
<TabsTrigger value="chat" className="gap-1.5">
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
Chat
|
|
</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="chat">
|
|
{detailTab === "chat" ? (
|
|
<IssueDetailChatTab
|
|
issueId={issue.id}
|
|
companyId={issue.companyId}
|
|
projectId={issue.projectId ?? null}
|
|
issueStatus={issue.status}
|
|
executionRunId={issue.executionRunId ?? null}
|
|
comments={threadComments}
|
|
hasOlderComments={hasOlderComments}
|
|
commentsLoadingOlder={commentsLoadingOlder}
|
|
onLoadOlderComments={loadOlderComments}
|
|
composerRef={commentComposerRef}
|
|
feedbackVotes={feedbackVotes}
|
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
|
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
|
reassignOptions={commentReassignOptions}
|
|
currentAssigneeValue={actualAssigneeValue}
|
|
suggestedAssigneeValue={suggestedAssigneeValue}
|
|
mentions={mentionOptions}
|
|
composerDisabledReason={commentComposerDisabledReason}
|
|
onVote={handleCommentVote}
|
|
onAdd={handleChatAdd}
|
|
onImageUpload={handleCommentImageUpload}
|
|
onAttachImage={handleCommentAttachImage}
|
|
onInterruptQueued={handleInterruptQueuedRun}
|
|
onCancelQueued={handleCancelQueuedComment}
|
|
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
|
onImageClick={handleChatImageClick}
|
|
/>
|
|
) : null}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="activity">
|
|
{detailTab === "activity" ? (
|
|
<IssueDetailActivityTab
|
|
issueId={issue.id}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
pendingApprovalAction={pendingApprovalAction}
|
|
onApprovalAction={(approvalId, action) => {
|
|
approvalDecision.mutate({ approvalId, action });
|
|
}}
|
|
/>
|
|
) : null}
|
|
</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>
|
|
|
|
{/* 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}
|
|
childIssues={childIssues}
|
|
onAddSubIssue={openNewSubIssue}
|
|
onUpdate={(data) => updateIssue.mutate(data)}
|
|
inline
|
|
/>
|
|
</div>
|
|
</ScrollArea>
|
|
</SheetContent>
|
|
</Sheet>
|
|
<ScrollToBottom />
|
|
</div>
|
|
);
|
|
}
|