mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Task documents keep versioned work beside each conversation. > - The default chat interface could read general documents but hid their editor. > - People also need unfinished edits to survive a tab switch or reload. > - This pull request restores the shared editor in Artifacts. > - A portable browser journey checks exact saved content and revision history. > - The benefit is a usable document flow with independent deployment checks. ## Linked Issues or Issue Description **What happened?** A person using the default task interface could read a general task document but could not create or edit one. The existing editor was available in Classic mode. A pending create could also leave newer text as a new form after its editor unmounted, causing a conflict against the person's own successful creation. **Expected behavior** A person can create and edit general documents through Artifacts. Titles, bodies and revision history persist after reopening and reloading the task. Unfinished text survives tab switches. A successful create makes retained newer text an edit of that confirmed revision. Later writes from another actor still cause a conflict. **Steps to reproduce** 1. Open an ordinary task in the default chat interface, then open Artifacts. 2. Try to create a general document. The previous interface has no authoring control. 3. With authoring restored, submit a new document, keep typing, and switch to Properties before the request finishes. 4. Reopen Artifacts. The previous draft path restores a new form despite the successful create. Related public work: #17598 adds document copy/download and #18157 adds image galleries. The selected actual merged parent is `f1d780838218f975064f38333946805e0ec103a5` from #18427. It includes the stable profile-entry ordering repair and the exact recovery-fixture prerequisite. The resulting PR diff contains 32 document and reporting files. ## What Changed - Reuse `IssueDocumentsSection` for general documents in default-chat Artifacts. Keep Classic authoring in its existing task body. Ordinary tasks, conversations and Coordinator mode share the same editor options and signed-in permission wrapper. - Preserve Plans, file-review documents, attachments, uploads and work products. Hide duplicate general-document cards. Use the same general-document key rules for visible rows, validation and refused writes. - Forward image uploads, mentions, references, author maps and feedback to both editor mounts. Keep empty mention/project values stable while queries load. - Back up unfinished forms within their signed-in account, task and editor scope. Preserve text entered before its key. Record a successful create on its matching retained newer buffer, including after unmount or an early reopen. Preserve newer title/body edits, real remote conflicts and matching cancellation cleanup. - Add the model-free `task-document-persistence` journey. Setup creates only an owned empty backlog task. The browser creates and edits the document; normal APIs independently check company, task, actor, exact content and both revision records. - Reopen the owned task through the explicit Backlog view, then reload it. Require exact UI readback and zero native runs. Add only the Doc registration, definition-hash inputs, report mapping and four closed checks to the shared harness. - Count explicit zero-run browser receipts after private journey metadata is removed for publication. Preserve missing billing evidence, positive usage and native/protocol accounting. - Update browser assertions for the restored inline editor while retaining exact download bytes, revision collisions, paused-state and no-run checks. Update the feature map, usage docs and shared editor stories. ## Verification - Latest focused checks use official Node 24.22.2: 568 journey, catalog, report, billing and attempt-accounting tests across 25 suites pass. All four admitted journeys remain registered. The seven relevant UI suites pass 529 tests, including the task-page container after the parent merge. Maps, token gates, the PR-scope diff check and the owned credential-pattern scan pass. The normal QA import preflight also passes with an empty environment and network access blocked. - Billing regression: before the repair, the explicit empty public receipt counted one run, and two positive-usage receipts were discarded by browser metadata. Those three cases fail before and pass after the repair. Two additional red regressions cover aggregate usage beside an empty run list. That ambiguous shape now keeps an unavailable receipt and a minimum count of one. Missing receipts retain unavailable billing, and native/protocol one-or-more run totals retain their measurements. - The previous fresh CI run failed its unchanged snapshot-only recovery fixture: one global sweep did not call its callback. The original failure remains recorded. An isolated PGlite probe with all canonical migrations exercises the real reconciler: it processes two earlier sources, then reaches the owned third source on the next sweep. It preserves source evidence, files and run IDs. This proves bounded pagination, not full native snapshot repair. - Local native recovery tests could not start PostgreSQL because the host semaphore limit was reached. That run executed zero cases. Canonical Linux CI later passed all 143 fixture cases, including the original snapshot-only case and the new competing-candidate control. The exact fixture repair is now inherited from #18427 and is absent from this PR diff. - The portable oracle rejects 19 stale, missing, foreign or incorrect outcomes. Result validation also rejects model/provider metadata and native runs for this case. - Five real Artifacts-to-Properties unmount regressions fail before the repair and pass after it. They cover both editor modes, reopening before and after completion, later typing, and a foreign revision that must still conflict. The edit-storage failure case also checks that Cancel clears the promoted fallback copy. - UI coverage includes default chat, Classic, conversations, Coordinator, Plan rendering, document-card deduplication, reserved keys, pending-query stability, permissions and rich editor options. - Earlier real browser checks passed five upload cases and two artifact-arrival cases. They retain exact CSV bytes, original/revised content, collision, paused-state and no-run assertions. Current-head normal CI runs the updated browser suites again. - The branch merges only the selected actual parent `f1d780838218f975064f38333946805e0ec103a5`. The merge has no conflicts or manual resolution. The SQL service and recovery fixture match that parent exactly. The own document editor, persistence flow, saved-state oracle and billing repair retain their previously qualified source blobs. Follow-up, clarification, question/resumption, screenshot helpers and the Steering map are byte-identical to the merged parent. Existing project/search/question/follow-up registrations remain present. - Required document source blobs remain exact: Doc UI `22e80663b053363e5f75e1432dd9043dc0e70243`, its unmount tests `16f3f4ed0dc9b11aaff129e06e6cf2bbf4522713`, readiness helper `800a747284db19b8484650b6759e8bd1f356839d`, and budget service `3a15d489f0aa55c22e8f7fe6a6bc6a0e14e5914e`. - Local typing is limited by borrowed declarations from another checkout. They omit current `skill_test` and sandbox creation-operation fields. No dependency declarations or repository config were changed. The authoritative clean CI Typecheck + Release Registry gate passes on exact head `2ffce29827ae170420d740940c1c069c7225a74c`. - Full monorepo typecheck, tests and build were not run locally. Borrowed build declarations from another checkout limit local typing. Authoritative [clean CI](https://github.com/paperclipai/paperclip/actions/runs/41171717008) passes on exact head `82d15946c5844d3ae41c6ade62fa224053f20a9a`: 55 successful checks, 3 skipped and 1 neutral, with zero pending or failed checks. Typecheck, build, Runner, server and browser lanes pass. Greptile gives that same head a fresh 5/5. All 15 review threads are resolved, with no further thread page. The PR is mergeable and clean. - Failure history remains recorded. The earlier recovery failure was repaired with a bounded, owned-source fixture. A later CI worker shut down after 2,375 Runner unit tests passed. Its one authorized narrow retry passed those tests and all five native integration suites. The separate AgentConfig browser failure was reproduced and fixed by the merged #18427 parent. These earlier passes do not replace current-head CI. - Final-source live qualification passed on exact harness and served application `82d15946c5844d3ae41c6ade62fa224053f20a9a`. QA run `41177696858` completed all four closed checks and all five phases with zero native runs. Cleanup passed; the durable manifest is complete with no pending evidence. The coordinator verified the original grade and manifest. - The [public qualification report](https://pages.paperclip.ing/evals-qa-smoke-41177696858-1/) records zero agent runs, empty run IDs and empty run usage. Pricing remains unavailable and billing remains incomplete; the report does not claim measured zero cost. - The earlier harness `36c8d9ddd76d0b7ddb28a9a6c674d9e538f6df88` pass against application `2ffce29827ae170420d740940c1c069c7225a74c` remains historical evidence. It is separate from the final-source measurement above. - The selected parent brings profile-entry ordering, proactive chat, a database migration, Runner updates, and task-page chat/read behavior. Its new task-page container is included in the final application measurement. The coordinator retains the original evidence and owns the primary merge and daily-plan admission. ## Risks Artifacts now mounts the existing shared editor. Its autosave, conflict, revision, annotation and server access contracts remain in place. Tests cover both interface modes, shared scopes, later remote writes and unavailable edit storage. Plans and generated file-review documents keep their existing flows. The PR adds no database schema or API change. The shared billing repair requires explicit captured empty run IDs and an unambiguous usage object containing only an empty run list for public browser-only receipts. Other fields beside an empty list keep conservative unavailable billing. Missing measurements remain unavailable. Contradicting positive usage remains counted. The inherited recovery fixture keeps the production sweep limit and its source, readback and no-agent-work assertions. The live journey must fail when authoring controls or saved outcomes are absent. It must not create the expected document through setup APIs or switch to another interface to pass. Final deployment verification must retain the actual app and harness revisions separately. ## Model Used OpenAI Codex, based on GPT-6, with tool use and code execution. The exact runtime model ID and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1660 lines
72 KiB
TypeScript
1660 lines
72 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
import type {
|
|
Agent,
|
|
DocumentRevision,
|
|
FeedbackDataSharingPreference,
|
|
FeedbackVote,
|
|
FeedbackVoteValue,
|
|
Issue,
|
|
IssueDocument,
|
|
} from "@paperclipai/shared";
|
|
import { isArtifactReviewDocumentKey, isSystemIssueDocumentKey } from "@paperclipai/shared";
|
|
import { useLocation } from "@/lib/router";
|
|
import { ApiError } from "../api/client";
|
|
import { issuesApi } from "../api/issues";
|
|
import { useDurableAutosave } from "../hooks/useDurableAutosave";
|
|
import { describeError } from "../api/errors";
|
|
import { loadStructuredDraft, saveStructuredDraft } from "../lib/composer-draft";
|
|
import { copyTextToClipboard } from "../lib/clipboard";
|
|
import { deriveDocumentRevisionState } from "../lib/document-revisions";
|
|
import { buildDocumentAnnotationHash, parseDocumentAnnotationHash } from "../lib/document-annotation-hash";
|
|
import type { CompanyUserProfile } from "../lib/company-members";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { isGeneralTaskDocumentKey } from "../lib/issue-artifacts";
|
|
import { useDraftOwner } from "../hooks/useDraftOwner";
|
|
import { cn, relativeTime } from "../lib/utils";
|
|
import { FoldCurtain } from "./FoldCurtain";
|
|
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations";
|
|
import type { DocumentAnnotationTarget } from "@/api/document-annotations";
|
|
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
|
|
import { DocumentMarkdownBody } from "./DocumentMarkdownBody";
|
|
import { MarkdownEditor, type MentionOption } from "./MarkdownEditor";
|
|
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Check, Copy, Diff, Download, FilePenLine, FileText, Lock, MoreHorizontal, Plus, Trash2, Unlock, X } from "lucide-react";
|
|
import { DocumentDiffModal } from "./DocumentDiffModal";
|
|
import { DocumentFrameHeader, type DocumentFrameHeaderRevisionActor } from "./DocumentFrameHeader";
|
|
import { SourceTrustBadge } from "./SourceTrustBadge";
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
type DraftState = {
|
|
key: string;
|
|
title: string;
|
|
body: string;
|
|
baseRevisionId: string | null;
|
|
isNew: boolean;
|
|
};
|
|
|
|
type DocumentConflictState = {
|
|
key: string;
|
|
serverDocument: IssueDocument;
|
|
localDraft: DraftState;
|
|
showRemote: boolean;
|
|
};
|
|
|
|
type DocumentSubjectConfig = {
|
|
id: string;
|
|
detailQueryKey?: QueryKey;
|
|
documentsQueryKey: QueryKey;
|
|
idleDocumentRevisionsQueryKey: QueryKey;
|
|
documentRevisionsQueryKey: (key: string) => QueryKey;
|
|
listDocuments: () => Promise<IssueDocument[]>;
|
|
listDocumentRevisions: (key: string) => Promise<DocumentRevision[]>;
|
|
getDocument: (key: string) => Promise<IssueDocument>;
|
|
upsertDocument: (key: string, data: {
|
|
title: string | null;
|
|
format: "markdown";
|
|
body: string;
|
|
baseRevisionId: string | null;
|
|
}) => Promise<IssueDocument>;
|
|
deleteDocument?: (key: string) => Promise<unknown>;
|
|
restoreDocumentRevision?: (key: string, revisionId: string) => Promise<IssueDocument>;
|
|
setDocumentLock?: (key: string, locked: boolean) => Promise<IssueDocument>;
|
|
syncDetailCache?: (queryClient: QueryClient, document: IssueDocument) => void;
|
|
hideSystemDocuments?: boolean;
|
|
legacyPlanDocument?: { body: string } | null;
|
|
annotations?: {
|
|
issueId: string;
|
|
target?: DocumentAnnotationTarget | ((documentKey: string) => DocumentAnnotationTarget);
|
|
} | null;
|
|
};
|
|
|
|
const DOCUMENT_AUTOSAVE_DEBOUNCE_MS = 900;
|
|
const DOCUMENT_KEY_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
const getFoldedDocumentsStorageKey = (issueId: string) => `paperclip:issue-document-folds:${issueId}`;
|
|
|
|
function loadFoldedDocumentKeys(issueId: string) {
|
|
if (typeof window === "undefined") return [];
|
|
try {
|
|
const raw = window.localStorage.getItem(getFoldedDocumentsStorageKey(issueId));
|
|
if (!raw) return [];
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parsed.filter((value): value is string => typeof value === "string") : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveFoldedDocumentKeys(issueId: string, keys: string[]) {
|
|
if (typeof window === "undefined") return;
|
|
window.localStorage.setItem(getFoldedDocumentsStorageKey(issueId), JSON.stringify(keys));
|
|
}
|
|
|
|
function renderFoldableBody(
|
|
body: string,
|
|
className?: string,
|
|
externalReferences?: MarkdownExternalReferenceMap,
|
|
galleryTarget?: { documentKey: string; revisionId?: string; currentIssueId?: string },
|
|
) {
|
|
return (
|
|
<FoldCurtain>
|
|
{galleryTarget ? (
|
|
<DocumentMarkdownBody {...galleryTarget} className={className} softBreaks={false} externalReferences={externalReferences}>
|
|
{body}
|
|
</DocumentMarkdownBody>
|
|
) : (
|
|
<MarkdownBody className={className} softBreaks={false} externalReferences={externalReferences}>
|
|
{body}
|
|
</MarkdownBody>
|
|
)}
|
|
</FoldCurtain>
|
|
);
|
|
}
|
|
|
|
function isPlanKey(key: string) {
|
|
return key.trim().toLowerCase() === "plan";
|
|
}
|
|
|
|
function generalDocumentKeyError(key: string): string | null {
|
|
if (isGeneralTaskDocumentKey(key)) return null;
|
|
if (isPlanKey(key)) return "Create and revise Plans in the task conversation.";
|
|
if (isArtifactReviewDocumentKey(key)) return "Review this document through its work product.";
|
|
return "This document key is reserved for task state.";
|
|
}
|
|
|
|
function titlesMatchKey(title: string | null | undefined, key: string) {
|
|
return (title ?? "").trim().toLowerCase() === key.trim().toLowerCase();
|
|
}
|
|
|
|
function isDocumentConflictError(error: unknown) {
|
|
return error instanceof ApiError && error.status === 409;
|
|
}
|
|
|
|
function isLockedDocumentError(error: unknown) {
|
|
return error instanceof ApiError && error.status === 409 && error.message === "Document is locked";
|
|
}
|
|
|
|
/**
|
|
* Unsaved document edits live here until the server confirms them. The scope
|
|
* names the signed-in user as well as the document, so another account in the
|
|
* same browser never restores (or autosaves) someone else's unpublished text.
|
|
* A null scope (the session is still loading) stores and restores nothing.
|
|
*/
|
|
function documentDraftStorageKey(scope: string, key: string) {
|
|
return `paperclip:document-draft:v2:${scope}:${key}`;
|
|
}
|
|
|
|
function loadStoredDocumentDraft(scope: string | null, key: string): DraftState | null {
|
|
if (!scope) return null;
|
|
const stored = loadStructuredDraft<Partial<DraftState> | null>(documentDraftStorageKey(scope, key), null);
|
|
if (
|
|
!stored ||
|
|
stored.key !== key ||
|
|
typeof stored.body !== "string" ||
|
|
typeof stored.title !== "string" ||
|
|
(stored.baseRevisionId !== null && typeof stored.baseRevisionId !== "string")
|
|
) return null;
|
|
return { key, title: stored.title, body: stored.body, baseRevisionId: stored.baseRevisionId ?? null, isNew: false };
|
|
}
|
|
|
|
function clearStoredDocumentDraft(scope: string | null, key: string) {
|
|
if (!scope) return;
|
|
try {
|
|
localStorage.removeItem(documentDraftStorageKey(scope, key));
|
|
} catch {
|
|
// Unavailable storage holds nothing to clear.
|
|
}
|
|
}
|
|
|
|
function newDocumentDraftStorageKey(scope: string) {
|
|
return `paperclip:new-document-draft:v1:${scope}`;
|
|
}
|
|
|
|
function loadStoredNewDocumentDraft(scope: string | null): DraftState | null {
|
|
if (!scope) return null;
|
|
const stored = loadStructuredDraft<Partial<DraftState> | null>(newDocumentDraftStorageKey(scope), null);
|
|
// Creation may finish after this pane unmounts. Its retained newer buffer
|
|
// then carries the confirmed revision and reopens as an edit.
|
|
if (!stored || typeof stored.isNew !== "boolean" || typeof stored.key !== "string" || typeof stored.title !== "string"
|
|
|| typeof stored.body !== "string" || (stored.isNew ? stored.baseRevisionId !== null : typeof stored.baseRevisionId !== "string")) return null;
|
|
return { key: stored.key, title: stored.title, body: stored.body, baseRevisionId: stored.baseRevisionId ?? null, isNew: stored.isNew };
|
|
}
|
|
|
|
function clearStoredNewDocumentDraft(scope: string | null, saved?: DraftState) {
|
|
if (!scope) return;
|
|
const stored = loadStoredNewDocumentDraft(scope);
|
|
// A save may finish after more text was typed or after the pane unmounted.
|
|
if (saved && stored && (stored.key !== saved.key || stored.title !== saved.title || stored.body !== saved.body)) return;
|
|
try { localStorage.removeItem(newDocumentDraftStorageKey(scope)); } catch { /* Storage may be unavailable. */ }
|
|
}
|
|
|
|
function downloadDocumentFile(key: string, body: string) {
|
|
const blob = new Blob([body], { type: "text/markdown;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = `${key}.md`;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function getRevisionActor(
|
|
revision: DocumentRevision,
|
|
maps: {
|
|
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
|
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
|
},
|
|
): DocumentFrameHeaderRevisionActor {
|
|
if (revision.createdByAgentId) {
|
|
const agent = maps.agentMap?.get(revision.createdByAgentId);
|
|
return {
|
|
kind: "agent",
|
|
name: agent?.name ?? revision.createdByAgentId.slice(0, 8),
|
|
agentIcon: agent?.icon ?? null,
|
|
agent: agent ?? { id: revision.createdByAgentId },
|
|
};
|
|
}
|
|
if (revision.createdByUserId) {
|
|
const profile = maps.userProfileMap?.get(revision.createdByUserId);
|
|
return {
|
|
kind: "user",
|
|
name: profile?.label ?? (revision.createdByUserId === "local-board" ? "Board" : revision.createdByUserId.slice(0, 8)),
|
|
imageUrl: profile?.image ?? null,
|
|
};
|
|
}
|
|
return { kind: "system", name: "System" };
|
|
}
|
|
|
|
function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null) {
|
|
if (!draft || draft.isNew || draft.key !== doc.key) return false;
|
|
return draft.body !== doc.body || (doc.title ?? "") !== draft.title;
|
|
}
|
|
|
|
function toDocumentSummary(document: IssueDocument) {
|
|
return {
|
|
id: document.id,
|
|
companyId: document.companyId,
|
|
issueId: document.issueId,
|
|
key: document.key,
|
|
title: document.title,
|
|
format: document.format,
|
|
latestRevisionId: document.latestRevisionId,
|
|
latestRevisionNumber: document.latestRevisionNumber,
|
|
createdByAgentId: document.createdByAgentId,
|
|
createdByUserId: document.createdByUserId,
|
|
updatedByAgentId: document.updatedByAgentId,
|
|
updatedByUserId: document.updatedByUserId,
|
|
lockedAt: document.lockedAt,
|
|
lockedByAgentId: document.lockedByAgentId,
|
|
lockedByUserId: document.lockedByUserId,
|
|
sourceTrust: document.sourceTrust,
|
|
createdAt: document.createdAt,
|
|
updatedAt: document.updatedAt,
|
|
};
|
|
}
|
|
|
|
function makeIssueDocumentSubject(issue: Issue): DocumentSubjectConfig {
|
|
return {
|
|
id: issue.id,
|
|
detailQueryKey: queryKeys.issues.detail(issue.id),
|
|
documentsQueryKey: queryKeys.issues.documents(issue.id),
|
|
idleDocumentRevisionsQueryKey: ["issues", "document-revisions", issue.id, "__idle__"],
|
|
documentRevisionsQueryKey: (key) => queryKeys.issues.documentRevisions(issue.id, key),
|
|
listDocuments: () => issuesApi.listDocuments(issue.id),
|
|
listDocumentRevisions: (key) => issuesApi.listDocumentRevisions(issue.id, key),
|
|
getDocument: (key) => issuesApi.getDocument(issue.id, key),
|
|
upsertDocument: (key, data) => issuesApi.upsertDocument(issue.id, key, data),
|
|
deleteDocument: (key) => issuesApi.deleteDocument(issue.id, key),
|
|
restoreDocumentRevision: (key, revisionId) => issuesApi.restoreDocumentRevision(issue.id, key, revisionId),
|
|
setDocumentLock: (key, locked) =>
|
|
locked ? issuesApi.lockDocument(issue.id, key) : issuesApi.unlockDocument(issue.id, key),
|
|
syncDetailCache: (queryClient, document) => {
|
|
queryClient.setQueryData<Issue | undefined>(
|
|
queryKeys.issues.detail(issue.id),
|
|
(current) => {
|
|
if (!current) return current;
|
|
const nextSummaries = (() => {
|
|
const summary = toDocumentSummary(document);
|
|
const existingIndex = (current.documentSummaries ?? []).findIndex((entry) => entry.key === document.key);
|
|
if (existingIndex === -1) return [...(current.documentSummaries ?? []), summary];
|
|
return (current.documentSummaries ?? []).map((entry, index) => index === existingIndex ? summary : entry);
|
|
})();
|
|
return {
|
|
...current,
|
|
planDocument: document.key === "plan" ? document : current.planDocument ?? null,
|
|
documentSummaries: nextSummaries,
|
|
legacyPlanDocument: document.key === "plan" ? null : current.legacyPlanDocument ?? null,
|
|
};
|
|
},
|
|
);
|
|
},
|
|
hideSystemDocuments: true,
|
|
legacyPlanDocument: issue.legacyPlanDocument,
|
|
annotations: { issueId: issue.id },
|
|
};
|
|
}
|
|
|
|
function AccountIssueDocumentsSection({
|
|
issue,
|
|
subject,
|
|
canDeleteDocuments,
|
|
canManageDocumentLocks = false,
|
|
generalDocumentsOnly = false,
|
|
feedbackVotes = [],
|
|
feedbackDataSharingPreference = "prompt",
|
|
feedbackTermsUrl = null,
|
|
mentions,
|
|
imageUploadHandler,
|
|
onVote,
|
|
extraActions,
|
|
agentMap,
|
|
userProfileMap,
|
|
defaultAnnotationPanelOpenKeys,
|
|
defaultAnnotationFocusedThreadIds,
|
|
forceEditDocumentKey,
|
|
externalReferences,
|
|
}: {
|
|
issue?: Issue;
|
|
subject?: DocumentSubjectConfig;
|
|
canDeleteDocuments: boolean;
|
|
canManageDocumentLocks?: boolean;
|
|
/** The chat shell keeps Plans in the conversation and file reviews on their work products. */
|
|
generalDocumentsOnly?: boolean;
|
|
feedbackVotes?: FeedbackVote[];
|
|
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
|
|
feedbackTermsUrl?: string | null;
|
|
mentions?: MentionOption[];
|
|
imageUploadHandler?: (file: File) => Promise<string>;
|
|
onVote?: (
|
|
revisionId: string,
|
|
vote: FeedbackVoteValue,
|
|
options?: { allowSharing?: boolean; reason?: string },
|
|
) => Promise<void>;
|
|
extraActions?: ReactNode;
|
|
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
|
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
|
/**
|
|
* Seed which document annotation panels are open on first render. Mostly useful
|
|
* for Storybook / screenshot harnesses; runtime callers usually omit this.
|
|
*/
|
|
defaultAnnotationPanelOpenKeys?: string[];
|
|
/** Per-doc seed for the focused annotation thread id (Storybook-only). */
|
|
defaultAnnotationFocusedThreadIds?: Readonly<Record<string, string>>;
|
|
/** Force a doc into edit mode on mount (Storybook-only). */
|
|
forceEditDocumentKey?: string | null;
|
|
externalReferences?: MarkdownExternalReferenceMap;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const location = useLocation();
|
|
const documentSubject = useMemo(() => {
|
|
if (subject) return subject;
|
|
if (!issue) throw new Error("IssueDocumentsSection requires either issue or subject");
|
|
return makeIssueDocumentSubject(issue);
|
|
}, [issue, subject]);
|
|
const annotationTargetForKey = useCallback((documentKey: string) => {
|
|
const configured = documentSubject.annotations?.target;
|
|
if (!configured) return undefined;
|
|
if (typeof configured === "function") return configured(documentKey);
|
|
if (configured.kind === "issue") return { ...configured, documentKey };
|
|
if (configured.kind === "case") return { ...configured, documentKey };
|
|
return configured;
|
|
}, [documentSubject]);
|
|
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [draft, setDraft] = useState<DraftState | null>(null);
|
|
const [documentConflict, setDocumentConflict] = useState<DocumentConflictState | null>(null);
|
|
const draftOwner = useDraftOwner();
|
|
const draftScope = draftOwner !== undefined ? `${draftOwner}:${documentSubject.id}` : null;
|
|
const newDraftScope = draftScope ? `${draftScope}:${generalDocumentsOnly ? "general" : "all"}` : null;
|
|
const [foldedDocumentKeys, setFoldedDocumentKeys] = useState<string[]>(() => loadFoldedDocumentKeys(documentSubject.id));
|
|
const [annotationPanelOpenKeys, setAnnotationPanelOpenKeys] = useState<string[]>(
|
|
() => (defaultAnnotationPanelOpenKeys ?? []),
|
|
);
|
|
const [autosaveDocumentKey, setAutosaveDocumentKey] = useState<string | null>(null);
|
|
const [copiedDocumentKey, setCopiedDocumentKey] = useState<string | null>(null);
|
|
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
|
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
|
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
|
const [handledRevisionHash, setHandledRevisionHash] = useState<{ target: string; latestRevisionId: string | null } | null>(null);
|
|
const documentHashTarget = useMemo(() => parseDocumentAnnotationHash(location.hash), [location.hash]);
|
|
const revisionTarget = documentHashTarget?.revisionId
|
|
? buildDocumentAnnotationHash({ ...documentHashTarget, image: undefined }) : null;
|
|
const revisionsQueryDocumentKey = revisionMenuOpenKey ?? (documentHashTarget?.revisionId ? documentHashTarget.documentKey : null);
|
|
const [diffViewKey, setDiffViewKey] = useState<string | null>(null);
|
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const hasScrolledToHashRef = useRef(false);
|
|
// Each save binds its own request and 409 check (see commitDraft).
|
|
const autosave = useDurableAutosave<DraftState>({
|
|
sourceId: `issue-documents:${documentSubject.id}`,
|
|
targetKey: `${draftScope}:${draft?.key ?? ""}`,
|
|
save: async () => undefined,
|
|
action: "save the document",
|
|
});
|
|
const { markDirty, reset, run: runAutosave } = autosave;
|
|
const autosaveState = autosave.status;
|
|
const autosaveStateRef = useRef(autosaveState);
|
|
autosaveStateRef.current = autosaveState;
|
|
|
|
const { data: documents } = useQuery({
|
|
queryKey: documentSubject.documentsQueryKey,
|
|
queryFn: documentSubject.listDocuments,
|
|
});
|
|
|
|
const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions, isError: revisionsQueryFailed } = useQuery({
|
|
queryKey: revisionsQueryDocumentKey
|
|
? documentSubject.documentRevisionsQueryKey(revisionsQueryDocumentKey)
|
|
: documentSubject.idleDocumentRevisionsQueryKey,
|
|
queryFn: async () => {
|
|
if (!revisionsQueryDocumentKey) return [];
|
|
return documentSubject.listDocumentRevisions(revisionsQueryDocumentKey);
|
|
},
|
|
enabled: Boolean(revisionsQueryDocumentKey),
|
|
});
|
|
|
|
const invalidateIssueDocuments = useCallback(() => {
|
|
if (documentSubject.detailQueryKey) {
|
|
queryClient.invalidateQueries({ queryKey: documentSubject.detailQueryKey });
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: documentSubject.documentsQueryKey });
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) =>
|
|
Array.isArray(query.queryKey)
|
|
&& query.queryKey.includes(documentSubject.id)
|
|
&& (
|
|
query.queryKey.includes("document-revisions")
|
|
|| query.queryKey.includes("document-annotations")
|
|
|| query.queryKey.includes("revisions")
|
|
),
|
|
});
|
|
}, [documentSubject, queryClient]);
|
|
|
|
const syncDocumentCaches = useCallback((document: IssueDocument) => {
|
|
if (documentSubject.hideSystemDocuments && isSystemIssueDocumentKey(document.key)) return;
|
|
queryClient.setQueryData<IssueDocument[] | undefined>(
|
|
documentSubject.documentsQueryKey,
|
|
(current) => {
|
|
if (!current) return [document];
|
|
const existingIndex = current.findIndex((entry) => entry.key === document.key);
|
|
if (existingIndex === -1) return [...current, document];
|
|
return current.map((entry, index) => index === existingIndex ? document : entry);
|
|
},
|
|
);
|
|
documentSubject.syncDetailCache?.(queryClient, document);
|
|
}, [documentSubject, queryClient]);
|
|
|
|
const upsertDocument = useMutation({
|
|
mutationFn: async (nextDraft: DraftState) =>
|
|
documentSubject.upsertDocument(nextDraft.key, {
|
|
title: isPlanKey(nextDraft.key) ? null : nextDraft.title.trim() || null,
|
|
format: "markdown",
|
|
body: nextDraft.body,
|
|
baseRevisionId: nextDraft.baseRevisionId,
|
|
}),
|
|
});
|
|
|
|
const deleteDocument = useMutation({
|
|
mutationFn: (key: string) => documentSubject.deleteDocument
|
|
? documentSubject.deleteDocument(key)
|
|
: Promise.reject(new Error("Document deletion is not available")),
|
|
onSuccess: () => {
|
|
setError(null);
|
|
setConfirmDeleteKey(null);
|
|
invalidateIssueDocuments();
|
|
},
|
|
onError: (err) => {
|
|
setError(err instanceof Error ? err.message : "Failed to delete document");
|
|
},
|
|
});
|
|
|
|
const restoreDocumentRevision = useMutation({
|
|
mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) =>
|
|
documentSubject.restoreDocumentRevision
|
|
? documentSubject.restoreDocumentRevision(key, revisionId)
|
|
: Promise.reject(new Error("Document revision restore is not available")),
|
|
onSuccess: (document, variables) => {
|
|
syncDocumentCaches(document);
|
|
setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null }));
|
|
setDraft((current) => current?.key === variables.key ? null : current);
|
|
setDocumentConflict((current) => current?.key === variables.key ? null : current);
|
|
resetAutosaveState();
|
|
setError(null);
|
|
invalidateIssueDocuments();
|
|
},
|
|
onError: (err) => {
|
|
setError(err instanceof Error ? err.message : "Failed to restore document revision");
|
|
},
|
|
});
|
|
|
|
const setDocumentLock = useMutation({
|
|
mutationFn: ({ key, locked }: { key: string; locked: boolean }) =>
|
|
documentSubject.setDocumentLock
|
|
? documentSubject.setDocumentLock(key, locked)
|
|
: Promise.reject(new Error("Document locking is not available")),
|
|
onSuccess: (document) => {
|
|
syncDocumentCaches(document);
|
|
setDraft((current) => current?.key === document.key ? null : current);
|
|
setDocumentConflict((current) => current?.key === document.key ? null : current);
|
|
resetAutosaveState();
|
|
setError(null);
|
|
invalidateIssueDocuments();
|
|
},
|
|
onError: (err) => {
|
|
setError(err instanceof Error ? err.message : "Failed to update document lock");
|
|
},
|
|
});
|
|
|
|
const sortedDocuments = useMemo(() => {
|
|
return (documents ?? []).filter((doc) =>
|
|
(!generalDocumentsOnly || isGeneralTaskDocumentKey(doc.key))
|
|
&& (!documentSubject.hideSystemDocuments || !isSystemIssueDocumentKey(doc.key)),
|
|
).sort((a, b) => {
|
|
if (a.key === "plan" && b.key !== "plan") return -1;
|
|
if (a.key !== "plan" && b.key === "plan") return 1;
|
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
});
|
|
}, [documentSubject.hideSystemDocuments, documents, generalDocumentsOnly]);
|
|
|
|
const feedbackVoteByTargetId = useMemo(() => {
|
|
const map = new Map<string, FeedbackVoteValue>();
|
|
for (const feedbackVote of feedbackVotes) {
|
|
if (feedbackVote.targetType !== "issue_document_revision") continue;
|
|
map.set(feedbackVote.targetId, feedbackVote.vote);
|
|
}
|
|
return map;
|
|
}, [feedbackVotes]);
|
|
|
|
const hasRealPlan = sortedDocuments.some((doc) => doc.key === "plan");
|
|
const legacyPlanDocument = generalDocumentsOnly ? null : documentSubject.legacyPlanDocument;
|
|
const isEmpty = sortedDocuments.length === 0 && !legacyPlanDocument;
|
|
const newGeneralDocumentKeyError = draft?.isNew && generalDocumentsOnly ? generalDocumentKeyError(draft.key) : null;
|
|
const newDocumentKeyError = newGeneralDocumentKeyError
|
|
?? (draft?.isNew && draft.key.trim().length > 0 && !DOCUMENT_KEY_PATTERN.test(draft.key.trim())
|
|
? "Use lowercase letters, numbers, -, or _, and start with a letter or number."
|
|
: null);
|
|
|
|
const resetAutosaveState = useCallback(() => {
|
|
setAutosaveDocumentKey(null);
|
|
reset();
|
|
}, [reset]);
|
|
|
|
const markDocumentDirty = useCallback((key: string) => {
|
|
setAutosaveDocumentKey(key);
|
|
markDirty();
|
|
}, [markDirty]);
|
|
|
|
const beginNewDocument = () => {
|
|
resetAutosaveState();
|
|
setDocumentConflict(null);
|
|
setDraft(loadStoredNewDocumentDraft(newDraftScope) ?? {
|
|
key: "",
|
|
title: "",
|
|
body: "",
|
|
baseRevisionId: null,
|
|
isNew: true,
|
|
});
|
|
setError(null);
|
|
};
|
|
|
|
const beginEdit = (key: string) => {
|
|
const doc = sortedDocuments.find((entry) => entry.key === key);
|
|
if (!doc) return;
|
|
const storedDraft = loadStoredDocumentDraft(draftScope, key);
|
|
const conflictedDraft = documentConflict?.key === key
|
|
? documentConflict.localDraft
|
|
: storedDraft && (storedDraft.body !== doc.body || storedDraft.title !== (doc.title ?? ""))
|
|
? storedDraft
|
|
: null;
|
|
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
|
resetAutosaveState();
|
|
setDocumentConflict((current) => current?.key === key ? current : null);
|
|
setDraft({
|
|
key: conflictedDraft?.key ?? doc.key,
|
|
title: conflictedDraft?.title ?? doc.title ?? "",
|
|
body: conflictedDraft?.body ?? doc.body,
|
|
baseRevisionId: conflictedDraft?.baseRevisionId ?? doc.latestRevisionId,
|
|
isNew: false,
|
|
});
|
|
setError(null);
|
|
};
|
|
|
|
const initialEditAppliedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!forceEditDocumentKey) return;
|
|
if (initialEditAppliedRef.current) return;
|
|
const target = (documents ?? []).find((entry) => entry.key === forceEditDocumentKey);
|
|
if (!target) return;
|
|
initialEditAppliedRef.current = true;
|
|
beginEdit(forceEditDocumentKey);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [forceEditDocumentKey, documents]);
|
|
|
|
const cancelDraft = () => {
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
if (draft && !draft.isNew) {
|
|
clearStoredDocumentDraft(draftScope, draft.key);
|
|
clearStoredNewDocumentDraft(newDraftScope, draft);
|
|
}
|
|
if (draft?.isNew) clearStoredNewDocumentDraft(newDraftScope);
|
|
resetAutosaveState();
|
|
setDocumentConflict(null);
|
|
setDraft(null);
|
|
setError(null);
|
|
};
|
|
|
|
const commitDraft = useCallback(async (
|
|
currentDraft: DraftState | null,
|
|
options?: { clearAfterSave?: boolean; trackAutosave?: boolean; overrideConflict?: boolean },
|
|
) => {
|
|
if (!currentDraft || upsertDocument.isPending) return false;
|
|
const normalizedKey = currentDraft.key.trim().toLowerCase();
|
|
const normalizedBody = currentDraft.body.trim();
|
|
const normalizedTitle = currentDraft.title.trim();
|
|
const activeConflict = documentConflict?.key === normalizedKey ? documentConflict : null;
|
|
|
|
const generalKeyError = generalDocumentsOnly ? generalDocumentKeyError(normalizedKey) : null;
|
|
if (generalKeyError) {
|
|
setError(generalKeyError);
|
|
return false;
|
|
}
|
|
|
|
if (activeConflict && !options?.overrideConflict) {
|
|
if (options?.trackAutosave) {
|
|
resetAutosaveState();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (!normalizedKey || !normalizedBody) {
|
|
if (currentDraft.isNew) {
|
|
setError("Document key and body are required");
|
|
} else if (!normalizedBody) {
|
|
setError("Document body cannot be empty");
|
|
}
|
|
if (options?.trackAutosave) {
|
|
resetAutosaveState();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
if (!DOCUMENT_KEY_PATTERN.test(normalizedKey)) {
|
|
setError("Document key must start with a letter or number and use only lowercase letters, numbers, -, or _.");
|
|
if (options?.trackAutosave) {
|
|
resetAutosaveState();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const existing = sortedDocuments.find((doc) => doc.key === normalizedKey);
|
|
if (
|
|
!currentDraft.isNew &&
|
|
existing &&
|
|
existing.body === currentDraft.body &&
|
|
(existing.title ?? "") === currentDraft.title
|
|
) {
|
|
if (options?.clearAfterSave) {
|
|
setDraft((value) => (value?.key === normalizedKey ? null : value));
|
|
}
|
|
if (options?.trackAutosave) {
|
|
resetAutosaveState();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const attempted: DraftState = {
|
|
...currentDraft,
|
|
key: normalizedKey,
|
|
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
|
body: currentDraft.body,
|
|
baseRevisionId: options?.overrideConflict
|
|
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
|
|
: currentDraft.baseRevisionId,
|
|
};
|
|
const applySaved = (saved: IssueDocument) => {
|
|
clearStoredDocumentDraft(draftScope, normalizedKey);
|
|
clearStoredNewDocumentDraft(newDraftScope, currentDraft);
|
|
const retained = currentDraft.isNew ? loadStoredNewDocumentDraft(newDraftScope) : null;
|
|
if (newDraftScope && retained?.isNew && retained.key.trim().toLowerCase() === normalizedKey && saved.latestRevisionId) {
|
|
// Keep later typing, but bind it to our successful creation even if
|
|
// its editor has already unmounted. Other forms/scopes stay untouched.
|
|
saveStructuredDraft(newDocumentDraftStorageKey(newDraftScope), {
|
|
...retained,
|
|
key: saved.key,
|
|
baseRevisionId: saved.latestRevisionId,
|
|
isNew: false,
|
|
});
|
|
}
|
|
setError(null);
|
|
setDocumentConflict((current) => current?.key === normalizedKey ? null : current);
|
|
setDraft((value) => {
|
|
if (!value || value.key !== normalizedKey) return value;
|
|
if (options?.clearAfterSave) return null;
|
|
return {
|
|
key: saved.key,
|
|
// Keep title and body edits made while this save was in flight.
|
|
title: value.title === currentDraft.title ? saved.title ?? "" : value.title,
|
|
body: value.body === currentDraft.body ? saved.body : value.body,
|
|
baseRevisionId: saved.latestRevisionId,
|
|
isNew: false,
|
|
};
|
|
});
|
|
syncDocumentCaches(saved);
|
|
invalidateIssueDocuments();
|
|
};
|
|
const save = async () => {
|
|
applySaved(await upsertDocument.mutateAsync(attempted));
|
|
};
|
|
// A retry after a lost response finds its own earlier save: the base
|
|
// revision is stale (or, for a new document, the key now exists), but the
|
|
// server already holds exactly this text.
|
|
const matchesServer = async (error: unknown) => {
|
|
if (isLockedDocumentError(error)) return false;
|
|
const latest = await documentSubject.getDocument(normalizedKey);
|
|
if (latest.body !== currentDraft.body) return false;
|
|
if (!isPlanKey(normalizedKey) && (latest.title ?? "") !== normalizedTitle) return false;
|
|
applySaved(latest);
|
|
return true;
|
|
};
|
|
|
|
const handleSaveFailure = async (err: unknown): Promise<false> => {
|
|
if (isLockedDocumentError(err)) {
|
|
setError("Document is locked. Unlock it before editing.");
|
|
resetAutosaveState();
|
|
invalidateIssueDocuments();
|
|
return false;
|
|
}
|
|
if (isDocumentConflictError(err)) {
|
|
try {
|
|
const latestDocument = await documentSubject.getDocument(normalizedKey);
|
|
setDocumentConflict({
|
|
key: normalizedKey,
|
|
serverDocument: latestDocument,
|
|
localDraft: {
|
|
key: normalizedKey,
|
|
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
|
|
body: currentDraft.body,
|
|
baseRevisionId: currentDraft.baseRevisionId,
|
|
isNew: false,
|
|
},
|
|
showRemote: true,
|
|
});
|
|
setFoldedDocumentKeys((current) => current.filter((key) => key !== normalizedKey));
|
|
setError(null);
|
|
resetAutosaveState();
|
|
return false;
|
|
} catch {
|
|
setError("Document changed remotely and the latest version could not be loaded");
|
|
return false;
|
|
}
|
|
}
|
|
setError(describeError(err, { action: "save the document" }).body);
|
|
return false;
|
|
};
|
|
|
|
if (options?.trackAutosave) setAutosaveDocumentKey(normalizedKey);
|
|
const result = await runAutosave(attempted, {
|
|
save,
|
|
matchesServer,
|
|
// A retry after an outage reports a lock or a conflict the same way.
|
|
onRetryFailed: (err) => void handleSaveFailure(err),
|
|
});
|
|
if (result.kind === "saved") return true;
|
|
// Kept on this device; it saves when the connection allows.
|
|
if (result.kind === "waiting") return false;
|
|
return handleSaveFailure(result.error);
|
|
}, [documentConflict, documentSubject, draftScope, generalDocumentsOnly, invalidateIssueDocuments, newDraftScope, resetAutosaveState, runAutosave, sortedDocuments, syncDocumentCaches, upsertDocument]);
|
|
|
|
const reloadDocumentFromServer = useCallback((key: string) => {
|
|
if (documentConflict?.key !== key) return;
|
|
const serverDocument = documentConflict.serverDocument;
|
|
clearStoredDocumentDraft(draftScope, key);
|
|
setDraft({
|
|
key: serverDocument.key,
|
|
title: serverDocument.title ?? "",
|
|
body: serverDocument.body,
|
|
baseRevisionId: serverDocument.latestRevisionId,
|
|
isNew: false,
|
|
});
|
|
setDocumentConflict(null);
|
|
resetAutosaveState();
|
|
setError(null);
|
|
}, [documentConflict, draftScope, resetAutosaveState]);
|
|
|
|
const overwriteDocumentFromDraft = useCallback(async (key: string) => {
|
|
if (documentConflict?.key !== key) return;
|
|
const sourceDraft =
|
|
draft && draft.key === key && !draft.isNew
|
|
? draft
|
|
: documentConflict.localDraft;
|
|
await commitDraft(
|
|
{
|
|
...sourceDraft,
|
|
baseRevisionId: documentConflict.serverDocument.latestRevisionId,
|
|
},
|
|
{
|
|
clearAfterSave: false,
|
|
trackAutosave: true,
|
|
overrideConflict: true,
|
|
},
|
|
);
|
|
}, [commitDraft, documentConflict, draft]);
|
|
|
|
const keepConflictedDraft = useCallback((key: string) => {
|
|
if (documentConflict?.key !== key) return;
|
|
setDraft(documentConflict.localDraft);
|
|
setDocumentConflict((current) =>
|
|
current?.key === key
|
|
? { ...current, showRemote: false }
|
|
: current,
|
|
);
|
|
setError(null);
|
|
}, [documentConflict]);
|
|
|
|
const copyDocumentBody = useCallback(async (key: string, body: string) => {
|
|
try {
|
|
await copyTextToClipboard(body);
|
|
setCopiedDocumentKey(key);
|
|
if (copiedDocumentTimerRef.current) {
|
|
clearTimeout(copiedDocumentTimerRef.current);
|
|
}
|
|
copiedDocumentTimerRef.current = setTimeout(() => {
|
|
setCopiedDocumentKey((current) => current === key ? null : current);
|
|
}, 1400);
|
|
} catch {
|
|
setError("Could not copy document");
|
|
}
|
|
}, []);
|
|
|
|
const getDocumentRevisions = useCallback((key: string) => {
|
|
const cached = queryClient.getQueryData<DocumentRevision[]>(documentSubject.documentRevisionsQueryKey(key));
|
|
if (cached) return cached;
|
|
if (revisionsQueryDocumentKey === key) return activeDocumentRevisions ?? [];
|
|
return [];
|
|
}, [activeDocumentRevisions, documentSubject, queryClient, revisionsQueryDocumentKey]);
|
|
|
|
const returnToLatestRevision = useCallback((key: string) => {
|
|
setSelectedRevisionIds((current) => ({ ...current, [key]: null }));
|
|
setError(null);
|
|
}, []);
|
|
|
|
const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => {
|
|
const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key));
|
|
const selectedRevision = revisionState.revisions.find((revision) => revision.id === revisionId);
|
|
if (!selectedRevision) return false;
|
|
if (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft)) {
|
|
setError("Save or cancel your local changes before viewing an older revision.");
|
|
return false;
|
|
}
|
|
if (selectedRevision.id === revisionState.currentRevision.id) {
|
|
returnToLatestRevision(doc.key);
|
|
return true;
|
|
}
|
|
resetAutosaveState();
|
|
setDraft((current) => current?.key === doc.key ? null : current);
|
|
setDocumentConflict((current) => current?.key === doc.key ? null : current);
|
|
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== doc.key));
|
|
setSelectedRevisionIds((current) => ({ ...current, [doc.key]: selectedRevision.id }));
|
|
setError(null);
|
|
return true;
|
|
}, [documentConflict, draft, getDocumentRevisions, resetAutosaveState, returnToLatestRevision]);
|
|
|
|
const toggleDocumentLock = useCallback((doc: IssueDocument, locked: boolean) => {
|
|
if (!canManageDocumentLocks || setDocumentLock.isPending) return;
|
|
if (locked && (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft))) {
|
|
setError("Save or cancel local changes before changing the document lock.");
|
|
return;
|
|
}
|
|
setDocumentLock.mutate({ key: doc.key, locked });
|
|
}, [canManageDocumentLocks, documentConflict, draft, setDocumentLock]);
|
|
|
|
const handleDraftBlur = async (event: React.FocusEvent<HTMLDivElement>) => {
|
|
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
await commitDraft(draft, { clearAfterSave: true, trackAutosave: true });
|
|
};
|
|
|
|
const handleDraftKeyDown = async (event: React.KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
cancelDraft();
|
|
return;
|
|
}
|
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
event.preventDefault();
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
await commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
setFoldedDocumentKeys(loadFoldedDocumentKeys(documentSubject.id));
|
|
setHandledRevisionHash(null);
|
|
setSelectedRevisionIds({});
|
|
}, [documentSubject.id]);
|
|
|
|
useEffect(() => {
|
|
hasScrolledToHashRef.current = false;
|
|
}, [documentSubject.id, location.hash]);
|
|
|
|
useEffect(() => {
|
|
const validKeys = new Set(sortedDocuments.map((doc) => doc.key));
|
|
setFoldedDocumentKeys((current) => {
|
|
const next = current.filter((key) => validKeys.has(key));
|
|
if (next.length !== current.length) {
|
|
saveFoldedDocumentKeys(documentSubject.id, next);
|
|
}
|
|
return next;
|
|
});
|
|
}, [documentSubject.id, sortedDocuments]);
|
|
|
|
useEffect(() => {
|
|
saveFoldedDocumentKeys(documentSubject.id, foldedDocumentKeys);
|
|
}, [documentSubject.id, foldedDocumentKeys]);
|
|
|
|
useEffect(() => {
|
|
if (!documentConflict) return;
|
|
const latest = sortedDocuments.find((doc) => doc.key === documentConflict.key);
|
|
if (!latest || latest.latestRevisionId === documentConflict.serverDocument.latestRevisionId) return;
|
|
setDocumentConflict((current) =>
|
|
current?.key === latest.key
|
|
? { ...current, serverDocument: latest }
|
|
: current,
|
|
);
|
|
}, [documentConflict, sortedDocuments]);
|
|
|
|
useEffect(() => {
|
|
const documentKey = documentHashTarget?.documentKey;
|
|
if (!documentKey) return;
|
|
const targetExists = sortedDocuments.some((doc) => doc.key === documentKey)
|
|
|| (documentKey === "plan" && Boolean(documentSubject.legacyPlanDocument));
|
|
if (!targetExists || hasScrolledToHashRef.current) return;
|
|
setFoldedDocumentKeys((current) => current.filter((key) => key !== documentKey));
|
|
const element = document.getElementById(`document-${documentKey}`);
|
|
if (!element) return;
|
|
hasScrolledToHashRef.current = true;
|
|
setHighlightDocumentKey(documentKey);
|
|
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
const timer = setTimeout(() => setHighlightDocumentKey((current) => current === documentKey ? null : current), 3000);
|
|
return () => clearTimeout(timer);
|
|
}, [documentSubject.legacyPlanDocument, documentHashTarget, sortedDocuments]);
|
|
|
|
useEffect(() => {
|
|
if (!documentHashTarget?.revisionId) return;
|
|
const doc = sortedDocuments.find((entry) => entry.key === documentHashTarget.documentKey);
|
|
if (!doc) return;
|
|
if (handledRevisionHash?.target === revisionTarget && handledRevisionHash.latestRevisionId === doc.latestRevisionId) return;
|
|
if (revisionsQueryFailed) {
|
|
setError("Could not load the reviewed revision. Try opening the link again.");
|
|
return;
|
|
}
|
|
if (!getDocumentRevisions(doc.key).some((revision) => revision.id === documentHashTarget.revisionId)
|
|
&& doc.latestRevisionId !== documentHashTarget.revisionId) {
|
|
if (!isFetchingDocumentRevisions && activeDocumentRevisions !== undefined) {
|
|
setError("The reviewed revision is unavailable.");
|
|
}
|
|
return;
|
|
}
|
|
if (previewRevision(doc, documentHashTarget.revisionId)) setHandledRevisionHash({ target: revisionTarget!, latestRevisionId: doc.latestRevisionId });
|
|
}, [activeDocumentRevisions, documentHashTarget, getDocumentRevisions, handledRevisionHash, isFetchingDocumentRevisions, previewRevision, revisionTarget, revisionsQueryFailed, sortedDocuments]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
if (copiedDocumentTimerRef.current) {
|
|
clearTimeout(copiedDocumentTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Persist every unsaved edit so a reload or an outage cannot lose it.
|
|
useEffect(() => {
|
|
if (!draft) return;
|
|
if (draft.isNew) {
|
|
if (newDraftScope) {
|
|
const retained = loadStoredNewDocumentDraft(newDraftScope);
|
|
if (retained && !retained.isNew && retained.key === draft.key.trim().toLowerCase()) {
|
|
// A previous mount's create can finish after this editor reopened.
|
|
// Promote its current text without replacing anything typed since.
|
|
setDraft({ ...draft, key: retained.key, baseRevisionId: retained.baseRevisionId, isNew: false });
|
|
return;
|
|
}
|
|
if (draft.key || draft.title || draft.body) saveStructuredDraft(newDocumentDraftStorageKey(newDraftScope), draft);
|
|
else clearStoredNewDocumentDraft(newDraftScope);
|
|
}
|
|
return;
|
|
}
|
|
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
|
if (!existing) return;
|
|
// Retire a matching new-form backup only after its text is saved remotely
|
|
// or retained as an edit. A different newer buffer keeps its backup.
|
|
if (existing.body === draft.body && (existing.title ?? "") === draft.title) {
|
|
clearStoredDocumentDraft(draftScope, draft.key);
|
|
clearStoredNewDocumentDraft(newDraftScope, draft);
|
|
} else if (draftScope) {
|
|
saveStructuredDraft(documentDraftStorageKey(draftScope, draft.key), draft);
|
|
const stored = loadStoredDocumentDraft(draftScope, draft.key);
|
|
if (stored?.body === draft.body && stored.title === draft.title && stored.baseRevisionId === draft.baseRevisionId) {
|
|
clearStoredNewDocumentDraft(newDraftScope, draft);
|
|
}
|
|
}
|
|
}, [draftScope, draft, newDraftScope, sortedDocuments]);
|
|
|
|
// After a reload, reopen the first document with an unsaved edit; the
|
|
// autosave below then saves it.
|
|
const restoredStoredDraftRef = useRef(false);
|
|
useEffect(() => {
|
|
// Wait for the session: the stored draft belongs to one user.
|
|
if (restoredStoredDraftRef.current || !documents || !draftScope) return;
|
|
restoredStoredDraftRef.current = true;
|
|
if (draft) return;
|
|
const newDocument = loadStoredNewDocumentDraft(newDraftScope);
|
|
if (newDocument) {
|
|
setDraft(newDocument);
|
|
return;
|
|
}
|
|
for (const doc of sortedDocuments) {
|
|
const stored = loadStoredDocumentDraft(draftScope, doc.key);
|
|
if (!stored) continue;
|
|
if (stored.body === doc.body && stored.title === (doc.title ?? "")) {
|
|
clearStoredDocumentDraft(draftScope, doc.key);
|
|
continue;
|
|
}
|
|
beginEdit(doc.key);
|
|
return;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [documents, draftScope, newDraftScope]);
|
|
|
|
useEffect(() => {
|
|
if (!draft || draft.isNew) return;
|
|
if (documentConflict?.key === draft.key) return;
|
|
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
|
if (!existing) return;
|
|
const hasChanges =
|
|
existing.body !== draft.body ||
|
|
(existing.title ?? "") !== draft.title;
|
|
if (!hasChanges) {
|
|
if (autosaveStateRef.current !== "saved") {
|
|
resetAutosaveState();
|
|
}
|
|
return;
|
|
}
|
|
markDocumentDirty(draft.key);
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
autosaveDebounceRef.current = setTimeout(() => {
|
|
void commitDraft(draft, { clearAfterSave: false, trackAutosave: true });
|
|
}, DOCUMENT_AUTOSAVE_DEBOUNCE_MS);
|
|
|
|
return () => {
|
|
if (autosaveDebounceRef.current) {
|
|
clearTimeout(autosaveDebounceRef.current);
|
|
}
|
|
};
|
|
}, [commitDraft, documentConflict, draft, markDocumentDirty, resetAutosaveState, sortedDocuments]);
|
|
|
|
const documentBodyShellClassName = "mt-3";
|
|
const documentBodyPaddingClassName = "";
|
|
const documentBodyContentClassName = "paperclip-edit-in-place-content min-h-(--sz-220px) text-sm leading-7";
|
|
const toggleFoldedDocument = (key: string) => {
|
|
setFoldedDocumentKeys((current) =>
|
|
current.includes(key)
|
|
? current.filter((entry) => entry !== key)
|
|
: [...current, key],
|
|
);
|
|
};
|
|
const setAnnotationPanelOpen = useCallback((key: string, nextOpen: boolean) => {
|
|
setAnnotationPanelOpenKeys((current) => {
|
|
const isOpen = current.includes(key);
|
|
if (nextOpen && !isOpen) return [...current, key];
|
|
if (!nextOpen && isOpen) return current.filter((entry) => entry !== key);
|
|
return current;
|
|
});
|
|
if (nextOpen) {
|
|
setFoldedDocumentKeys((current) => current.filter((entry) => entry !== key));
|
|
}
|
|
}, []);
|
|
const toggleAnnotationPanel = useCallback((key: string) => {
|
|
setAnnotationPanelOpenKeys((current) => {
|
|
if (current.includes(key)) return current.filter((entry) => entry !== key);
|
|
setFoldedDocumentKeys((folded) => folded.filter((entry) => entry !== key));
|
|
return [...current, key];
|
|
});
|
|
}, []);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{isEmpty && !draft?.isNew ? (
|
|
<div className="flex flex-wrap items-center justify-end gap-2 min-w-0">
|
|
{extraActions}
|
|
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">New document</span>
|
|
<span className="sm:hidden">New</span>
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
|
<h3 className="w-full text-sm font-medium text-muted-foreground shrink-0 sm:w-auto">Documents</h3>
|
|
<div className="flex flex-wrap items-center gap-2 min-w-0 sm:ml-auto">
|
|
{extraActions}
|
|
<Button variant="outline" size="sm" onClick={beginNewDocument} className="shrink-0">
|
|
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
|
<span className="hidden sm:inline">New document</span>
|
|
<span className="sm:hidden">New</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
|
|
|
{draft?.isNew && (
|
|
<div
|
|
className="space-y-3 rounded-lg border border-border bg-accent/10 p-3"
|
|
onBlurCapture={handleDraftBlur}
|
|
onKeyDown={handleDraftKeyDown}
|
|
>
|
|
<Input
|
|
autoFocus
|
|
value={draft.key}
|
|
onChange={(event) =>
|
|
setDraft((current) => current ? { ...current, key: event.target.value.toLowerCase() } : current)
|
|
}
|
|
placeholder="Document key"
|
|
/>
|
|
{newDocumentKeyError && (
|
|
<p className="text-xs text-destructive">{newDocumentKeyError}</p>
|
|
)}
|
|
{!isPlanKey(draft.key) && (
|
|
<Input
|
|
value={draft.title}
|
|
onChange={(event) =>
|
|
setDraft((current) => current ? { ...current, title: event.target.value } : current)
|
|
}
|
|
placeholder="Optional title"
|
|
/>
|
|
)}
|
|
<MarkdownEditor
|
|
value={draft.body}
|
|
onChange={(body) =>
|
|
setDraft((current) => current ? { ...current, body } : current)
|
|
}
|
|
placeholder="Markdown body"
|
|
bordered={false}
|
|
className="bg-transparent"
|
|
contentClassName="min-h-(--sz-220px) text-sm leading-7"
|
|
mentions={mentions}
|
|
imageUploadHandler={imageUploadHandler}
|
|
onSubmit={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
|
/>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button variant="outline" size="sm" onClick={cancelDraft}>
|
|
<X className="mr-1.5 h-3.5 w-3.5" />
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void commitDraft(draft, { clearAfterSave: false, trackAutosave: false })}
|
|
disabled={upsertDocument.isPending}
|
|
>
|
|
{upsertDocument.isPending ? "Saving..." : "Create document"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!hasRealPlan && legacyPlanDocument ? (
|
|
<div
|
|
id="document-plan"
|
|
className={cn(
|
|
"rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 transition-colors duration-1000",
|
|
highlightDocumentKey === "plan" && "border-primary/50 bg-primary/5",
|
|
)}
|
|
>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<FileText className="h-4 w-4 text-amber-600" />
|
|
<Badge variant="outline" className="border-amber-500/30 font-mono text-(length:--text-nano) uppercase tracking-(--tracking-eyebrow) text-amber-700 dark:text-amber-300">
|
|
PLAN
|
|
</Badge>
|
|
</div>
|
|
<div className={documentBodyPaddingClassName}>
|
|
{renderFoldableBody(legacyPlanDocument.body, documentBodyContentClassName, externalReferences, { documentKey: "plan" })}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-3">
|
|
{sortedDocuments.map((doc) => {
|
|
const isLocked = Boolean(doc.lockedAt);
|
|
const activeDraft = !isLocked && draft?.key === doc.key && !draft.isNew ? draft : null;
|
|
const activeConflict = !isLocked && documentConflict?.key === doc.key ? documentConflict : null;
|
|
const isFolded = foldedDocumentKeys.includes(doc.key);
|
|
const rawRevisionHistory = getDocumentRevisions(doc.key);
|
|
const revisionState = deriveDocumentRevisionState(doc, rawRevisionHistory);
|
|
const revisionHistory = revisionState.revisions;
|
|
const currentRevision = revisionState.currentRevision;
|
|
const selectedRevisionId = selectedRevisionIds[doc.key] ?? null;
|
|
const selectedHistoricalRevision = selectedRevisionId
|
|
? revisionHistory.find((revision) => revision.id === selectedRevisionId) ?? null
|
|
: null;
|
|
const isHistoricalPreview = Boolean(selectedHistoricalRevision);
|
|
const displayedTitle = selectedHistoricalRevision
|
|
? selectedHistoricalRevision.title ?? ""
|
|
: activeDraft?.title ?? currentRevision.title ?? "";
|
|
const waitingForLinkedRevision = documentHashTarget?.documentKey === doc.key
|
|
&& Boolean(documentHashTarget.revisionId)
|
|
&& (handledRevisionHash?.target !== revisionTarget || handledRevisionHash.latestRevisionId !== doc.latestRevisionId);
|
|
const galleryRevisionId = selectedHistoricalRevision?.id
|
|
?? (documentHashTarget?.documentKey === doc.key && documentHashTarget.revisionId === doc.latestRevisionId
|
|
? documentHashTarget.revisionId : undefined);
|
|
const displayedBody = selectedHistoricalRevision?.body ?? activeDraft?.body ?? currentRevision.body;
|
|
const displayedRevisionNumber = selectedHistoricalRevision?.revisionNumber ?? currentRevision.revisionNumber;
|
|
const displayedUpdatedAt = selectedHistoricalRevision?.createdAt ?? currentRevision.createdAt;
|
|
const showTitle = !isPlanKey(doc.key) && !!displayedTitle.trim() && !titlesMatchKey(displayedTitle, doc.key);
|
|
const canVoteOnDocument = Boolean(doc.latestRevisionId && doc.updatedByAgentId && !doc.updatedByUserId && onVote);
|
|
const lockActionPending = setDocumentLock.isPending && setDocumentLock.variables?.key === doc.key;
|
|
const annotationTarget = annotationTargetForKey(doc.key);
|
|
|
|
return (
|
|
<div
|
|
key={doc.id}
|
|
id={`document-${doc.key}`}
|
|
className={cn(
|
|
"rounded-lg border border-border p-3 transition-colors duration-1000",
|
|
highlightDocumentKey === doc.key && "border-primary/50 bg-primary/5",
|
|
)}
|
|
>
|
|
<DocumentFrameHeader
|
|
documentKey={doc.key}
|
|
folded={isFolded}
|
|
onToggleFolded={() => toggleFoldedDocument(doc.key)}
|
|
sourceTrustSlot={<SourceTrustBadge sourceTrust={doc.sourceTrust} artifactLabel="document" />}
|
|
revisionMenu={{
|
|
open: revisionMenuOpenKey === doc.key,
|
|
onOpenChange: (open) => setRevisionMenuOpenKey(open ? doc.key : null),
|
|
loading: revisionMenuOpenKey === doc.key && isFetchingDocumentRevisions,
|
|
revisions: revisionHistory.map((revision) => ({
|
|
id: revision.id,
|
|
revisionNumber: revision.revisionNumber,
|
|
createdAt: revision.createdAt,
|
|
actor: getRevisionActor(revision, { agentMap, userProfileMap }),
|
|
})),
|
|
selectedRevisionId,
|
|
currentRevisionId: currentRevision.id,
|
|
displayedRevisionNumber,
|
|
historicalPreview: isHistoricalPreview,
|
|
onSelectRevision: (revisionId) => previewRevision(doc, revisionId),
|
|
}}
|
|
updatedAt={displayedUpdatedAt}
|
|
annotationSlot={documentSubject.annotations && !isSystemIssueDocumentKey(doc.key) ? (
|
|
<DocumentAnnotationsCountChip
|
|
issueId={documentSubject.annotations.issueId}
|
|
target={annotationTarget}
|
|
docKey={doc.key}
|
|
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
|
|
onToggle={() => toggleAnnotationPanel(doc.key)}
|
|
/>
|
|
) : null}
|
|
titleSlot={showTitle ? <p className="mt-2 text-sm font-medium">{displayedTitle}</p> : null}
|
|
actionsSlot={
|
|
<>
|
|
{canManageDocumentLocks ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className={cn(
|
|
"text-muted-foreground transition-colors",
|
|
isLocked && "text-amber-700 hover:text-amber-800 dark:text-amber-300 dark:hover:text-amber-200",
|
|
)}
|
|
title={isLocked ? "Unlock document" : "Lock document"}
|
|
aria-label={isLocked ? `Unlock ${doc.key} document` : `Lock ${doc.key} document`}
|
|
onClick={() => toggleDocumentLock(doc, !isLocked)}
|
|
disabled={lockActionPending}
|
|
>
|
|
{isLocked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
) : isLocked ? (
|
|
<span title="Locked document" aria-label="Locked document" className="inline-flex h-6 w-6 items-center justify-center text-amber-700 dark:text-amber-300">
|
|
<Lock className="h-3.5 w-3.5" />
|
|
</span>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className={cn(
|
|
"text-muted-foreground transition-colors",
|
|
copiedDocumentKey === doc.key && "text-foreground",
|
|
)}
|
|
title={copiedDocumentKey === doc.key ? "Copied" : "Copy document"}
|
|
onClick={() => void copyDocumentBody(doc.key, displayedBody)}
|
|
>
|
|
{copiedDocumentKey === doc.key ? (
|
|
<Check className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
className="text-muted-foreground"
|
|
title="Document actions"
|
|
>
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{!isHistoricalPreview && !isLocked ? (
|
|
<DropdownMenuItem onClick={() => beginEdit(doc.key)}>
|
|
<FilePenLine className="h-3.5 w-3.5" />
|
|
Edit document
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{!isHistoricalPreview && !isLocked ? <DropdownMenuSeparator /> : null}
|
|
<DropdownMenuItem
|
|
onClick={() => downloadDocumentFile(doc.key, displayedBody)}
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
Download document
|
|
</DropdownMenuItem>
|
|
{doc.latestRevisionNumber > 1 ? (
|
|
<DropdownMenuItem onClick={() => setDiffViewKey(doc.key)}>
|
|
<Diff className="h-3.5 w-3.5" />
|
|
View diff
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
{canDeleteDocuments && !isLocked ? <DropdownMenuSeparator /> : null}
|
|
{canDeleteDocuments && !isLocked ? (
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onClick={() => setConfirmDeleteKey(doc.key)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Delete document
|
|
</DropdownMenuItem>
|
|
) : null}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{!isFolded ? (
|
|
<div
|
|
className="mt-3 space-y-3"
|
|
onBlurCapture={!isHistoricalPreview
|
|
? async (event) => {
|
|
if (activeDraft) {
|
|
await handleDraftBlur(event);
|
|
}
|
|
}
|
|
: undefined}
|
|
onKeyDown={!isHistoricalPreview
|
|
? async (event) => {
|
|
if (activeDraft) {
|
|
await handleDraftKeyDown(event);
|
|
}
|
|
}
|
|
: undefined}
|
|
>
|
|
{waitingForLinkedRevision ? (
|
|
<p role="status" className="text-sm text-muted-foreground">
|
|
{error ?? "Loading reviewed revision…"}
|
|
</p>
|
|
) : null}
|
|
{isHistoricalPreview && selectedHistoricalRevision && (
|
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
|
Viewing revision {selectedHistoricalRevision.revisionNumber}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
This is a historical preview. Restoring it creates a new latest revision and keeps history append-only.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => returnToLatestRevision(doc.key)}
|
|
>
|
|
Return to latest
|
|
</Button>
|
|
{!isLocked ? (
|
|
<Button
|
|
size="sm"
|
|
onClick={() => restoreDocumentRevision.mutate({
|
|
key: doc.key,
|
|
revisionId: selectedHistoricalRevision.id,
|
|
})}
|
|
disabled={restoreDocumentRevision.isPending}
|
|
>
|
|
{restoreDocumentRevision.isPending && restoreDocumentRevision.variables?.key === doc.key
|
|
? "Restoring..."
|
|
: "Restore this revision"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{activeConflict && !isHistoricalPreview && (
|
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-3">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">Out of date</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
This document changed while you were editing. Your local draft is preserved and autosave is paused.
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
setDocumentConflict((current) =>
|
|
current?.key === doc.key
|
|
? { ...current, showRemote: !current.showRemote }
|
|
: current,
|
|
)
|
|
}
|
|
>
|
|
{activeConflict.showRemote ? "Hide remote" : "Review remote"}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => keepConflictedDraft(doc.key)}
|
|
>
|
|
Keep my draft
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => reloadDocumentFromServer(doc.key)}
|
|
>
|
|
Reload remote
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void overwriteDocumentFromDraft(doc.key)}
|
|
disabled={upsertDocument.isPending}
|
|
>
|
|
{upsertDocument.isPending ? "Saving..." : "Overwrite remote"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{activeConflict.showRemote && (
|
|
<div className="mt-3 rounded-md border border-border/70 bg-background/60 p-3">
|
|
<div className="mb-2 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
|
|
<span>Remote revision {activeConflict.serverDocument.latestRevisionNumber}</span>
|
|
<span>•</span>
|
|
<span>updated {relativeTime(activeConflict.serverDocument.updatedAt)}</span>
|
|
</div>
|
|
{!isPlanKey(doc.key) && activeConflict.serverDocument.title ? (
|
|
<p className="mb-2 text-sm font-medium">{activeConflict.serverDocument.title}</p>
|
|
) : null}
|
|
{renderFoldableBody(activeConflict.serverDocument.body, "text-sm leading-7", externalReferences)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{activeDraft && !isPlanKey(doc.key) && !isHistoricalPreview && (
|
|
<Input
|
|
value={activeDraft.title}
|
|
onChange={(event) => {
|
|
markDocumentDirty(doc.key);
|
|
setDraft((current) => current ? { ...current, title: event.target.value } : current);
|
|
}}
|
|
placeholder="Optional title"
|
|
/>
|
|
)}
|
|
<div
|
|
className={`${documentBodyShellClassName} ${
|
|
activeDraft || isHistoricalPreview ? "" : "rounded-md hover:bg-accent/10"
|
|
}`}
|
|
>
|
|
{(() => {
|
|
// Loading and error copy must never become editor input
|
|
// or annotation content. A blocked draft stays editable.
|
|
const renderedDocumentBody = waitingForLinkedRevision && !activeDraft ? null : isHistoricalPreview ? (
|
|
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences, { documentKey: doc.key, revisionId: galleryRevisionId, currentIssueId: issue?.id })
|
|
) : activeDraft ? (
|
|
<MarkdownEditor
|
|
value={displayedBody}
|
|
onChange={(body) => {
|
|
markDocumentDirty(doc.key);
|
|
setDraft((current) => {
|
|
if (current && current.key === doc.key && !current.isNew) {
|
|
return { ...current, body };
|
|
}
|
|
return current;
|
|
});
|
|
}}
|
|
placeholder="Markdown body"
|
|
bordered={false}
|
|
className="bg-transparent"
|
|
contentClassName={documentBodyContentClassName}
|
|
mentions={mentions}
|
|
imageUploadHandler={imageUploadHandler}
|
|
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
|
|
/>
|
|
) : (
|
|
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences, { documentKey: doc.key, revisionId: galleryRevisionId, currentIssueId: issue?.id })
|
|
);
|
|
|
|
return documentSubject.annotations && (!waitingForLinkedRevision || activeDraft) ? (
|
|
<IssueDocumentAnnotations
|
|
issueId={documentSubject.annotations.issueId}
|
|
target={annotationTarget}
|
|
doc={doc}
|
|
bodyMarkdown={displayedBody}
|
|
draftDirty={Boolean(activeDraft) && (
|
|
(activeDraft?.body ?? doc.body) !== doc.body
|
|
|| (autosaveDocumentKey === doc.key && (autosaveState === "saving" || autosaveState === "waiting"))
|
|
)}
|
|
draftConflicted={Boolean(activeConflict)}
|
|
historicalPreview={isHistoricalPreview}
|
|
locationHash={location.hash}
|
|
panelOpen={annotationPanelOpenKeys.includes(doc.key)}
|
|
onPanelOpenChange={(next) => setAnnotationPanelOpen(doc.key, next)}
|
|
agentMap={agentMap}
|
|
userProfileMap={userProfileMap}
|
|
defaultFocusedThreadId={defaultAnnotationFocusedThreadIds?.[doc.key]}
|
|
>
|
|
{renderedDocumentBody}
|
|
</IssueDocumentAnnotations>
|
|
) : renderedDocumentBody;
|
|
})()}
|
|
</div>
|
|
<div className="flex min-h-4 items-center justify-end px-1">
|
|
<span
|
|
className={`text-(length:--text-micro) transition-opacity duration-150 ${
|
|
isHistoricalPreview
|
|
? "text-amber-700 dark:text-amber-300"
|
|
: activeConflict
|
|
? "text-amber-700 dark:text-amber-300"
|
|
: autosaveState === "error"
|
|
? "text-destructive"
|
|
: "text-muted-foreground"
|
|
} ${activeDraft || isHistoricalPreview ? "opacity-100" : "opacity-0"}`}
|
|
>
|
|
{isHistoricalPreview
|
|
? "Viewing historical revision"
|
|
: activeDraft
|
|
? activeConflict
|
|
? "Out of date"
|
|
: autosaveDocumentKey === doc.key
|
|
? autosave.label ?? ""
|
|
: ""
|
|
: ""}
|
|
</span>
|
|
{activeDraft && autosaveDocumentKey === doc.key && autosaveState === "stalled" ? <Button type="button" size="sm" variant="ghost" onClick={autosave.resendNow}>Retry saving</Button> : null}
|
|
</div>
|
|
{canVoteOnDocument && doc.latestRevisionId ? (
|
|
<OutputFeedbackButtons
|
|
activeVote={feedbackVoteByTargetId.get(doc.latestRevisionId) ?? null}
|
|
sharingPreference={feedbackDataSharingPreference}
|
|
termsUrl={feedbackTermsUrl}
|
|
onVote={(vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) =>
|
|
onVote?.(doc.latestRevisionId!, vote, options) ?? Promise.resolve()
|
|
}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{confirmDeleteKey === doc.key && (
|
|
<div className="mt-3 flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
|
|
<p className="text-sm text-destructive font-medium">
|
|
Delete this document? This cannot be undone.
|
|
</p>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setConfirmDeleteKey(null)}
|
|
disabled={deleteDocument.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => deleteDocument.mutate(doc.key)}
|
|
disabled={deleteDocument.isPending}
|
|
>
|
|
{deleteDocument.isPending ? "Deleting..." : "Delete"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{diffViewKey && (() => {
|
|
const diffDoc = sortedDocuments.find((d) => d.key === diffViewKey);
|
|
if (!diffDoc) return null;
|
|
return (
|
|
<DocumentDiffModal
|
|
documentKey={diffDoc.key}
|
|
latestRevisionNumber={diffDoc.latestRevisionNumber}
|
|
revisionsQueryKey={documentSubject.documentRevisionsQueryKey(diffDoc.key)}
|
|
revisionsQueryFn={() => documentSubject.listDocumentRevisions(diffDoc.key)}
|
|
open
|
|
onOpenChange={(open) => { if (!open) setDiffViewKey(null); }}
|
|
/>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function IssueDocumentsSection(props: Parameters<typeof AccountIssueDocumentsSection>[0]) {
|
|
const owner = useDraftOwner();
|
|
const previousOwner = useRef(owner);
|
|
const generation = useRef(0);
|
|
if (previousOwner.current !== owner) {
|
|
if (previousOwner.current !== undefined) generation.current += 1;
|
|
previousOwner.current = owner;
|
|
}
|
|
return <AccountIssueDocumentsSection key={JSON.stringify([generation.current, props.subject?.id ?? props.issue?.id])} {...props} />;
|
|
}
|