Files
PaperClipAI/ui/src/components/IssueDocumentsSection.tsx
T
DottaandPaperclip 322fa6b65c fix(ui): restore task document authoring and add persistence journey (#18356)
## 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>
2026-10-31 01:49:24 -05:00

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} />;
}