mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## Thinking Path > - Paperclip orchestrates AI-agent companies through company-scoped issues, comments, and issue documents. > - Issue documents are the durable place where plans, handoffs, and other work artifacts are revised over time. > - Some documents need to be preserved as operator-approved snapshots while agents continue working on the same issue. > - Without document locking, a later board or agent write can overwrite the document key that reviewers expected to remain stable. > - This pull request adds board-managed issue document locks and makes agent writes to locked keys create a derived document instead of mutating the locked document. > - The benefit is safer document handoffs: approved or frozen issue documents stay immutable until the board explicitly unlocks them. ## What Changed - Added `locked_at`, `locked_by_agent_id`, and `locked_by_user_id` document fields plus migration `0085_tranquil_the_executioner.sql`. - Added document lock/unlock service behavior, route endpoints, activity events, and locked-document write protections. - Made agent document writes to locked keys create a new derived key such as `plan-2` rather than overwriting the locked document. - Surfaced lock state through shared issue document types, UI API methods, document header lock controls, and activity formatting. - Added server and UI tests for lock/unlock behavior, locked document immutability, and UI action visibility. - Updated `doc/SPEC-implementation.md` with the V1 document lock contract and endpoints. ## Verification - `git rebase public-gh/master` completed cleanly after committing the branch changes. - `git diff --check` passed before commit. - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/documents-service.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts ui/src/components/IssueDocumentsSection.test.tsx ui/src/components/IssueContinuationHandoff.test.tsx ui/src/lib/document-revisions.test.ts` passed: 5 files, 32 tests. ## Risks - Medium risk because this changes the document persistence contract and adds a migration. - The migration uses `ADD COLUMN IF NOT EXISTS` and guarded foreign-key creation so it remains safe for users who may have already applied an earlier copy of the migration. - Locked documents intentionally reject board edits/deletes/restores until unlocked; any existing workflows that expected direct overwrite need to unlock first. - Agent writes to locked keys now create derived documents, which may create extra issue documents when agents retry locked writes. ## Model Used - OpenAI Codex coding agent based on GPT-5, with tool use and local code execution in the Paperclip worktree. ## 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 checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [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>
4195 lines
163 KiB
TypeScript
4195 lines
163 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode, 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 { ApiError } from "../api/client";
|
|
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 { accessApi, type CurrentBoardAccess } from "../api/access";
|
|
import { agentsApi } from "../api/agents";
|
|
import { authApi } from "../api/auth";
|
|
import { projectsApi } from "../api/projects";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useDialogActions } 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 { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members";
|
|
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
|
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
|
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,
|
|
applyLocalQueuedIssueCommentState,
|
|
createOptimisticIssueComment,
|
|
flattenIssueCommentPages,
|
|
getNextIssueCommentPageParam,
|
|
isQueuedIssueComment,
|
|
loadRemainingIssueCommentPages,
|
|
matchesIssueRef,
|
|
mergeIssueComments,
|
|
removeIssueCommentFromPages,
|
|
shouldAutoloadOlderIssueComments,
|
|
takeOptimisticIssueComment,
|
|
upsertIssueCommentInPages,
|
|
type IssueCommentReassignment,
|
|
type OptimisticIssueComment,
|
|
} from "../lib/optimistic-issue-comments";
|
|
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
|
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
|
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
|
import { ApprovalCard } from "../components/ApprovalCard";
|
|
import { InlineEditor } from "../components/InlineEditor";
|
|
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
|
|
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
|
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
|
import { IssueSiblingNavigation } from "../components/IssueSiblingNavigation";
|
|
import { IssuesList } from "../components/IssuesList";
|
|
import { AgentIcon } from "../components/AgentIconPicker";
|
|
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
|
|
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
|
|
import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard";
|
|
import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
|
|
import { IssueProperties } from "../components/IssueProperties";
|
|
import { IssueRunLedger } from "../components/IssueRunLedger";
|
|
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 { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
|
|
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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { formatIssueActivityAction } from "@/lib/activity-format";
|
|
import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key";
|
|
import { buildIssueSiblingNavigation, shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
|
|
import { filterIssueDescendants } from "../lib/issue-tree";
|
|
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
|
|
import {
|
|
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
|
|
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
|
|
successfulRunHandoffActivityTone,
|
|
} from "../lib/successful-run-handoff";
|
|
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
|
|
import {
|
|
Activity as ActivityIcon,
|
|
AlertTriangle,
|
|
Archive,
|
|
ArrowLeft,
|
|
Check,
|
|
ChevronRight,
|
|
Copy,
|
|
Eye,
|
|
EyeOff,
|
|
Flag,
|
|
Hexagon,
|
|
ListTree,
|
|
MessageSquare,
|
|
MoreHorizontal,
|
|
MoreVertical,
|
|
PauseCircle,
|
|
Paperclip,
|
|
PlayCircle,
|
|
Plus,
|
|
Repeat,
|
|
SlidersHorizontal,
|
|
Trash2,
|
|
XCircle,
|
|
} from "lucide-react";
|
|
import {
|
|
getClosedIsolatedExecutionWorkspaceMessage,
|
|
isClosedIsolatedExecutionWorkspace,
|
|
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
|
type AskUserQuestionsAnswer,
|
|
type AskUserQuestionsInteraction,
|
|
type ActivityEvent,
|
|
type Agent,
|
|
type FeedbackVote,
|
|
type Issue,
|
|
type IssueAttachment,
|
|
type IssueComment,
|
|
type IssueWorkMode,
|
|
type IssueThreadInteraction,
|
|
type RequestConfirmationInteraction,
|
|
type SuggestTasksInteraction,
|
|
type IssueTreeControlMode,
|
|
} from "@paperclipai/shared";
|
|
|
|
type CommentReassignment = IssueCommentReassignment;
|
|
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
|
|
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
|
|
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
|
runId?: string | null;
|
|
runAgentId?: string | null;
|
|
interruptedRunId?: string | null;
|
|
queueState?: "queued";
|
|
queueTargetRunId?: string | null;
|
|
queueReason?: "hold" | "active_run" | "other";
|
|
};
|
|
|
|
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
|
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
|
const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3;
|
|
const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10;
|
|
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
|
pause: "Pause subtree",
|
|
resume: "Resume subtree",
|
|
cancel: "Cancel subtree",
|
|
restore: "Restore subtree",
|
|
};
|
|
const LEAF_WORK_CONTROL_MODE_LABEL: Partial<Record<IssueTreeControlMode, string>> = {
|
|
pause: "Pause work",
|
|
resume: "Resume work",
|
|
};
|
|
const TREE_CONTROL_MODE_HELP_TEXT: Record<IssueTreeControlMode, string> = {
|
|
pause: "Pause active execution in this issue subtree until an explicit resume.",
|
|
resume: "Release the active subtree pause hold so held work can continue.",
|
|
cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.",
|
|
restore: "Restore issues cancelled by this subtree operation so work can resume.",
|
|
};
|
|
const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial<Record<IssueTreeControlMode, string>> = {
|
|
pause: "Pause active execution on this issue until an explicit resume.",
|
|
resume: "Release the active pause hold so this issue can continue.",
|
|
};
|
|
function issueTreeControlLabel(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
|
|
return scope === "leaf"
|
|
? LEAF_WORK_CONTROL_MODE_LABEL[mode] ?? TREE_CONTROL_MODE_LABEL[mode]
|
|
: TREE_CONTROL_MODE_LABEL[mode];
|
|
}
|
|
|
|
function issueTreeControlHelpText(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
|
|
return scope === "leaf"
|
|
? LEAF_WORK_CONTROL_MODE_HELP_TEXT[mode] ?? TREE_CONTROL_MODE_HELP_TEXT[mode]
|
|
: TREE_CONTROL_MODE_HELP_TEXT[mode];
|
|
}
|
|
|
|
function treeControlPreviewErrorCopy(error: unknown): string {
|
|
if (error instanceof ApiError) {
|
|
if (error.status === 403) return "Only board users can preview subtree controls.";
|
|
if (error.status === 409) return "Preview is stale because subtree hold state changed. Retry to refresh.";
|
|
if (error.status === 422) return "This subtree action is currently invalid for the selected issues.";
|
|
}
|
|
return error instanceof Error ? error.message : "Unable to load preview.";
|
|
}
|
|
|
|
export function canBoardResolveRecoveryAction(
|
|
companyId: string | null | undefined,
|
|
boardAccess: CurrentBoardAccess | undefined,
|
|
) {
|
|
if (!companyId || !boardAccess) return false;
|
|
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
|
|
if (!boardAccess.memberships || boardAccess.memberships.length === 0) {
|
|
return boardAccess.companyIds.includes(companyId);
|
|
}
|
|
|
|
const membership = boardAccess.memberships.find(
|
|
(item) => item.companyId === companyId && item.status === "active",
|
|
);
|
|
if (!membership) return false;
|
|
return membership.membershipRole !== "viewer" && membership.membershipRole !== null;
|
|
}
|
|
|
|
function resolveRunningIssueRun(
|
|
activeRun: ActiveRunForIssue | null | undefined,
|
|
liveRuns: readonly LiveRunForIssue[] | undefined,
|
|
) {
|
|
return activeRun?.status === "running"
|
|
? activeRun
|
|
: (liveRuns ?? []).find((run) => run.status === "running") ?? null;
|
|
}
|
|
|
|
function dedupeLiveRunsById(liveRuns: readonly LiveRunForIssue[]) {
|
|
const seen = new Set<string>();
|
|
return liveRuns.filter((run) => {
|
|
if (seen.has(run.id)) return false;
|
|
seen.add(run.id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
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, userProfileMap }: { evt: ActivityEvent; agentMap: Map<string, Agent>; userProfileMap?: Map<string, import("../lib/company-members").CompanyUserProfile> }) {
|
|
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") {
|
|
const profile = userProfileMap?.get(id);
|
|
return <Identity name={profile?.label ?? "Board"} avatarUrl={profile?.image} 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-3xl 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} blockerAttention={headerSeed.blockerAttention} />
|
|
<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"];
|
|
issueWorkMode: IssueWorkMode;
|
|
executionRunId: string | null;
|
|
blockedBy: Issue["blockedBy"];
|
|
blockerAttention: Issue["blockerAttention"] | null;
|
|
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
|
|
recoveryAction: Issue["activeRecoveryAction"];
|
|
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
|
|
canFalsePositiveRecoveryAction?: boolean;
|
|
legacyRecoverySourceIssue?: {
|
|
identifier: string | null;
|
|
href: string;
|
|
title?: string | null;
|
|
} | null;
|
|
comments: IssueDetailComment[];
|
|
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
|
|
interactions: IssueThreadInteraction[];
|
|
hasOlderComments: boolean;
|
|
commentsLoadingOlder: boolean;
|
|
onLoadOlderComments: () => void;
|
|
onRefreshLatestComments: () => Promise<unknown> | void;
|
|
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
|
|
composerRef: Ref<IssueChatComposerHandle>;
|
|
footer?: ReactNode;
|
|
feedbackVotes?: FeedbackVote[];
|
|
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
|
feedbackTermsUrl: string | null;
|
|
agentMap: Map<string, Agent>;
|
|
currentUserId: string | null;
|
|
userLabelMap: ReadonlyMap<string, string> | null;
|
|
userProfileMap: ReadonlyMap<string, import("../lib/company-members").CompanyUserProfile> | null;
|
|
draftKey: string;
|
|
reassignOptions: Array<{ id: string; label: string; searchText?: string }>;
|
|
currentAssigneeValue: string;
|
|
suggestedAssigneeValue: string;
|
|
mentions: MentionOption[];
|
|
composerDisabledReason: string | null;
|
|
composerHint: string | null;
|
|
queuedCommentReason: "hold" | "active_run" | "other";
|
|
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<IssueAttachment | void>;
|
|
onInterruptQueued: (runId: string) => Promise<void>;
|
|
onPauseWorkRun?: (runId: string) => Promise<void>;
|
|
onCancelQueued: (commentId: string) => void;
|
|
interruptingQueuedRunId: string | null;
|
|
pausingWorkRunId: string | null;
|
|
onImageClick: (src: string) => void;
|
|
onAcceptInteraction: (
|
|
interaction: ActionableIssueThreadInteraction,
|
|
selectedClientKeys?: string[],
|
|
) => Promise<void>;
|
|
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
|
|
onSubmitInteractionAnswers: (
|
|
interaction: IssueThreadInteraction,
|
|
answers: AskUserQuestionsAnswer[],
|
|
) => Promise<void>;
|
|
onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise<void>;
|
|
assigneeUserId: string | null;
|
|
onResumeFromBacklog?: () => Promise<void> | void;
|
|
resumeFromBacklogPending?: boolean;
|
|
};
|
|
|
|
const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|
issueId,
|
|
companyId,
|
|
projectId,
|
|
issueWorkMode,
|
|
issueStatus,
|
|
executionRunId,
|
|
blockedBy,
|
|
blockerAttention,
|
|
successfulRunHandoff,
|
|
recoveryAction,
|
|
onResolveRecoveryAction,
|
|
canFalsePositiveRecoveryAction,
|
|
legacyRecoverySourceIssue,
|
|
comments,
|
|
locallyQueuedCommentRunIds,
|
|
interactions,
|
|
hasOlderComments,
|
|
commentsLoadingOlder,
|
|
onLoadOlderComments,
|
|
onRefreshLatestComments,
|
|
onWorkModeChange,
|
|
composerRef,
|
|
footer,
|
|
feedbackVotes,
|
|
feedbackDataSharingPreference,
|
|
feedbackTermsUrl,
|
|
agentMap,
|
|
currentUserId,
|
|
userLabelMap,
|
|
userProfileMap,
|
|
draftKey,
|
|
reassignOptions,
|
|
currentAssigneeValue,
|
|
suggestedAssigneeValue,
|
|
mentions,
|
|
composerDisabledReason,
|
|
composerHint,
|
|
queuedCommentReason,
|
|
onVote,
|
|
onAdd,
|
|
onImageUpload,
|
|
onAttachImage,
|
|
onInterruptQueued,
|
|
onPauseWorkRun,
|
|
onCancelQueued,
|
|
interruptingQueuedRunId,
|
|
pausingWorkRunId,
|
|
onImageClick,
|
|
onAcceptInteraction,
|
|
onRejectInteraction,
|
|
onSubmitInteractionAnswers,
|
|
onCancelInteraction,
|
|
assigneeUserId,
|
|
onResumeFromBacklog,
|
|
resumeFromBacklogPending,
|
|
}: 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 liveRunIds = useMemo(() => {
|
|
const ids = new Set<string>();
|
|
for (const run of resolvedLiveRuns) ids.add(run.id);
|
|
if (resolvedActiveRun) ids.add(resolvedActiveRun.id);
|
|
return ids;
|
|
}, [resolvedActiveRun, resolvedLiveRuns]);
|
|
const timelineRuns = useMemo(() => {
|
|
const historicalRuns = liveRunIds.size === 0
|
|
? resolvedLinkedRuns
|
|
: resolvedLinkedRuns.filter((run) => !liveRunIds.has(run.runId));
|
|
return historicalRuns.map((run) => ({
|
|
...run,
|
|
adapterType: run.adapterType,
|
|
hasStoredOutput: (run.logBytes ?? 0) > 0,
|
|
}));
|
|
}, [liveRunIds, resolvedLinkedRuns]);
|
|
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 followUpCommentIds = new Set<string>();
|
|
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,
|
|
});
|
|
}
|
|
for (const evt of resolvedActivity) {
|
|
if (evt.action !== "issue.comment_added") continue;
|
|
const details = evt.details ?? {};
|
|
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
|
|
if (!commentId) continue;
|
|
if (details["followUpRequested"] === true || details["resumeIntent"] === true) {
|
|
followUpCommentIds.add(commentId);
|
|
}
|
|
}
|
|
|
|
return comments.map((comment) => {
|
|
const meta = runMetaByCommentId.get(comment.id);
|
|
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
|
|
if (followUpCommentIds.has(comment.id)) {
|
|
nextComment.followUpRequested = true;
|
|
}
|
|
const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null;
|
|
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
|
|
queuedTargetRunId,
|
|
targetRunIsLive: queuedTargetRunId ? liveRunIds.has(queuedTargetRunId) : false,
|
|
runningRunId: runningIssueRun?.id ?? null,
|
|
});
|
|
if (locallyQueuedComment !== nextComment) {
|
|
return locallyQueuedComment;
|
|
}
|
|
if (
|
|
isQueuedIssueComment({
|
|
comment: nextComment,
|
|
activeRunStartedAt,
|
|
activeRunAgentId: runningIssueRun?.agentId ?? null,
|
|
activeRunCommentId: runningIssueRun?.contextCommentId ?? null,
|
|
activeRunWakeCommentId: runningIssueRun?.contextWakeCommentId ?? 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,
|
|
queueReason: queuedCommentReason,
|
|
};
|
|
}
|
|
return nextComment;
|
|
});
|
|
}, [
|
|
comments,
|
|
liveRunIds,
|
|
locallyQueuedCommentRunIds,
|
|
queuedCommentReason,
|
|
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}
|
|
interactions={interactions}
|
|
feedbackVotes={feedbackVotes}
|
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
|
feedbackTermsUrl={feedbackTermsUrl}
|
|
linkedRuns={timelineRuns}
|
|
timelineEvents={timelineEvents}
|
|
liveRuns={resolvedLiveRuns}
|
|
activeRun={resolvedActiveRun}
|
|
blockedBy={blockedBy ?? []}
|
|
blockerAttention={blockerAttention}
|
|
successfulRunHandoff={successfulRunHandoff}
|
|
recoveryAction={recoveryAction ?? null}
|
|
onResolveRecoveryAction={onResolveRecoveryAction}
|
|
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
|
|
legacyRecoverySourceIssue={legacyRecoverySourceIssue ?? null}
|
|
companyId={companyId}
|
|
projectId={projectId}
|
|
issueStatus={issueStatus}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
userLabelMap={userLabelMap}
|
|
userProfileMap={userProfileMap}
|
|
draftKey={draftKey}
|
|
enableReassign
|
|
reassignOptions={reassignOptions}
|
|
currentAssigneeValue={currentAssigneeValue}
|
|
suggestedAssigneeValue={suggestedAssigneeValue}
|
|
mentions={mentions}
|
|
composerDisabledReason={composerDisabledReason}
|
|
composerHint={composerHint}
|
|
onVote={onVote}
|
|
onAdd={onAdd}
|
|
imageUploadHandler={onImageUpload}
|
|
onAttachImage={onAttachImage}
|
|
onInterruptQueued={onInterruptQueued}
|
|
onCancelQueued={onCancelQueued}
|
|
interruptingQueuedRunId={interruptingQueuedRunId}
|
|
stoppingRunId={pausingWorkRunId}
|
|
onStopRun={onPauseWorkRun}
|
|
stopRunLabel="Pause work"
|
|
stoppingRunLabel="Pausing..."
|
|
stopRunVariant="pause"
|
|
onAcceptInteraction={onAcceptInteraction}
|
|
onRejectInteraction={onRejectInteraction}
|
|
onSubmitInteractionAnswers={(interaction, answers) =>
|
|
onSubmitInteractionAnswers(interaction, answers)
|
|
}
|
|
onCancelInteraction={onCancelInteraction}
|
|
issueWorkMode={issueWorkMode}
|
|
onWorkModeChange={onWorkModeChange}
|
|
onCancelRun={runningIssueRun && onPauseWorkRun
|
|
? async () => {
|
|
await onPauseWorkRun(runningIssueRun.id);
|
|
}
|
|
: undefined}
|
|
onImageClick={onImageClick}
|
|
onRefreshLatestComments={onRefreshLatestComments}
|
|
assigneeUserId={assigneeUserId}
|
|
onResumeFromBacklog={onResumeFromBacklog}
|
|
resumeFromBacklogPending={resumeFromBacklogPending}
|
|
footer={footer}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
type IssueDetailActivityTabProps = {
|
|
issue: Issue;
|
|
issueId: string;
|
|
companyId: string;
|
|
issueStatus: Issue["status"];
|
|
childIssues: Issue[];
|
|
agentMap: Map<string, Agent>;
|
|
hasLiveRuns: boolean;
|
|
currentUserId: string | null;
|
|
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
|
|
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
|
|
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
|
|
onCheckMonitorNow: () => void;
|
|
checkingMonitorNow: boolean;
|
|
handoffFocusSignal?: number;
|
|
};
|
|
|
|
function IssueDetailActivityTab({
|
|
issue,
|
|
issueId,
|
|
companyId,
|
|
issueStatus,
|
|
childIssues,
|
|
agentMap,
|
|
hasLiveRuns,
|
|
currentUserId,
|
|
userProfileMap,
|
|
pendingApprovalAction,
|
|
onApprovalAction,
|
|
onCheckMonitorNow,
|
|
checkingMonitorNow,
|
|
handoffFocusSignal = 0,
|
|
}: 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 { data: continuationHandoff } = useQuery({
|
|
queryKey: queryKeys.issues.document(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
|
|
queryFn: async () => {
|
|
try {
|
|
return await issuesApi.getDocument(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY);
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 404) return null;
|
|
throw error;
|
|
}
|
|
},
|
|
retry: false,
|
|
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.getDocument>> | null>(
|
|
issueId,
|
|
),
|
|
});
|
|
const { data: issueTreeCostSummary } = useQuery({
|
|
queryKey: queryKeys.issues.costSummary(issueId),
|
|
queryFn: () => issuesApi.getCostSummary(issueId),
|
|
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.getCostSummary>>>(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 runtimeMs = 0;
|
|
let runCount = 0;
|
|
let hasCost = false;
|
|
let hasTokens = false;
|
|
const nowMs = Date.now();
|
|
|
|
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;
|
|
|
|
if (run.startedAt) {
|
|
const startMs = new Date(run.startedAt).getTime();
|
|
const endMs = run.finishedAt ? new Date(run.finishedAt).getTime() : nowMs;
|
|
if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs) {
|
|
runtimeMs += endMs - startMs;
|
|
runCount += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
input,
|
|
output,
|
|
cached,
|
|
cost,
|
|
totalTokens: input + output,
|
|
hasCost,
|
|
hasTokens,
|
|
runtimeMs,
|
|
runCount,
|
|
hasRuntime: runtimeMs > 0,
|
|
};
|
|
}, [linkedRuns]);
|
|
const issueTreeCostTokens =
|
|
(issueTreeCostSummary?.inputTokens ?? 0) + (issueTreeCostSummary?.outputTokens ?? 0);
|
|
const hasIssueTreeCost =
|
|
!!issueTreeCostSummary
|
|
&& (issueTreeCostSummary.costCents > 0
|
|
|| issueTreeCostTokens > 0
|
|
|| issueTreeCostSummary.cachedInputTokens > 0
|
|
|| issueTreeCostSummary.runtimeMs > 0
|
|
|| issueTreeCostSummary.issueCount > 1);
|
|
const shouldShowCostSummary =
|
|
(linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost;
|
|
|
|
if (initialLoading) {
|
|
return <IssueSectionSkeleton titleWidth="w-20" rows={4} />;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{shouldShowCostSummary && (
|
|
<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 && !hasIssueTreeCost ? (
|
|
<div className="text-xs text-muted-foreground">No cost data yet.</div>
|
|
) : (
|
|
<div className="space-y-1 text-xs text-muted-foreground tabular-nums">
|
|
<div className="flex flex-wrap gap-3">
|
|
<span className="font-medium text-foreground">This issue</span>
|
|
{issueCostSummary.hasCost ? (
|
|
<span className="font-medium text-foreground">
|
|
${issueCostSummary.cost.toFixed(4)}
|
|
</span>
|
|
) : null}
|
|
{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>
|
|
) : null}
|
|
{issueCostSummary.hasRuntime ? (
|
|
<span>
|
|
Runtime {formatDurationMs(issueCostSummary.runtimeMs)}
|
|
{` (${issueCostSummary.runCount} run${issueCostSummary.runCount === 1 ? "" : "s"})`}
|
|
</span>
|
|
) : null}
|
|
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !issueCostSummary.hasRuntime ? (
|
|
<span>No direct cost data.</span>
|
|
) : null}
|
|
</div>
|
|
{hasIssueTreeCost && issueTreeCostSummary ? (
|
|
<div className="flex flex-wrap gap-3">
|
|
<span className="font-medium text-foreground">
|
|
Including sub-issues {(issueTreeCostSummary.costCents / 100).toLocaleString(undefined, {
|
|
style: "currency",
|
|
currency: "USD",
|
|
minimumFractionDigits: 4,
|
|
maximumFractionDigits: 4,
|
|
})}
|
|
</span>
|
|
<span>
|
|
Tokens {formatTokens(issueTreeCostTokens)}
|
|
{issueTreeCostSummary.cachedInputTokens > 0
|
|
? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})`
|
|
: ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`}
|
|
</span>
|
|
{issueTreeCostSummary.runCount > 0 ? (
|
|
<span>
|
|
Runtime {formatDurationMs(issueTreeCostSummary.runtimeMs)}
|
|
{` (${issueTreeCostSummary.runCount} run${issueTreeCostSummary.runCount === 1 ? "" : "s"})`}
|
|
</span>
|
|
) : null}
|
|
<span>{issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="mb-3">
|
|
<IssueRunLedger
|
|
issueId={issueId}
|
|
companyId={companyId}
|
|
issueStatus={issueStatus}
|
|
childIssues={childIssues}
|
|
agentMap={agentMap}
|
|
hasLiveRuns={hasLiveRuns}
|
|
activityEvents={activity ?? []}
|
|
renderActivityEvent={(evt) => {
|
|
const tone = successfulRunHandoffActivityTone(evt.action);
|
|
const isHandoffWarning =
|
|
evt.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
|
|
|| evt.action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
|
|
return (
|
|
<div className={cn("space-y-1.5 rounded-lg border px-3 py-2 text-xs", tone.className)}>
|
|
<div className="flex items-center gap-1.5">
|
|
{isHandoffWarning ? (
|
|
<AlertTriangle className={cn("h-3.5 w-3.5 shrink-0", tone.iconClassName)} />
|
|
) : null}
|
|
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
|
|
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
|
|
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
|
</div>
|
|
<IssueReferenceActivitySummary event={evt} />
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
{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>
|
|
)}
|
|
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
|
|
<IssueScheduledRetryCard issueId={issue.id} scheduledRetry={issue.scheduledRetry ?? null} />
|
|
<IssueMonitorActivityCard
|
|
issue={issue}
|
|
onCheckNow={onCheckMonitorNow}
|
|
checkingNow={checkingMonitorNow}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function IssueDetail() {
|
|
const { issueId } = useParams<{ issueId: string }>();
|
|
const { selectedCompanyId } = useCompany();
|
|
const { openNewIssue } = useDialogActions();
|
|
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 [handoffFocusSignal, setHandoffFocusSignal] = useState(0);
|
|
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 [treeControlOpen, setTreeControlOpen] = useState(false);
|
|
const [treeControlMode, setTreeControlMode] = useState<IssueTreeControlMode>("pause");
|
|
const [treeControlReason, setTreeControlReason] = useState("");
|
|
const [treeControlWakeAgentsOnResume, setTreeControlWakeAgentsOnResume] = useState(false);
|
|
const [treeControlCancelConfirmed, setTreeControlCancelConfirmed] = useState(false);
|
|
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
|
const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState<Map<string, string>>(() => new Map());
|
|
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,
|
|
refetch: refetchComments,
|
|
} = 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 shouldPrefetchOlderComments = useMemo(
|
|
() =>
|
|
shouldAutoloadOlderIssueComments({
|
|
activeDetailTab: detailTab,
|
|
hasOlderComments: hasOlderComments ?? false,
|
|
loadedCommentCount: comments.length,
|
|
initialPageLoading: commentsLoading,
|
|
olderPageLoading: commentsLoadingOlder,
|
|
autoLoadLimit: ISSUE_COMMENT_AUTOLOAD_LIMIT,
|
|
}),
|
|
[comments.length, commentsLoading, commentsLoadingOlder, detailTab, hasOlderComments],
|
|
);
|
|
const { data: interactions = [] } = useQuery({
|
|
queryKey: queryKeys.issues.interactions(issueId!),
|
|
queryFn: () => issuesApi.listInteractions(issueId!),
|
|
enabled: !!issueId,
|
|
placeholderData: keepPreviousDataForSameQueryTail<IssueThreadInteraction[]>(issueId ?? "pending"),
|
|
});
|
|
|
|
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;
|
|
useEffect(() => {
|
|
if (!hasLiveRuns && locallyQueuedCommentRunIds.size > 0) {
|
|
setLocallyQueuedCommentRunIds(new Map());
|
|
}
|
|
}, [hasLiveRuns, locallyQueuedCommentRunIds.size]);
|
|
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.listByDescendantRoot(resolvedCompanyId, issue.id)
|
|
: ["issues", "parent", "pending"],
|
|
queryFn: () => issuesApi.list(resolvedCompanyId!, { descendantOf: issue!.id, includeBlockedBy: true }),
|
|
enabled: !!resolvedCompanyId && !!issue?.id,
|
|
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(issue?.id ?? "pending"),
|
|
});
|
|
const {
|
|
data: rawSiblingIssues = [],
|
|
isLoading: siblingIssuesLoading,
|
|
isError: siblingIssuesError,
|
|
} = useQuery({
|
|
queryKey:
|
|
issue?.parentId && resolvedCompanyId
|
|
? queryKeys.issues.listByParent(resolvedCompanyId, issue.parentId)
|
|
: ["issues", "siblings", "pending"],
|
|
queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.parentId!, includeBlockedBy: true }),
|
|
enabled: !!resolvedCompanyId && !!issue?.parentId,
|
|
});
|
|
const { data: companyLiveRuns } = useQuery({
|
|
queryKey: resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"],
|
|
queryFn: () => heartbeatsApi.liveRunsForCompany(resolvedCompanyId!),
|
|
enabled: !!resolvedCompanyId,
|
|
refetchInterval: 5000,
|
|
placeholderData: keepPreviousDataForSameQueryTail<LiveRunForIssue[]>(resolvedCompanyId ?? "pending"),
|
|
});
|
|
|
|
const { data: agents } = useQuery({
|
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
|
enabled: !!selectedCompanyId,
|
|
});
|
|
const { data: companyMembers } = useQuery({
|
|
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
|
|
queryFn: () => accessApi.listUserDirectory(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: boardAccess } = useQuery({
|
|
queryKey: queryKeys.access.currentBoardAccess,
|
|
queryFn: () => accessApi.getCurrentBoardAccess(),
|
|
enabled: !!session?.user?.id,
|
|
retry: false,
|
|
});
|
|
const canManageTreeControl = Boolean(
|
|
selectedCompanyId
|
|
&& boardAccess?.companyIds?.includes(selectedCompanyId),
|
|
);
|
|
const canResolveBoardRecoveryAction = canBoardResolveRecoveryAction(selectedCompanyId, boardAccess);
|
|
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 {
|
|
data: treeControlPreview,
|
|
isFetching: treeControlPreviewLoading,
|
|
error: treeControlPreviewError,
|
|
refetch: refetchTreeControlPreview,
|
|
} = useQuery({
|
|
queryKey: [
|
|
"issues",
|
|
"tree-control-preview",
|
|
issueId ?? "pending",
|
|
treeControlMode,
|
|
],
|
|
queryFn: () =>
|
|
issuesApi.previewTreeControl(issueId!, {
|
|
mode: treeControlMode,
|
|
releasePolicy: {
|
|
strategy: "manual",
|
|
},
|
|
}),
|
|
enabled: treeControlOpen && !!issueId && canManageTreeControl,
|
|
staleTime: 0,
|
|
retry: false,
|
|
});
|
|
const { data: treeControlState } = useQuery({
|
|
queryKey: ["issues", "tree-control-state", issueId ?? "pending"],
|
|
queryFn: () => issuesApi.getTreeControlState(issueId!),
|
|
enabled: !!issueId && canManageTreeControl,
|
|
retry: false,
|
|
});
|
|
const { data: activeRootPauseHolds = [] } = useQuery({
|
|
queryKey: ["issues", "tree-holds", issueId ?? "pending", "active-pause-with-members"],
|
|
queryFn: () =>
|
|
issuesApi.listTreeHolds(issueId!, {
|
|
status: "active",
|
|
mode: "pause",
|
|
includeMembers: true,
|
|
}),
|
|
enabled: !!issueId && treeControlState?.activePauseHold?.isRoot === true,
|
|
});
|
|
const { data: activeCancelHolds = [] } = useQuery({
|
|
queryKey: ["issues", "tree-holds", issueId ?? "pending", "active-cancel"],
|
|
queryFn: () =>
|
|
issuesApi.listTreeHolds(issueId!, {
|
|
status: "active",
|
|
mode: "cancel",
|
|
}),
|
|
enabled: !!issueId && canManageTreeControl,
|
|
});
|
|
|
|
const agentMap = useMemo(() => {
|
|
const map = new Map<string, Agent>();
|
|
for (const a of agents ?? []) map.set(a.id, a);
|
|
return map;
|
|
}, [agents]);
|
|
const userProfileMap = useMemo(
|
|
() => buildCompanyUserProfileMap(companyMembers?.users),
|
|
[companyMembers?.users],
|
|
);
|
|
const userLabelMap = useMemo(
|
|
() => buildCompanyUserLabelMap(companyMembers?.users),
|
|
[companyMembers?.users],
|
|
);
|
|
const mentionOptions = useMemo<MentionOption[]>(() => {
|
|
return buildMarkdownMentionOptions({
|
|
agents,
|
|
projects: orderedProjects,
|
|
members: companyMembers?.users,
|
|
});
|
|
}, [agents, companyMembers?.users, 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(
|
|
() => {
|
|
const descendants = issue?.id ? filterIssueDescendants(issue.id, rawChildIssues) : rawChildIssues;
|
|
return [...descendants].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
|
},
|
|
[issue?.id, rawChildIssues],
|
|
);
|
|
const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]);
|
|
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 siblingNavigation = useMemo(
|
|
() => issue && !childIssuesLoading && !siblingIssuesLoading && !siblingIssuesError
|
|
? buildIssueSiblingNavigation(issue, rawSiblingIssues, childIssues)
|
|
: null,
|
|
[childIssues, childIssuesLoading, issue, rawSiblingIssues, siblingIssuesError, siblingIssuesLoading],
|
|
);
|
|
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 }> = [];
|
|
options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }));
|
|
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, companyMembers?.users, 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 issueCacheRefs = useMemo(() => {
|
|
const refs = new Set<string>();
|
|
if (issueId) refs.add(issueId);
|
|
if (issue?.id) refs.add(issue.id);
|
|
if (issue?.identifier) refs.add(issue.identifier);
|
|
return [...refs];
|
|
}, [issue?.id, issue?.identifier, issueId]);
|
|
|
|
const invalidateIssueDetail = useCallback(() => {
|
|
for (const ref of issueCacheRefs) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) });
|
|
}
|
|
}, [issueCacheRefs, queryClient]);
|
|
const invalidateIssueThreadLazily = useCallback(() => {
|
|
for (const ref of issueCacheRefs) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), refetchType: "inactive" });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), refetchType: "inactive" });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), refetchType: "inactive" });
|
|
}
|
|
}, [issueCacheRefs, queryClient]);
|
|
|
|
const invalidateIssueRunState = useCallback(() => {
|
|
for (const ref of issueCacheRefs) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
|
}
|
|
}, [issueCacheRefs, 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 upsertInteractionInCache = useCallback((interaction: IssueThreadInteraction) => {
|
|
queryClient.setQueryData<IssueThreadInteraction[] | undefined>(
|
|
queryKeys.issues.interactions(issueId!),
|
|
(current) => {
|
|
const existing = current ?? [];
|
|
const next = existing.filter((entry) => entry.id !== interaction.id);
|
|
next.push(interaction);
|
|
next.sort((left, right) => {
|
|
const createdAtDelta =
|
|
new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime();
|
|
return createdAtDelta === 0 ? left.id.localeCompare(right.id) : createdAtDelta;
|
|
});
|
|
return next;
|
|
},
|
|
);
|
|
}, [issueId, queryClient]);
|
|
|
|
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 resolveRecoveryAction = useMutation({
|
|
mutationFn: (data: {
|
|
actionId?: string;
|
|
outcome: ResolveRecoveryActionOutcome;
|
|
sourceIssueStatus: "done" | "in_review" | "blocked";
|
|
resolutionNote?: string | null;
|
|
}) => issuesApi.resolveRecoveryAction(issueId!, data),
|
|
onSuccess: ({ issue: 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) => {
|
|
pushToast({
|
|
title: "Recovery resolution failed",
|
|
body: err instanceof Error ? err.message : "Unable to resolve recovery action",
|
|
tone: "error",
|
|
});
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
|
|
if (selectedCompanyId) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
|
}
|
|
},
|
|
});
|
|
const executeTreeControl = useMutation({
|
|
mutationFn: async () => {
|
|
if (treeControlMode === "resume") {
|
|
const pauseHoldId = treeControlState?.activePauseHold?.holdId;
|
|
if (!pauseHoldId) {
|
|
throw new Error("No active subtree pause hold is available to resume.");
|
|
}
|
|
const releasedHold = await issuesApi.releaseTreeHold(issueId!, pauseHoldId, {
|
|
reason: treeControlReason.trim() || null,
|
|
metadata: {
|
|
wakeAgents: treeControlWakeAgentsOnResume,
|
|
},
|
|
});
|
|
return { kind: "release" as const, hold: releasedHold };
|
|
}
|
|
const created = await issuesApi.createTreeHold(issueId!, {
|
|
mode: treeControlMode,
|
|
reason: treeControlReason.trim() || null,
|
|
releasePolicy: {
|
|
strategy: "manual",
|
|
...(treeControlMode === "pause" ? { note: treeControlScope === "leaf" ? "leaf_pause" : "full_pause" } : {}),
|
|
},
|
|
...(treeControlMode === "restore"
|
|
? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } }
|
|
: {}),
|
|
});
|
|
return { kind: "create" as const, hold: created.hold, preview: created.preview };
|
|
},
|
|
onSuccess: async (result) => {
|
|
const modeLabel = issueTreeControlLabel(result.hold.mode, treeControlScope);
|
|
const cancelCount = result.preview?.totals.activeRuns ?? 0;
|
|
pushToast({
|
|
title: result.kind === "release"
|
|
? treeControlScope === "leaf" ? "Work resumed" : "Subtree resumed"
|
|
: result.hold.mode === "pause"
|
|
? treeControlScope === "leaf" ? "Work paused" : "Subtree paused"
|
|
: `${modeLabel} applied`,
|
|
body: result.kind === "release"
|
|
? (result.hold.releaseReason?.trim() || (treeControlScope === "leaf" ? "Active issue pause released." : "Active subtree pause released."))
|
|
: result.hold.mode === "pause"
|
|
? treeControlScope === "leaf"
|
|
? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
|
: `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
|
: result.hold.reason?.trim()
|
|
? result.hold.reason
|
|
: "Subtree control applied.",
|
|
});
|
|
setTreeControlOpen(false);
|
|
setTreeControlReason("");
|
|
setTreeControlWakeAgentsOnResume(false);
|
|
setTreeControlCancelConfirmed(false);
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["issues", "tree-holds", issueId ?? "pending"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-preview", issueId ?? "pending"] }),
|
|
]);
|
|
if (selectedCompanyId) {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }),
|
|
...(issue?.id
|
|
? [
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByDescendantRoot(selectedCompanyId, issue.id) }),
|
|
]
|
|
: []),
|
|
]);
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Unable to apply subtree control",
|
|
body: err instanceof Error ? err.message : "Please try again.",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
const pauseIssueWorkRun = useMutation({
|
|
mutationFn: async ({ runId, scope }: { runId: string; scope: "leaf" | "subtree" }) => {
|
|
const created = await issuesApi.createTreeHold(issueId!, {
|
|
mode: "pause",
|
|
reason: "Paused from active run controls.",
|
|
releasePolicy: { strategy: "manual", note: scope === "leaf" ? "leaf_pause" : "full_pause" },
|
|
metadata: { source: "issue_active_run_control", runId },
|
|
});
|
|
return created;
|
|
},
|
|
onSuccess: async (result) => {
|
|
const cancelCount = result.preview?.totals.activeRuns ?? 0;
|
|
pushToast({
|
|
title: "Work paused",
|
|
body: cancelCount > 0
|
|
? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
|
: "Work paused. This issue is held until resume.",
|
|
tone: "success",
|
|
});
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
|
|
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["issues", "tree-holds", issueId ?? "pending"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-preview", issueId ?? "pending"] }),
|
|
]);
|
|
invalidateIssueCollections();
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Unable to pause work",
|
|
body: err instanceof Error ? err.message : "Please try again.",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
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 checkIssueMonitorNow = useMutation({
|
|
mutationFn: () => issuesApi.checkMonitorNow(issueId!),
|
|
onSuccess: () => {
|
|
invalidateIssueDetail();
|
|
invalidateIssueRunState();
|
|
invalidateIssueCollections();
|
|
pushToast({
|
|
title: "Monitor check queued",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Monitor check failed",
|
|
body: err instanceof Error ? err.message : "Unable to trigger the monitor right now",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
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,
|
|
queuedCommentTargetRunId: queuedComment?.id ?? 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",
|
|
});
|
|
}
|
|
}
|
|
if (context?.queuedCommentTargetRunId) {
|
|
setLocallyQueuedCommentRunIds((current) => {
|
|
const next = new Map(current);
|
|
next.set(comment.id, context.queuedCommentTargetRunId!);
|
|
return next;
|
|
});
|
|
}
|
|
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 acceptInteraction = useMutation({
|
|
mutationFn: ({
|
|
interaction,
|
|
selectedClientKeys,
|
|
}: {
|
|
interaction: ActionableIssueThreadInteraction;
|
|
selectedClientKeys?: string[];
|
|
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
|
|
onSuccess: (interaction) => {
|
|
upsertInteractionInCache(interaction);
|
|
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(resolvedCompanyId, issue.id) });
|
|
}
|
|
invalidateIssueDetail();
|
|
invalidateIssueCollections();
|
|
const createdCount = interaction.kind === "suggest_tasks"
|
|
? interaction.result?.createdTasks?.length ?? 0
|
|
: 0;
|
|
const skippedCount = interaction.kind === "suggest_tasks"
|
|
? interaction.result?.skippedClientKeys?.length ?? 0
|
|
: 0;
|
|
pushToast({
|
|
title: interaction.kind === "request_confirmation"
|
|
? "Request confirmed"
|
|
: skippedCount > 0
|
|
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
|
|
: "Suggested tasks accepted",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Accept failed",
|
|
body: err instanceof Error ? err.message : "Unable to accept the suggested tasks",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
const rejectInteraction = useMutation({
|
|
mutationFn: ({ interaction, reason }: { interaction: ActionableIssueThreadInteraction; reason?: string }) =>
|
|
issuesApi.rejectInteraction(issueId!, interaction.id, reason),
|
|
onSuccess: (interaction) => {
|
|
upsertInteractionInCache(interaction);
|
|
invalidateIssueDetail();
|
|
invalidateIssueCollections();
|
|
pushToast({
|
|
title: interaction.kind === "request_confirmation" ? "Request declined" : "Suggestion rejected",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Reject failed",
|
|
body: err instanceof Error ? err.message : "Unable to reject the suggested tasks",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
const answerInteraction = useMutation({
|
|
mutationFn: ({
|
|
interaction,
|
|
answers,
|
|
}: {
|
|
interaction: IssueThreadInteraction;
|
|
answers: AskUserQuestionsAnswer[];
|
|
}) => issuesApi.respondToInteraction(issueId!, interaction.id, { answers }),
|
|
onSuccess: (interaction) => {
|
|
upsertInteractionInCache(interaction);
|
|
invalidateIssueDetail();
|
|
invalidateIssueCollections();
|
|
pushToast({
|
|
title: "Answers submitted",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Submit failed",
|
|
body: err instanceof Error ? err.message : "Unable to submit answers",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
const cancelInteraction = useMutation({
|
|
mutationFn: ({ interaction }: { interaction: AskUserQuestionsInteraction }) =>
|
|
issuesApi.cancelInteraction(issueId!, interaction.id),
|
|
onSuccess: (interaction) => {
|
|
upsertInteractionInCache(interaction);
|
|
invalidateIssueDetail();
|
|
invalidateIssueCollections();
|
|
pushToast({
|
|
title: "Question cancelled",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Cancel failed",
|
|
body: err instanceof Error ? err.message : "Unable to cancel the question",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
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,
|
|
queuedCommentTargetRunId: queuedComment?.id ?? 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 && context?.queuedCommentTargetRunId) {
|
|
setLocallyQueuedCommentRunIds((current) => {
|
|
const next = new Map(current);
|
|
next.set(comment.id, context.queuedCommentTargetRunId!);
|
|
return next;
|
|
});
|
|
}
|
|
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 Promise.all(issueCacheRefs.flatMap((ref) => [
|
|
queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(ref) }),
|
|
queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(ref) }),
|
|
queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(ref) }),
|
|
queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(ref) }),
|
|
]));
|
|
|
|
const previousRunState = issueCacheRefs.map((ref) => ({
|
|
ref,
|
|
runs: queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(ref)),
|
|
liveRuns: queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(ref)),
|
|
activeRun: queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(ref)),
|
|
issue: queryClient.getQueryData<Issue>(queryKeys.issues.detail(ref)),
|
|
}));
|
|
const previousLocalQueuedCommentRunIds = locallyQueuedCommentRunIds;
|
|
const cachedActiveRun =
|
|
previousRunState.find((state) => state.activeRun?.id === runId)?.activeRun ??
|
|
previousRunState.find((state) => state.activeRun)?.activeRun ??
|
|
null;
|
|
const liveRunList = dedupeLiveRunsById(previousRunState.flatMap((state) => state.liveRuns ?? []));
|
|
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();
|
|
for (const ref of issueCacheRefs) {
|
|
queryClient.setQueryData<RunForIssue[] | undefined>(
|
|
queryKeys.issues.runs(ref),
|
|
(current) => upsertInterruptedRun(current, targetRun, interruptedAt),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const ref of issueCacheRefs) {
|
|
queryClient.setQueryData(
|
|
queryKeys.issues.liveRuns(ref),
|
|
(current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId),
|
|
);
|
|
queryClient.setQueryData(
|
|
queryKeys.issues.activeRun(ref),
|
|
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
|
|
);
|
|
queryClient.setQueryData(
|
|
queryKeys.issues.detail(ref),
|
|
(current: Issue | undefined) => clearIssueExecutionRun(current, runId),
|
|
);
|
|
}
|
|
setLocallyQueuedCommentRunIds((current) => {
|
|
const next = new Map([...current].filter(([, targetRunId]) => targetRunId !== runId));
|
|
return next.size === current.size ? current : next;
|
|
});
|
|
|
|
return {
|
|
previousRunState,
|
|
previousLocalQueuedCommentRunIds,
|
|
};
|
|
},
|
|
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) => {
|
|
for (const state of context?.previousRunState ?? []) {
|
|
queryClient.setQueryData(queryKeys.issues.runs(state.ref), state.runs);
|
|
queryClient.setQueryData(queryKeys.issues.liveRuns(state.ref), state.liveRuns);
|
|
queryClient.setQueryData(queryKeys.issues.activeRun(state.ref), state.activeRun);
|
|
queryClient.setQueryData(queryKeys.issues.detail(state.ref), state.issue);
|
|
}
|
|
if (context?.previousLocalQueuedCommentRunIds) {
|
|
setLocallyQueuedCommentRunIds(context.previousLocalQueuedCommentRunIds);
|
|
}
|
|
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) => {
|
|
setLocallyQueuedCommentRunIds((current) => {
|
|
if (!current.has(comment.id)) return current;
|
|
const next = new Map(current);
|
|
next.delete(comment.id);
|
|
return next;
|
|
});
|
|
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(() => {
|
|
const hash = location.hash;
|
|
if (!hash.startsWith("#document-")) return;
|
|
const documentKey = decodeURIComponent(hash.slice("#document-".length));
|
|
if (documentKey !== ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY) return;
|
|
setDetailTab("activity");
|
|
setHandoffFocusSignal((current) => current + 1);
|
|
}, [location.hash]);
|
|
|
|
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;
|
|
const canArchiveFromInbox = isFromInbox && !!issue?.id && !issueHidden;
|
|
|
|
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 refetchLatestComments = useCallback(async () => {
|
|
// Refetch page 0 first so comments that arrived after initial load are
|
|
// visible, then load every remaining older page. The chat thread is
|
|
// paginated and virtualized, so "latest" must be resolved against the
|
|
// complete comment set rather than the current loaded window.
|
|
const refreshed = await refetchComments();
|
|
const loaded = await loadRemainingIssueCommentPages<IssueComment>({
|
|
pages: refreshed.data?.pages,
|
|
pageParams: refreshed.data?.pageParams as Array<string | null> | undefined,
|
|
pageSize: ISSUE_COMMENT_PAGE_SIZE,
|
|
maxPages: JUMP_TO_LATEST_MAX_COMMENT_PAGES,
|
|
fetchPage: (afterCommentId) =>
|
|
issuesApi.listComments(issueId!, {
|
|
order: "desc",
|
|
limit: ISSUE_COMMENT_PAGE_SIZE,
|
|
after: afterCommentId,
|
|
}),
|
|
});
|
|
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
|
|
queryKeys.issues.comments(issueId!),
|
|
loaded,
|
|
);
|
|
await new Promise<void>((resolve) => {
|
|
if (typeof window === "undefined") {
|
|
resolve();
|
|
return;
|
|
}
|
|
window.requestAnimationFrame(() => resolve());
|
|
});
|
|
}, [issueId, queryClient, refetchComments]);
|
|
useEffect(() => {
|
|
if (!shouldPrefetchOlderComments) return;
|
|
void fetchOlderComments();
|
|
}, [fetchOlderComments, shouldPrefetchOlderComments]);
|
|
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) => {
|
|
return uploadAttachment.mutateAsync(file);
|
|
}, [uploadAttachment]);
|
|
const handleInterruptQueuedRun = useCallback(async (runId: string) => {
|
|
await interruptQueuedComment.mutateAsync(runId);
|
|
}, [interruptQueuedComment]);
|
|
const handleAcceptInteraction = useCallback(async (
|
|
interaction: ActionableIssueThreadInteraction,
|
|
selectedClientKeys?: string[],
|
|
) => {
|
|
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
|
|
}, [acceptInteraction]);
|
|
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
|
|
await rejectInteraction.mutateAsync({ interaction, reason });
|
|
}, [rejectInteraction]);
|
|
const handleSubmitInteractionAnswers = useCallback(async (
|
|
interaction: IssueThreadInteraction,
|
|
answers: AskUserQuestionsAnswer[],
|
|
) => {
|
|
await answerInteraction.mutateAsync({ interaction, answers });
|
|
}, [answerInteraction]);
|
|
const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => {
|
|
await cancelInteraction.mutateAsync({ interaction });
|
|
}, [cancelInteraction]);
|
|
const canResumeFromBacklog = issue?.status === "backlog" && Boolean(issue.assigneeAgentId || issue.assigneeUserId);
|
|
const handleResumeFromBacklog = useCallback(async () => {
|
|
await updateIssue.mutateAsync({ status: "todo" });
|
|
}, [updateIssue.mutateAsync]);
|
|
const activeRecoveryActionId = issue?.activeRecoveryAction?.id;
|
|
const handleResolveRecoveryAction = useCallback(
|
|
(outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => {
|
|
const actionId = activeRecoveryActionId;
|
|
if (!actionId) return;
|
|
switch (outcome) {
|
|
case "done":
|
|
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" });
|
|
return;
|
|
case "in_review":
|
|
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "in_review" });
|
|
return;
|
|
case "false_positive_done":
|
|
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "done" });
|
|
return;
|
|
case "false_positive_in_review":
|
|
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "false_positive", sourceIssueStatus: "in_review" });
|
|
return;
|
|
}
|
|
},
|
|
[activeRecoveryActionId, resolveRecoveryAction.mutateAsync],
|
|
);
|
|
|
|
const treePreviewAffectedIssues = useMemo(
|
|
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
|
|
[treeControlPreview],
|
|
);
|
|
const treePreviewDisplayIssues = useMemo(
|
|
() => {
|
|
const previewIssues = treeControlPreview?.issues ?? [];
|
|
if (treeControlMode !== "pause") {
|
|
return previewIssues.filter((candidate) => !candidate.skipped);
|
|
}
|
|
return previewIssues.filter((candidate) => !candidate.skipped || candidate.skipReason === "terminal_status");
|
|
},
|
|
[treeControlMode, treeControlPreview],
|
|
);
|
|
const activePauseHold = treeControlState?.activePauseHold ?? null;
|
|
const activeRootPauseHoldsForDisplay = useMemo(
|
|
() => activePauseHold?.isRoot === true ? activeRootPauseHolds : [],
|
|
[activePauseHold?.isRoot, activeRootPauseHolds],
|
|
);
|
|
const heldIssueIds = useMemo(() => {
|
|
const ids = new Set<string>();
|
|
for (const hold of activeRootPauseHoldsForDisplay) {
|
|
for (const member of hold.members ?? []) {
|
|
if (member.skipped) continue;
|
|
ids.add(member.issueId);
|
|
}
|
|
}
|
|
return ids;
|
|
}, [activeRootPauseHoldsForDisplay]);
|
|
const mutedChildIssueIds = useMemo(() => {
|
|
const ids = new Set<string>();
|
|
for (const child of childIssues) {
|
|
if (heldIssueIds.has(child.id)) ids.add(child.id);
|
|
}
|
|
return ids;
|
|
}, [childIssues, heldIssueIds]);
|
|
const childPauseBadgeById = useMemo(() => {
|
|
const badges = new Map<string, string>();
|
|
for (const child of childIssues) {
|
|
if (!heldIssueIds.has(child.id)) continue;
|
|
badges.set(child.id, "Paused");
|
|
}
|
|
return badges;
|
|
}, [childIssues, heldIssueIds]);
|
|
const activePauseHoldRoot = useMemo(() => {
|
|
if (!activePauseHold) return null;
|
|
if (activePauseHold.rootIssueId === issue?.id) return issue ?? null;
|
|
return issue?.ancestors?.find((ancestor) => ancestor.id === activePauseHold.rootIssueId) ?? null;
|
|
}, [activePauseHold, issue]);
|
|
const activeRootPauseHold = useMemo(
|
|
() => activeRootPauseHoldsForDisplay.find((hold) => hold.id === activePauseHold?.holdId) ?? null,
|
|
[activePauseHold?.holdId, activeRootPauseHoldsForDisplay],
|
|
);
|
|
|
|
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 legacyRecoverySourceIssue = (() => {
|
|
if (
|
|
issue.originKind !== "stranded_issue_recovery" &&
|
|
issue.originKind !== "stale_active_run_evaluation"
|
|
) {
|
|
return null;
|
|
}
|
|
const parent = ancestors.length > 0 ? ancestors[0] : null;
|
|
if (!parent) return null;
|
|
const ref = parent.identifier ?? parent.id;
|
|
return {
|
|
identifier: parent.identifier ?? null,
|
|
title: parent.title ?? null,
|
|
href: createIssueDetailPath(ref),
|
|
};
|
|
})();
|
|
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 treePreviewWarnings = treeControlPreview?.warnings ?? [];
|
|
const heldDescendantCount = activeRootPauseHold?.members?.filter((member) => member.depth > 0 && !member.skipped).length
|
|
?? Math.max(heldIssueIds.size - 1, 0);
|
|
const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0;
|
|
const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true;
|
|
const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0;
|
|
const isTerminalIssue = issue.status === "done" || issue.status === "cancelled";
|
|
const isAgentOwnedNonTerminalIssue = Boolean(issue.assigneeAgentId) && !isTerminalIssue;
|
|
const canPauseLeafWork = canManageTreeControl && childIssues.length === 0 && !activePauseHold && !isTerminalIssue;
|
|
const canResumeLeafWork = canManageTreeControl && childIssues.length === 0 && activePauseHold?.isRoot === true;
|
|
const treeControlScope: "leaf" | "subtree" = childIssues.length === 0 ? "leaf" : "subtree";
|
|
const previewAffectedIssueCount = treePreviewAffectedIssues.length;
|
|
const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0;
|
|
const treeControlPrimaryButtonLabel =
|
|
treeControlMode === "pause"
|
|
? treeControlScope === "leaf"
|
|
? "Pause work"
|
|
: "Pause and stop work"
|
|
: treeControlMode === "cancel"
|
|
? `Cancel ${previewAffectedIssueCount} issues`
|
|
: treeControlMode === "restore"
|
|
? `Restore ${previewAffectedIssueCount} issues`
|
|
: treeControlScope === "leaf"
|
|
? "Resume work"
|
|
: "Resume subtree";
|
|
const treePreviewAffectedIssueRows = treePreviewDisplayIssues.map((candidate) => ({
|
|
candidate,
|
|
issue: {
|
|
...issue,
|
|
id: candidate.id,
|
|
identifier: candidate.identifier,
|
|
title: candidate.title,
|
|
status: candidate.status,
|
|
parentId: candidate.parentId,
|
|
assigneeAgentId: candidate.assigneeAgentId,
|
|
assigneeUserId: candidate.assigneeUserId,
|
|
executionRunId: candidate.activeRun?.id ?? null,
|
|
} satisfies Issue,
|
|
}));
|
|
const treePreviewAffectedAgentRows = (treeControlPreview?.affectedAgents ?? [])
|
|
.map((previewAgent) => ({
|
|
...previewAgent,
|
|
agent: agentMap.get(previewAgent.agentId) ?? null,
|
|
}))
|
|
.sort((a, b) => (a.agent?.name ?? a.agentId).localeCompare(b.agent?.name ?? b.agentId));
|
|
const pausedComposerHint = activePauseHold
|
|
? (
|
|
issue.assigneeAgentId
|
|
? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} for triage while the subtree remains paused.`
|
|
: "Assign an agent to wake them for triage while the subtree remains paused."
|
|
)
|
|
: null;
|
|
const composerHint = pausedComposerHint;
|
|
const queuedCommentReason: "hold" | "active_run" | "other" = activePauseHold ? "hold" : "active_run";
|
|
const canApplyTreeControl =
|
|
Boolean(treeControlPreview)
|
|
&& !treeControlPreviewLoading
|
|
&& (treeControlMode !== "cancel" || treeControlCancelConfirmed);
|
|
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-3xl 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>
|
|
)}
|
|
{activePauseHold && (
|
|
<div className="rounded-md border border-amber-500/35 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200">
|
|
{activePauseHold.isRoot ? (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-medium">
|
|
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
|
|
</span>
|
|
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
|
{childIssues.length === 0
|
|
? "Issue execution is held until resume. Human comments can still wake the assignee for triage."
|
|
: "Root and descendant execution is held until resume. Human comments can still wake assignees for triage."}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
|
{childIssues.length === 0
|
|
? "1 issue held"
|
|
: `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`}
|
|
{activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
|
|
</div>
|
|
{canShowSubtreeControls || canResumeLeafWork ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setTreeControlMode("resume");
|
|
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
|
setTreeControlOpen(true);
|
|
}}
|
|
>
|
|
{childIssues.length === 0 ? "Resume work" : "Resume subtree"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setTreeControlMode("resume");
|
|
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
|
setTreeControlOpen(true);
|
|
}}
|
|
>
|
|
View affected ({childIssues.length === 0 ? 1 : heldDescendantCount})
|
|
</Button>
|
|
{canShowSubtreeControls ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => {
|
|
setTreeControlMode("cancel");
|
|
setTreeControlCancelConfirmed(false);
|
|
setTreeControlOpen(true);
|
|
}}
|
|
>
|
|
Cancel subtree...
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs">
|
|
This issue is paused by ancestor{" "}
|
|
{activePauseHoldRoot?.identifier ? (
|
|
<Link to={createIssueDetailPath(activePauseHoldRoot.identifier)} className="underline">
|
|
{activePauseHoldRoot.identifier}
|
|
</Link>
|
|
) : (
|
|
activePauseHold.rootIssueId.slice(0, 8)
|
|
)}
|
|
. Resume from the root issue to deliver deferred work.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
|
<StatusIcon
|
|
status={issue.status}
|
|
blockerAttention={issue.blockerAttention}
|
|
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.productivityReview ? (
|
|
<ProductivityReviewBadge review={issue.productivityReview} />
|
|
) : null}
|
|
|
|
{issue.originKind === "issue_productivity_review" ? (
|
|
<span
|
|
className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
|
|
title="This task is a productivity review."
|
|
>
|
|
<Eye className="h-3 w-3" />
|
|
Productivity review
|
|
</span>
|
|
) : null}
|
|
|
|
{issue.workMode === "planning" ? (
|
|
<span
|
|
className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
|
|
title="This issue is in planning mode."
|
|
>
|
|
Planning
|
|
</span>
|
|
) : null}
|
|
|
|
{hasAssignedBacklogBlocker(issue.blockedBy) ? (
|
|
<span
|
|
data-testid="issue-detail-parked-blocker"
|
|
className="inline-flex items-center gap-1 rounded-full border border-amber-500/60 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
|
|
title="Blocked by parked work — at least one assigned blocker is in backlog and will not wake its assignee."
|
|
>
|
|
<Flag className="h-3 w-3" />
|
|
Blocked by parked work
|
|
</span>
|
|
) : null}
|
|
|
|
{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">
|
|
{canArchiveFromInbox && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => {
|
|
if (!archivePending && issue?.id) archiveFromInbox.mutate(issue.id);
|
|
}}
|
|
disabled={archivePending}
|
|
title="Archive from inbox"
|
|
aria-label="Archive from inbox"
|
|
>
|
|
<Archive className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<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"
|
|
aria-label="More issue actions"
|
|
title="More issue actions"
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
setMoreOpen(true);
|
|
}
|
|
}}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-52 p-1" align="end">
|
|
{canPauseLeafWork ? (
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
onClick={() => {
|
|
setTreeControlMode("pause");
|
|
setTreeControlCancelConfirmed(false);
|
|
setTreeControlOpen(true);
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<PauseCircle className="h-3 w-3" />
|
|
Pause work...
|
|
</button>
|
|
) : null}
|
|
{canResumeLeafWork ? (
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
onClick={() => {
|
|
setTreeControlMode("resume");
|
|
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue);
|
|
setTreeControlOpen(true);
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<PlayCircle className="h-3 w-3" />
|
|
Resume work
|
|
</button>
|
|
) : null}
|
|
{canShowSubtreeControls ? (
|
|
<>
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
onClick={() => {
|
|
setTreeControlMode("pause");
|
|
setTreeControlCancelConfirmed(false);
|
|
setTreeControlOpen(true);
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<PauseCircle className="h-3 w-3" />
|
|
Pause subtree...
|
|
</button>
|
|
{canResumeSubtree ? (
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
onClick={() => {
|
|
setTreeControlMode("resume");
|
|
setTreeControlWakeAgentsOnResume(true);
|
|
setTreeControlOpen(true);
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<PlayCircle className="h-3 w-3" />
|
|
Resume subtree
|
|
</button>
|
|
) : null}
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
|
onClick={() => {
|
|
setTreeControlMode("cancel");
|
|
setTreeControlCancelConfirmed(false);
|
|
setTreeControlOpen(true);
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<XCircle className="h-3 w-3" />
|
|
Cancel subtree...
|
|
</button>
|
|
{canRestoreSubtree ? (
|
|
<button
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
|
onClick={() => {
|
|
setTreeControlMode("restore");
|
|
setTreeControlWakeAgentsOnResume(false);
|
|
setTreeControlCancelConfirmed(false);
|
|
setTreeControlOpen(true);
|
|
setMoreOpen(false);
|
|
}}
|
|
>
|
|
<Repeat className="h-3 w-3" />
|
|
Restore subtree...
|
|
</button>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
<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
|
|
foldable
|
|
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}
|
|
liveIssueIds={liveIssueIds}
|
|
mutedIssueIds={mutedChildIssueIds}
|
|
issueBadgeById={childPauseBadgeById}
|
|
projectId={issue.projectId ?? undefined}
|
|
viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`}
|
|
issueLinkState={resolvedIssueDetailState ?? location.state}
|
|
searchFilters={{ descendantOf: issue.id, includeBlockedBy: true }}
|
|
searchWithinLoadedIssues
|
|
baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)}
|
|
createIssueLabel="Sub-issue"
|
|
defaultSortField="workflow"
|
|
showProgressSummary
|
|
parentIssueIdForCostSummary={issue.id}
|
|
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)}
|
|
canManageDocumentLocks={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>
|
|
<TabsTrigger value="related-work" className="gap-1.5">
|
|
<ListTree className="h-3.5 w-3.5" />
|
|
Related work
|
|
</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}
|
|
issueWorkMode={issue.workMode ?? "standard"}
|
|
executionRunId={issue.executionRunId ?? null}
|
|
blockedBy={issue.blockedBy ?? []}
|
|
blockerAttention={issue.blockerAttention ?? null}
|
|
successfulRunHandoff={issue.successfulRunHandoff ?? null}
|
|
recoveryAction={issue.activeRecoveryAction ?? null}
|
|
onResolveRecoveryAction={handleResolveRecoveryAction}
|
|
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
|
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
|
|
comments={threadComments}
|
|
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
|
|
interactions={interactions}
|
|
hasOlderComments={hasOlderComments}
|
|
commentsLoadingOlder={commentsLoadingOlder}
|
|
onLoadOlderComments={loadOlderComments}
|
|
onRefreshLatestComments={refetchLatestComments}
|
|
composerRef={commentComposerRef}
|
|
footer={
|
|
siblingNavigation ? (
|
|
<IssueSiblingNavigation
|
|
navigation={siblingNavigation}
|
|
linkState={resolvedIssueDetailState ?? location.state}
|
|
/>
|
|
) : null
|
|
}
|
|
feedbackVotes={feedbackVotes}
|
|
feedbackDataSharingPreference={feedbackDataSharingPreference}
|
|
feedbackTermsUrl={FEEDBACK_TERMS_URL}
|
|
agentMap={agentMap}
|
|
currentUserId={currentUserId}
|
|
userLabelMap={userLabelMap}
|
|
userProfileMap={userProfileMap}
|
|
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
|
|
reassignOptions={commentReassignOptions}
|
|
currentAssigneeValue={actualAssigneeValue}
|
|
suggestedAssigneeValue={suggestedAssigneeValue}
|
|
mentions={mentionOptions}
|
|
composerDisabledReason={commentComposerDisabledReason}
|
|
composerHint={composerHint}
|
|
queuedCommentReason={queuedCommentReason}
|
|
onVote={handleCommentVote}
|
|
onAdd={handleChatAdd}
|
|
onImageUpload={handleCommentImageUpload}
|
|
onAttachImage={handleCommentAttachImage}
|
|
onInterruptQueued={handleInterruptQueuedRun}
|
|
onPauseWorkRun={canManageTreeControl
|
|
? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined)
|
|
: undefined}
|
|
onWorkModeChange={(nextMode) => {
|
|
const currentMode: IssueWorkMode = issue.workMode ?? "standard";
|
|
if (currentMode === nextMode) return;
|
|
return updateIssue.mutateAsync({ workMode: nextMode }).then(() => undefined);
|
|
}}
|
|
onCancelQueued={handleCancelQueuedComment}
|
|
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
|
|
pausingWorkRunId={pauseIssueWorkRun.isPending ? pauseIssueWorkRun.variables?.runId ?? null : null}
|
|
onImageClick={handleChatImageClick}
|
|
onAcceptInteraction={handleAcceptInteraction}
|
|
onRejectInteraction={handleRejectInteraction}
|
|
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
|
|
onCancelInteraction={handleCancelInteraction}
|
|
assigneeUserId={issue.assigneeUserId ?? null}
|
|
onResumeFromBacklog={canResumeFromBacklog ? handleResumeFromBacklog : undefined}
|
|
resumeFromBacklogPending={
|
|
updateIssue.isPending && updateIssue.variables?.status === "todo"
|
|
}
|
|
/>
|
|
) : null}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="activity">
|
|
{detailTab === "activity" ? (
|
|
<IssueDetailActivityTab
|
|
issue={issue}
|
|
issueId={issue.id}
|
|
companyId={issue.companyId}
|
|
issueStatus={issue.status}
|
|
childIssues={childIssues}
|
|
agentMap={agentMap}
|
|
hasLiveRuns={hasLiveRuns}
|
|
currentUserId={currentUserId}
|
|
userProfileMap={userProfileMap}
|
|
pendingApprovalAction={pendingApprovalAction}
|
|
handoffFocusSignal={handoffFocusSignal}
|
|
onApprovalAction={(approvalId, action) => {
|
|
approvalDecision.mutate({ approvalId, action });
|
|
}}
|
|
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
|
|
checkingMonitorNow={checkIssueMonitorNow.isPending}
|
|
/>
|
|
) : null}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="related-work">
|
|
<IssueRelatedWorkPanel relatedWork={issue.relatedWork} />
|
|
</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>
|
|
|
|
<Dialog open={treeControlOpen} onOpenChange={setTreeControlOpen}>
|
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[560px]">
|
|
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
|
|
<DialogTitle>{issueTreeControlLabel(treeControlMode, treeControlScope)}</DialogTitle>
|
|
<DialogDescription>
|
|
{issueTreeControlHelpText(treeControlMode, treeControlScope)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
|
|
{treeControlMode === "cancel" ? (
|
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
|
|
Cancelling a subtree is destructive. Non-terminal issues will be marked cancelled, and running or queued work will be interrupted where possible.
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs text-muted-foreground">
|
|
Reason (optional)
|
|
</label>
|
|
<Textarea
|
|
value={treeControlReason}
|
|
onChange={(event) => setTreeControlReason(event.target.value)}
|
|
placeholder="Explain why this subtree control is being applied..."
|
|
className="min-h-[88px]"
|
|
/>
|
|
</div>
|
|
|
|
{(treeControlMode === "resume" || treeControlMode === "restore") ? (
|
|
<div className="space-y-2">
|
|
<label className="flex items-start gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5"
|
|
disabled={previewAffectedAgentCount === 0}
|
|
checked={treeControlWakeAgentsOnResume}
|
|
onChange={(event) => setTreeControlWakeAgentsOnResume(event.target.checked)}
|
|
/>
|
|
<span>
|
|
<span className="block font-medium">Wake affected agents ({previewAffectedAgentCount})</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{previewAffectedAgentCount === 0
|
|
? "No assigned agents are eligible to wake from this preview."
|
|
: "Wake assigned agents after this operation completes."}
|
|
</span>
|
|
</span>
|
|
</label>
|
|
{treeControlWakeAgentsOnResume && treePreviewAffectedAgentRows.length > 0 ? (
|
|
<div className="max-h-32 space-y-1 overflow-y-auto overscroll-contain">
|
|
{treePreviewAffectedAgentRows.map(({ agentId, agent }) => (
|
|
<div key={agentId} className="flex items-center gap-2 rounded-sm px-1 py-1 text-sm hover:bg-accent/50">
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-background">
|
|
<AgentIcon icon={agent?.icon} className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{agent?.name ?? agentId.slice(0, 8)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{treeControlMode === "cancel" ? (
|
|
<label className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-0.5"
|
|
checked={treeControlCancelConfirmed}
|
|
onChange={(event) => setTreeControlCancelConfirmed(event.target.checked)}
|
|
/>
|
|
<span>I understand this will cancel {previewAffectedIssueCount} issues.</span>
|
|
</label>
|
|
) : null}
|
|
|
|
<div className="space-y-2">
|
|
{treeControlPreviewLoading ? (
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-3 w-full" />
|
|
<Skeleton className="h-3 w-4/5" />
|
|
<Skeleton className="h-3 w-2/3" />
|
|
</div>
|
|
) : treeControlPreviewError ? (
|
|
<div className="space-y-2">
|
|
<p className="text-xs text-destructive">{treeControlPreviewErrorCopy(treeControlPreviewError)}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
void refetchTreeControlPreview();
|
|
}}
|
|
>
|
|
Retry preview
|
|
</Button>
|
|
</div>
|
|
) : treeControlPreview ? (
|
|
<div className="space-y-2">
|
|
{treePreviewWarnings.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{treePreviewWarnings.map((warning) => (
|
|
<p key={warning.code} className="text-xs text-amber-700 dark:text-amber-300">
|
|
{warning.message}
|
|
</p>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{treePreviewAffectedIssueRows.length > 0 ? (
|
|
<div className="max-h-56 overflow-y-auto overscroll-contain">
|
|
{treePreviewAffectedIssueRows.map(({ candidate, issue: previewIssue }) => (
|
|
<div key={candidate.id} style={candidate.depth > 0 ? { paddingLeft: `${Math.min(candidate.depth, 6) * 14}px` } : undefined}>
|
|
<Link
|
|
to={createIssueDetailPath(candidate.identifier ?? candidate.id)}
|
|
issuePrefetch={previewIssue}
|
|
className={cn(
|
|
"group flex items-start gap-2 border-b border-border py-2 pl-1 pr-2 text-sm no-underline text-inherit transition-colors last:border-b-0 hover:bg-accent/50 sm:items-center",
|
|
candidate.skipped && "opacity-60",
|
|
)}
|
|
>
|
|
<StatusIcon status={candidate.status} />
|
|
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
|
{candidate.identifier ?? candidate.id.slice(0, 8)}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate">{candidate.title}</span>
|
|
{candidate.skipped && candidate.skipReason === "terminal_status" ? (
|
|
<span className="shrink-0 text-xs text-muted-foreground">Complete</span>
|
|
) : null}
|
|
</Link>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">Preview unavailable.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="border-t border-border/60 bg-background px-6 py-4">
|
|
<Button variant="outline" onClick={() => setTreeControlOpen(false)} disabled={executeTreeControl.isPending}>
|
|
Close
|
|
</Button>
|
|
<Button
|
|
onClick={() => executeTreeControl.mutate()}
|
|
disabled={executeTreeControl.isPending || !canApplyTreeControl}
|
|
variant={treeControlMode === "cancel" ? "destructive" : "default"}
|
|
>
|
|
{executeTreeControl.isPending ? "Applying..." : treeControlPrimaryButtonLabel}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|