mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/247/changes/paperclip/247 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task documents hold plans, reports, and image evidence. > - Task attachments already have an image gallery. > - Inline document images had no gallery navigation or image links. > - This change gives each document a gallery that uses the existing modal. > - People can review the image sequence and share a link to one image. ## Linked Issues or Issue Description **What existing behavior does this improve?** Image review in task documents, including the side panel and Classic interface. **Current behavior** Inline document images do not open a gallery. People cannot move through them with arrow keys or share a link to one image. **Proposed behavior** Select an image to open the gallery. Use arrow keys or Previous and Next. The URL records its one-based position, for example `#document-report&image=2`. Reloading that URL opens the selected image. Revision links keep their exact saved content. **Reason and benefit** People can inspect a document's image evidence in reading order and share the exact view. No matching issue was found. The latest 300 open PR titles were checked for related document gallery work. ## What Changed - Add a document renderer that collects rendered images in reading order, including reference-style and repeated images. - Reuse the gallery modal with optional index-change and focus-return controls. - Keep the document owner in the saved tab identity when people switch between local and other-task documents. - Match explicit current-task document owners with local document tabs. Keep review and viewer parameters during gallery navigation. - Treat another Classic review receipt as a new request while image navigation retains the selected revision. - Make inline image controls work with a keyboard. Keep focus in the modal and restore focus on close. - Keep document, revision, review, and maximized-view link parameters during image navigation. - Fix a Classic revision-cache race found by the browser test. Recheck a pinned revision when the saved document head changes. - Update the feature and agent access maps. No new server API or permission is needed. ## Verification - Passed 119 focused tests for document galleries, the shared gallery, document rendering, the revision panel, and hash routing. - Passed all 3 Chromium journeys: desktop, mobile, and Classic. These use real uploads and saved document revisions. - The browser journeys also check explicit current-task owners, review parameters after reopening, other-task links after tab switches, and a second Classic review link after Return to latest. - Browser checks cover keyboard and button navigation, wraparound, shared-link reload, exact historical content, invalid positions, focus return and containment, scroll locking, and Escape, Close, and backdrop dismissal. - UI typecheck, token gates, map checks, and diff whitespace checks passed. - The UI production build passed in the browser harness. - Earlier browser runs found and then verified the Classic revision-cache fix. A later build exited 137 while the full test suite ran. The browser run passed after that suite stopped. - Full repository typecheck and build could not complete because Cargo is absent. The full repository test run did not complete within this sandbox. These checks remain subject to CI. ## Risks - Image positions follow document reading order. Editing an unpinned document can change those positions. A revision link retains the saved sequence. - The shared modal now uses Radix Escape handling. The existing gallery tests and nested mobile dialog journey pass. - No schema change or migration. ## Model Used OpenAI GPT-6 through Codex. The runtime did not expose a more specific model ID or context window size. Used reasoning, code editing, shell commands, and browser automation. ## 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>
1573 lines
68 KiB
TypeScript
1573 lines
68 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 { 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 { 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 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 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,
|
|
feedbackVotes = [],
|
|
feedbackDataSharingPreference = "prompt",
|
|
feedbackTermsUrl = null,
|
|
mentions,
|
|
imageUploadHandler,
|
|
onVote,
|
|
extraActions,
|
|
agentMap,
|
|
userProfileMap,
|
|
defaultAnnotationPanelOpenKeys,
|
|
defaultAnnotationFocusedThreadIds,
|
|
forceEditDocumentKey,
|
|
externalReferences,
|
|
}: {
|
|
issue?: Issue;
|
|
subject?: DocumentSubjectConfig;
|
|
canDeleteDocuments: boolean;
|
|
canManageDocumentLocks?: 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 [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) => !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]);
|
|
|
|
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 isEmpty = sortedDocuments.length === 0 && !documentSubject.legacyPlanDocument;
|
|
const newDocumentKeyError =
|
|
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({
|
|
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);
|
|
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;
|
|
|
|
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);
|
|
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,
|
|
title: saved.title ?? "",
|
|
// Keep text typed while this save was in flight.
|
|
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, invalidateIssueDocuments, 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 || draft.isNew) return;
|
|
const existing = sortedDocuments.find((doc) => doc.key === draft.key);
|
|
if (!existing) return;
|
|
if (existing.body === draft.body && (existing.title ?? "") === draft.title) {
|
|
clearStoredDocumentDraft(draftScope, draft.key);
|
|
} else if (draftScope) {
|
|
saveStructuredDraft(documentDraftStorageKey(draftScope, draft.key), draft);
|
|
}
|
|
}, [draftScope, draft, 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;
|
|
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]);
|
|
|
|
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 && documentSubject.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(documentSubject.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} />;
|
|
}
|