Files
PaperClipAI/ui/src/components/IssueDocumentsSection.tsx
T
5c85ae64a0 Cases: experimental first-class case object (#9198)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The board currently uses issues for execution, but longer-lived
content work needs a separate object that can survive beyond a single
task thread.
> - The Cases subsystem adds an experimental, company-scoped record for
content artifacts and their supporting metadata.
> - The backend needs durable storage, API routes, revision history,
issue linkage, and company-boundary enforcement before the UI can depend
on Cases.
> - The UI needs an opt-in navigation surface, list/detail views,
reference chips, and issue-page context so operators can inspect Cases
without making them the default workflow.
> - The agent-facing skills need a contract for creating and updating
Cases so automated content workflows can dogfood the feature.
> - This pull request ships that experimental end-to-end path behind the
`enableCases` flag.
> - The benefit is a first-class place to collect content work,
references, attachments, revisions, and related execution threads
without polluting the core issue model.

## Linked Issues or Issue Description

No public GitHub issue exists for this experimental feature.

Feature request fields:

### Problem

Content-oriented work such as release notes, announcements, docs, and
campaigns can span many execution issues, which makes the final artifact
hard to find and reason about after the execution thread moves on.

### Proposed solution

Add an experimental Cases object that is company-scoped, linked to
issues, queryable through the API, inspectable in the board UI, and
writable by agent workflows through documented conventions.

### Alternatives considered

Continue encoding content artifacts directly in issues or documents
only. That keeps the data model smaller, but it does not give operators
a stable artifact-centric view or a clean way to link related execution
history.

### Roadmap alignment

Checked `ROADMAP.md`; this PR does not duplicate an existing planned
core roadmap item.

## What Changed

- Added the `cases` data model, migration, schema exports, and
experimental `enableCases` instance setting.
- Added company-scoped Cases API routes for list/detail/update, issue
links, revisions, children, activity events, annotations, attachments,
and idempotent agent-oriented upserts.
- Scoped case and issue lookup helpers before access checks so
inaccessible cross-company identifiers resolve as not found rather than
leaking existence.
- Fixed case PATCH timestamp handling so non-status updates cannot
overwrite `completedAt` from a stale pre-transaction row snapshot.
- Moved Cases list type/status/project filters into the server request
before the server-side limit is applied, including multi-select filters
and no-project filtering.
- Added backend route coverage for creation, updates, idempotency, issue
linking, attribution, company-boundary enforcement, OpenAPI
registration, list filtering, timestamp patch behavior, and inaccessible
lookup regressions.
- Added the experimental Cases UI surface: sidebar entry, gated routes,
list filters/grouping, detail overview, activity, revisions, children,
attachments, and issue-page case rail.
- Added case reference rendering and company-prefixed case href
generation so case links resolve directly inside the active company
route.
- Added Paperclip skill documentation for agent workflows that create or
update Cases.
- Wired release-content skills to emit Cases for dogfooding.
- Rebased onto current `master` and renumbered the Cases migrations to
`0143`/`0144` after the latest upstream migration sequence.

## Verification

- Current PR head: `ecc13be0d`.
- Rebased on current `master` (`606aa4f266`) and pushed to the existing
PR branch.
- `git diff --check origin/master...HEAD` — passed before the first
update push; subsequent committed diffs were also checked with `git diff
--check` before commit.
- Guardrails checked: no `pnpm-lock.yaml` changes, no
`.github/workflows` changes, and changed-file count is below the
Greptile 100-file limit.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/cases-routes.test.ts
src/__tests__/instance-settings-service.test.ts
src/__tests__/openapi-routes.test.ts` — passed, 3 files / 26 tests
before review-fix commits.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/cases-routes.test.ts` — passed after each server-side
Greptile fix, latest 1 file / 15 tests.
- `pnpm --filter @paperclipai/server typecheck` — passed after the
timestamp and lookup fixes.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Cases.test.tsx src/pages/CaseDetail.test.tsx
src/pages/CompanySkills.test.tsx src/App.cases-routing.test.tsx` —
passed, 4 files / 30 tests before review-fix commits.
- `pnpm --filter @paperclipai/ui exec vitest run
src/pages/Cases.test.tsx` — passed after the list-filter fix, 1 file /
12 tests.
- `pnpm --filter @paperclipai/ui typecheck` — passed after the
list-filter fix.
- `pnpm check:token-gates` — passed after UI changes.
- Remote PR checks on head `ecc13be0d` are green: Paperclip CI, build,
typecheck, test matrix, e2e, Canary Dry Run, policy, commit review,
Superagent Security Scan, Socket, Snyk, and Greptile passed; Storybook
visual regression is skipped and security-review is neutral.
- Greptile Review: 5/5 confidence, zero unresolved Greptile threads.

## Risks

- Medium feature risk because this introduces a new experimental domain
object across database, server, shared contracts, skills, and UI.
- The feature is gated behind `enableCases`, which limits default
operator exposure while the model is exercised.
- Case links now prefer company-prefixed hrefs; the unprefixed redirect
remains for externally entered URLs.
- Cases list filtering now sends multi-select filters to the server
before limiting; the UI still applies the same local filters as a second
pass for ancestor/context rows.
- Migrations were renumbered on top of current master; the SQL uses
guarded `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS` patterns where
relevant for safer replay.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex in the Paperclip local coding environment was used
for this PR curation, rebase verification, review-fix implementation,
push, and PR description update. The runtime exposes tool use and shell
execution; context-window size is not exposed by this Paperclip adapter.
Several implementation commits also include AI co-author trailers
recorded in git history.

## 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 searched the GitHub PR list for similar PRs and confirmed this
is not a duplicate
- [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)
- [ ] 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>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:11:03 -05:00

1415 lines
59 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 { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { deriveDocumentRevisionState } from "../lib/document-revisions";
import type { CompanyUserProfile } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
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 { 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,
) {
return (
<FoldCurtain>
<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";
}
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">>>;
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,
};
}
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 },
};
}
export function IssueDocumentsSection({
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">>>;
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 [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 [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);
const {
state: autosaveState,
markDirty,
reset,
runSave,
} = useAutosaveIndicator();
const { data: documents } = useQuery({
queryKey: documentSubject.documentsQueryKey,
queryFn: documentSubject.listDocuments,
});
const { data: activeDocumentRevisions, isFetching: isFetchingDocumentRevisions } = useQuery({
queryKey: revisionMenuOpenKey
? documentSubject.documentRevisionsQueryKey(revisionMenuOpenKey)
: documentSubject.idleDocumentRevisionsQueryKey,
queryFn: async () => {
if (!revisionMenuOpenKey) return [];
return documentSubject.listDocumentRevisions(revisionMenuOpenKey);
},
enabled: Boolean(revisionMenuOpenKey),
});
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 conflictedDraft = documentConflict?.key === key ? documentConflict.localDraft : 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);
}
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 save = async () => {
const saved = await upsertDocument.mutateAsync({
...currentDraft,
key: normalizedKey,
title: isPlanKey(normalizedKey) ? "" : normalizedTitle,
body: currentDraft.body,
baseRevisionId: options?.overrideConflict
? activeConflict?.serverDocument.latestRevisionId ?? currentDraft.baseRevisionId
: currentDraft.baseRevisionId,
});
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 ?? "",
body: saved.body,
baseRevisionId: saved.latestRevisionId,
isNew: false,
};
});
syncDocumentCaches(saved);
invalidateIssueDocuments();
};
try {
if (options?.trackAutosave) {
setAutosaveDocumentKey(normalizedKey);
await runSave(save);
} else {
await save();
}
return true;
} catch (err) {
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(err instanceof Error ? err.message : "Failed to save document");
return false;
}
}, [documentConflict, documentSubject, invalidateIssueDocuments, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]);
const reloadDocumentFromServer = useCallback((key: string) => {
if (documentConflict?.key !== key) return;
const serverDocument = documentConflict.serverDocument;
setDraft({
key: serverDocument.key,
title: serverDocument.title ?? "",
body: serverDocument.body,
baseRevisionId: serverDocument.latestRevisionId,
isNew: false,
});
setDocumentConflict(null);
resetAutosaveState();
setError(null);
}, [documentConflict, 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 navigator.clipboard.writeText(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 (revisionMenuOpenKey === key) return activeDocumentRevisions ?? [];
return [];
}, [activeDocumentRevisions, documentSubject, queryClient, revisionMenuOpenKey]);
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;
if (selectedRevision.id === revisionState.currentRevision.id) {
returnToLatestRevision(doc.key);
return;
}
if (documentConflict?.key === doc.key || documentHasUnsavedChanges(doc, draft)) {
setError("Save or cancel your local changes before viewing an older revision.");
return;
}
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);
}, [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));
}, [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 hash = location.hash;
if (!hash.startsWith("#document-")) return;
const documentKey = decodeURIComponent(hash.slice("#document-".length));
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, location.hash, sortedDocuments]);
useEffect(() => {
return () => {
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
if (copiedDocumentTimerRef.current) {
clearTimeout(copiedDocumentTimerRef.current);
}
};
}, []);
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 (autosaveState !== "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);
}
};
}, [autosaveState, 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)}
</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 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}
>
{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"
}`}
>
{(() => {
const renderedDocumentBody = isHistoricalPreview ? (
renderFoldableBody(displayedBody, documentBodyContentClassName, externalReferences)
) : 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)
);
return documentSubject.annotations ? (
<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")
)}
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
? autosaveState === "saving"
? "Autosaving..."
: autosaveState === "saved"
? "Saved"
: autosaveState === "error"
? "Could not save"
: ""
: ""
: ""}
</span>
</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>
);
}