Files
PaperClipAI/ui/src/components/IssueDocumentAnnotations.tsx
T
0ee0543e5b feat(ui): refine the chat-style task workflow (#11263)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The issue detail view is the main place where people read work and
guide agents.
> - The chat-style task view needs clear messages, controls, properties,
and document feedback.
> - Dense metadata and disconnected controls make active work harder to
scan.
> - This pull request refines the existing chat-style task workflow
across desktop and mobile layouts.
> - The benefit is a clearer issue thread with faster access to the
controls that guide work.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The change improves the chat-style issue detail view that was introduced
in [#10606](https://github.com/paperclipai/paperclip/pull/10606) and
expanded in
[#10707](https://github.com/paperclipai/paperclip/pull/10707).

**Current behavior**

The issue thread spreads task controls across the page. Agent-turn
metadata competes with the message content. Document annotation comments
use inline placement that limits the document reading area. The mobile
composer can overlap the bottom navigation.

**Proposed behavior**

The issue view keeps the thread focused on message content. It moves
supporting controls into the properties area, adds searchable
assignment, restores the sub-task tree, docks document comments in a
side gutter, and keeps the mobile composer clear of navigation.

**Reason and benefit**

People can scan active work faster and find task controls without
leaving the issue. The layout also gives documents and mobile
conversations more usable space.

**Breaking changes**

None. The change updates presentation and interaction behavior in the
existing issue UI.

## What Changed

- Refined task-chat message spacing, metadata, agent bubbles, and
composer alignment.
- Added searchable assignment and restored sub-task navigation in the
properties pane.
- Moved document annotation comments into a right-side gutter.
- Kept the mobile composer above the auto-hiding bottom navigation.
- Added and updated focused component tests for the changed
interactions.

## Verification

- `pnpm check:token-gates`
- `TZ=UTC pnpm --filter @paperclipai/ui exec vitest run
src/components/InlineEntitySelector.test.tsx
src/components/IssueDocumentAnnotations.test.tsx
src/components/IssueProperties.test.tsx
src/components/TaskChatThread.test.tsx src/pages/IssueDetail.test.tsx`
- The token gates report 3/3 clean.
- The focused test run passes 124 tests in 5 files.
- Visual snapshot baselines were not updated. This follows the
`doc/design/DECISION-SHEET.md` entry "Per-change snapshot verification
demoted to dormant (Jul 13 2026)."

## Risks

- The changes affect several related issue-detail layouts. A browser
review should cover desktop and mobile widths before merge.
- The monitor-row test formats time in the host timezone. The
verification command sets `TZ=UTC` to match CI.

> 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 Codex with GPT-5. The context-window size is not exposed in
this environment. The model used reasoning, repository tools, code
execution, and GitHub tools.

## 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
- [ ] All Paperclip CI gates are green
- [ ] 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: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-12 11:41:40 -07:00

442 lines
18 KiB
TypeScript

import { Profiler, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Agent, DocumentAnnotationThreadWithComments, IssueDocument } from "@paperclipai/shared";
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
import { queryKeys } from "@/lib/queryKeys";
import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
import {
initializeSelectionDebug,
isSelectionDebugEnabled,
recordAnnotationCommit,
} from "@/lib/document-annotation-debug";
import { DocumentAnnotationLayer, type AnnotationAnchorRect, type PendingAnchor } from "./DocumentAnnotationLayer";
import { DocumentAnnotationPanel } from "./DocumentAnnotationPanel";
import { DocumentAnnotationPopover } from "./DocumentAnnotationPopover";
import type { CompanyUserProfile } from "@/lib/company-members";
// Width of the right-hand comment gutter on desktop (lg+). The gutter is an
// in-flow flex column beside the document, so it scrolls with the doc instead
// of floating over the viewport (PAP-504).
const DESKTOP_ANNOTATION_PANEL_WIDTH = 360;
type AnnotationDocument = Pick<IssueDocument, "key" | "latestRevisionId" | "latestRevisionNumber">;
export interface IssueDocumentAnnotationsProps {
issueId: string;
doc: AnnotationDocument;
target?: DocumentAnnotationTarget;
/** The body that is being rendered/edited (current or historical revision). */
bodyMarkdown: string;
/** True when a draft has unsaved changes or is currently saving. */
draftDirty: boolean;
/** True when there is a remote conflict that requires user resolution. */
draftConflicted: boolean;
/** True when the document is being viewed in historical revision preview. */
historicalPreview: boolean;
/** Render the document body (rendered MarkdownBody or MarkdownEditor) inside the wrapper. */
children: ReactNode;
/** Current location hash so we can resolve deep-link targets. */
locationHash: string;
/** Controlled panel state. Caller owns this so the count chip can live in the doc header. */
panelOpen: boolean;
onPanelOpenChange: (open: boolean) => void;
/** Keep the panel in document flow for narrow hosts such as the task properties pane. */
panelPlacement?: "floating" | "inline" | "popover";
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
/** Seed which thread is focused on mount. Used by Storybook/screenshot harness. */
defaultFocusedThreadId?: string;
/**
* Seed the composer with a pending anchor and open the panel once. Used when
* a host captures a selection before the annotated document wrapper exists.
*/
initialComposerAnchor?: PendingAnchor | null;
onInitialComposerAnchorConsumed?: () => void;
}
export function IssueDocumentAnnotations({
issueId,
doc,
target,
bodyMarkdown,
draftDirty,
draftConflicted,
historicalPreview,
children,
locationHash,
panelOpen,
onPanelOpenChange,
panelPlacement = "floating",
agentMap,
userProfileMap,
defaultFocusedThreadId,
initialComposerAnchor,
onInitialComposerAnchorConsumed,
}: IssueDocumentAnnotationsProps) {
const selectionDebugEnabled = isSelectionDebugEnabled();
if (selectionDebugEnabled) initializeSelectionDebug();
const containerRef = useRef<HTMLElement | null>(null);
const [focusedThreadId, setFocusedThreadId] = useState<string | null>(defaultFocusedThreadId ?? null);
const [focusedCommentId, setFocusedCommentId] = useState<string | null>(null);
const [selectionAnchor, setSelectionAnchor] = useState<PendingAnchor | null>(null);
const [composerAnchor, setComposerAnchor] = useState<PendingAnchor | null>(null);
const [popoverAnchorRect, setPopoverAnchorRect] = useState<AnnotationAnchorRect | null>(null);
const [isMobile, setIsMobile] = useState(false);
const hashHandledRef = useRef<string | null>(null);
// Bus token to ask the body layer to capture the current selection into a pendingAnchor.
const [captureSelectionRequestId, setCaptureSelectionRequestId] = useState(0);
const consumedInitialAnchorRef = useRef<PendingAnchor | null>(null);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const mediaQuery = window.matchMedia("(max-width: 1023px)");
const handler = () => setIsMobile(mediaQuery.matches);
handler();
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}
return undefined;
}, []);
const annotationsQuery = useQuery({
queryKey: target?.kind === "routine"
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
: target?.kind === "case"
? queryKeys.cases.documentAnnotations(target.caseId, target.documentKey, "all")
: queryKeys.issues.documentAnnotations(issueId, doc.key, "all"),
queryFn: () => target
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
: documentAnnotationsApi.list(issueId, doc.key, { status: "all", includeComments: true }),
staleTime: 30_000,
});
const allThreads = annotationsQuery.data ?? [];
// Resolve deep link `#document-<key>&thread=...&comment=...` once per change.
useEffect(() => {
if (!locationHash) return;
if (hashHandledRef.current === locationHash) return;
const target = parseDocumentAnnotationHash(locationHash);
if (!target || target.documentKey !== doc.key) return;
if (!target.threadId) return;
hashHandledRef.current = locationHash;
onPanelOpenChange(true);
setFocusedThreadId(target.threadId);
setFocusedCommentId(target.commentId);
}, [doc.key, locationHash, onPanelOpenChange]);
const newCommentDisabled = draftDirty || draftConflicted || historicalPreview || !doc.latestRevisionId;
const newCommentDisabledReason = historicalPreview
? "New comments are disabled while previewing a historical revision."
: draftConflicted
? "Resolve the document conflict before adding new comments."
: draftDirty
? "Save the draft to anchor new comments."
: !doc.latestRevisionId
? "Document has no saved revision yet."
: null;
const handleSelectionAnchorChange = useCallback((anchor: PendingAnchor | null) => {
setSelectionAnchor(anchor);
if (anchor && panelPlacement === "popover") {
setComposerAnchor(null);
setFocusedThreadId(null);
setPopoverAnchorRect(null);
onPanelOpenChange(false);
}
}, [onPanelOpenChange, panelPlacement]);
const handleClearComposerAnchor = useCallback(() => {
setSelectionAnchor(null);
setComposerAnchor(null);
setPopoverAnchorRect(null);
}, []);
const handleRequestComment = useCallback((anchor: PendingAnchor, rect?: AnnotationAnchorRect) => {
if (newCommentDisabled) return;
setSelectionAnchor(null);
setComposerAnchor(anchor);
setFocusedThreadId(null);
if (rect) setPopoverAnchorRect(rect);
onPanelOpenChange(true);
}, [newCommentDisabled, onPanelOpenChange]);
useEffect(() => {
if (!initialComposerAnchor) return;
if (consumedInitialAnchorRef.current === initialComposerAnchor) return;
if (newCommentDisabled) return;
consumedInitialAnchorRef.current = initialComposerAnchor;
setComposerAnchor(initialComposerAnchor);
onPanelOpenChange(true);
onInitialComposerAnchorConsumed?.();
}, [initialComposerAnchor, newCommentDisabled, onInitialComposerAnchorConsumed, onPanelOpenChange]);
const handleThreadFocus = useCallback((threadId: string | null, rect?: AnnotationAnchorRect) => {
setFocusedThreadId(threadId);
if (threadId) {
setComposerAnchor(null);
if (rect) setPopoverAnchorRect(rect);
onPanelOpenChange(true);
setFocusedCommentId(null);
}
}, [onPanelOpenChange]);
const handleAnchorRectChange = useCallback((rect: AnnotationAnchorRect | null) => {
setPopoverAnchorRect((current) => isSameAnchorRect(current, rect) ? current : rect);
}, []);
const handleRequestCommentFromSelection = useCallback(() => {
if (newCommentDisabled) return;
if (selectionAnchor) {
handleRequestComment(selectionAnchor);
return;
}
// Trigger the layer to re-read the current selection and emit a pendingAnchor.
setCaptureSelectionRequestId((current) => current + 1);
}, [handleRequestComment, newCommentDisabled, selectionAnchor]);
// ⌘⇧M / Ctrl+Shift+M global shortcut while the panel is open.
useEffect(() => {
if (!panelOpen) return;
if (typeof window === "undefined") return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
const isMeta = event.metaKey || event.ctrlKey;
if (!isMeta || !event.shiftKey) return;
if (event.key.toLowerCase() !== "m") return;
event.preventDefault();
handleRequestCommentFromSelection();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [panelOpen, handleRequestCommentFromSelection]);
const focusedThread = useMemo(() => {
if (!focusedThreadId) return null;
return allThreads.find((thread) => thread.id === focusedThreadId) ?? null;
}, [allThreads, focusedThreadId]);
const overlayThreads = useMemo(
() => allThreads.map((thread) => ({
id: thread.id,
selectedText: thread.selectedText,
status: thread.status,
anchorState: thread.anchorState,
})),
[allThreads],
);
const isInlinePlacement = panelPlacement === "inline";
const isPopoverPlacement = panelPlacement === "popover";
const showPopover = panelOpen && isPopoverPlacement && !isMobile
&& Boolean(popoverAnchorRect && (composerAnchor || focusedThread));
// On desktop (lg+) the panel docks into an in-flow gutter column beside the
// document so it scrolls with the doc rather than floating over the viewport.
const showDesktopGutter = panelOpen && !isInlinePlacement && !isPopoverPlacement && !isMobile;
const annotationPanel = panelOpen ? (
<DocumentAnnotationPanel
open={panelOpen}
onOpenChange={(open) => {
onPanelOpenChange(open);
if (!open) {
setSelectionAnchor(null);
setComposerAnchor(null);
setFocusedThreadId(null);
setFocusedCommentId(null);
}
}}
issueId={issueId}
target={target}
documentKey={doc.key}
documentRevisionNumber={doc.latestRevisionNumber}
baseRevisionId={doc.latestRevisionId}
baseRevisionNumber={doc.latestRevisionNumber}
threads={allThreads as DocumentAnnotationThreadWithComments[]}
focusedThreadId={focusedThreadId}
focusedCommentId={focusedCommentId}
onFocusThread={(id) => {
setFocusedThreadId(id);
if (!id) setFocusedCommentId(null);
}}
pendingAnchor={composerAnchor}
onClearPendingAnchor={handleClearComposerAnchor}
onRequestCommentFromSelection={handleRequestCommentFromSelection}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
isMobile={isMobile}
inline={isInlinePlacement || isPopoverPlacement}
desktopWidth={showDesktopGutter ? DESKTOP_ANNOTATION_PANEL_WIDTH : undefined}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
) : null;
const content = (
<div
className={cn(
"paperclip-doc-annotation-host relative",
// Docked desktop gutter: lay the doc and the comment column side by side
// so the panel is part of the document's scroll flow (Google-Docs style).
showDesktopGutter && "lg:flex lg:items-stretch lg:gap-6",
)}
>
<section
ref={(element) => {
containerRef.current = element;
}}
className={cn("relative min-w-0", showDesktopGutter && "lg:flex-1")}
data-testid={`document-annotation-body-${doc.key}`}
>
<div className="relative z-(--z-1)">
{children}
</div>
{!historicalPreview && doc.latestRevisionId ? (
<DocumentAnnotationLayer
containerRef={containerRef}
markdown={bodyMarkdown}
threads={overlayThreads}
focusedThreadId={focusedThread?.id ?? null}
onThreadFocus={handleThreadFocus}
pendingAnchor={selectionAnchor}
onPendingAnchorChange={handleSelectionAnchorChange}
onRequestComment={handleRequestComment}
onAnchorRectChange={isPopoverPlacement && panelOpen && (composerAnchor || focusedThreadId)
? handleAnchorRectChange
: undefined}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
hideResolved
captureSelectionRequestId={captureSelectionRequestId}
pendingHighlightText={composerAnchor?.selectedText ?? null}
/>
) : null}
{showPopover && popoverAnchorRect ? (
<DocumentAnnotationPopover
anchorRect={popoverAnchorRect}
containerRef={containerRef}
target={target ?? { kind: "issue", issueId, documentKey: doc.key }}
documentKey={doc.key}
baseRevisionId={doc.latestRevisionId}
baseRevisionNumber={doc.latestRevisionNumber}
pendingAnchor={composerAnchor}
thread={focusedThread as DocumentAnnotationThreadWithComments | null}
focusedCommentId={focusedCommentId}
onFocusThread={setFocusedThreadId}
onClose={() => {
setComposerAnchor(null);
setFocusedThreadId(null);
setFocusedCommentId(null);
setPopoverAnchorRect(null);
onPanelOpenChange(false);
}}
onThreadCreated={() => {
setComposerAnchor(null);
setPopoverAnchorRect(null);
onPanelOpenChange(false);
}}
newCommentDisabled={newCommentDisabled}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
) : null}
</section>
{panelOpen && (isInlinePlacement || (isPopoverPlacement && !showPopover)) && !isMobile ? (
<div className="mt-3" data-testid="document-annotation-panel-inline">
{annotationPanel}
</div>
) : null}
{showDesktopGutter ? (
<div
data-testid="document-annotation-panel-anchor"
className="hidden shrink-0 lg:block"
style={{ width: DESKTOP_ANNOTATION_PANEL_WIDTH }}
>
{/* Sticky within the gutter: stays beside the highlighted range while
the document is on screen, then scrolls away with the doc. */}
<div className="sticky top-4">
{annotationPanel}
</div>
</div>
) : null}
{panelOpen && isMobile ? annotationPanel : null}
</div>
);
return selectionDebugEnabled ? (
<Profiler id="IssueDocumentAnnotations" onRender={recordAnnotationCommit}>
{content}
</Profiler>
) : content;
}
function isSameAnchorRect(a: AnnotationAnchorRect | null, b: AnnotationAnchorRect | null): boolean {
return a === b || Boolean(a && b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height);
}
export interface DocumentAnnotationsCountChipProps {
issueId: string;
docKey: string;
target?: DocumentAnnotationTarget;
panelOpen: boolean;
onToggle: () => void;
}
/**
* Renders the unresolved-count chip for a document. Lives in the document header row
* (next to `rev N ▾`) so it stays visible when the document is folded.
*/
export function DocumentAnnotationsCountChip({
issueId,
docKey,
target,
panelOpen,
onToggle,
}: DocumentAnnotationsCountChipProps) {
const annotationsQuery = useQuery({
queryKey: target?.kind === "routine"
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
: target?.kind === "case"
? queryKeys.cases.documentAnnotations(target.caseId, target.documentKey, "all")
: queryKeys.issues.documentAnnotations(issueId, docKey, "all"),
queryFn: () => target
? documentAnnotationsApi.listForTarget(target, { status: "all", includeComments: true })
: documentAnnotationsApi.list(issueId, docKey, { status: "all", includeComments: true }),
staleTime: 30_000,
});
const threads = annotationsQuery.data ?? [];
const openCount = useMemo(
() => threads.filter((thread) => thread.status === "open" && thread.anchorState !== "orphaned").length,
[threads],
);
return (
<Button
type="button"
size="sm"
variant="ghost"
data-state={panelOpen ? "open" : "closed"}
className={cn(
"h-auto gap-1 rounded-md px-1.5 py-0 text-(length:--text-micro) font-normal text-muted-foreground hover:text-foreground",
panelOpen && "bg-muted text-foreground",
openCount > 0 && "text-foreground",
)}
onClick={onToggle}
data-testid={`document-annotation-count-${docKey}`}
aria-label={openCount === 0
? `Open comments on ${docKey}`
: `Open ${openCount} unresolved comments on ${docKey}`}
aria-expanded={panelOpen}
>
<MessageSquare className="h-3 w-3" aria-hidden="true" />
<span className="tabular-nums">{openCount}</span>
<span className="hidden sm:inline">
{openCount === 1 ? "comment" : "comments"}
</span>
</Button>
);
}