Files
PaperClipAI/ui/src/components/DocumentAnnotationPopover.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

155 lines
6.8 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type { Agent, DocumentAnnotationThreadWithComments } from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { DocumentAnnotationTarget } from "@/api/document-annotations";
import type { CompanyUserProfile } from "@/lib/company-members";
import { useDocumentAnnotationMutations } from "@/hooks/useDocumentAnnotationMutations";
import type { AnnotationAnchorRect, PendingAnchor } from "./DocumentAnnotationLayer";
import { copyAnnotationLink, ThreadCard, truncate } from "./DocumentAnnotationPanel";
export interface DocumentAnnotationPopoverProps {
anchorRect: AnnotationAnchorRect;
containerRef: React.RefObject<HTMLElement | null>;
target: DocumentAnnotationTarget;
documentKey: string;
baseRevisionId: string | null;
baseRevisionNumber: number;
pendingAnchor: PendingAnchor | null;
thread: DocumentAnnotationThreadWithComments | null;
focusedCommentId: string | null;
onFocusThread: (threadId: string | null) => void;
onClose: () => void;
onThreadCreated: (thread: DocumentAnnotationThreadWithComments) => void;
newCommentDisabled?: boolean;
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
}
export function DocumentAnnotationPopover(props: DocumentAnnotationPopoverProps) {
const cardRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const [composer, setComposer] = useState("");
const [reply, setReply] = useState("");
const target = useMemo(() => props.target, [props.target]);
const { createThread, addReply, updateStatus, mutationError } = useDocumentAnnotationMutations({
target,
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
pendingAnchor: props.pendingAnchor,
onFocusThread: props.onFocusThread,
onThreadCreated: props.onThreadCreated,
onReplyAdded: () => setReply(""),
});
useEffect(() => composerRef.current?.focus(), [props.pendingAnchor]);
useEffect(() => {
const dismiss = (event: PointerEvent) => {
if (!cardRef.current?.contains(event.target as Node)) props.onClose();
};
const escape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
props.onClose();
}
};
document.addEventListener("pointerdown", dismiss);
document.addEventListener("keydown", escape);
return () => {
document.removeEventListener("pointerdown", dismiss);
document.removeEventListener("keydown", escape);
};
}, [props.onClose]);
const cardHeight = cardRef.current?.offsetHeight ?? 260;
const containerHeight = visibleBottomWithinContainer(props.containerRef.current);
const below = props.anchorRect.top + props.anchorRect.height + 6;
const top = below + cardHeight <= containerHeight
? below
: Math.max(0, props.anchorRect.top - cardHeight - 6);
const containerWidth = props.containerRef.current?.clientWidth ?? 320;
const left = Math.max(0, Math.min(props.anchorRect.left, containerWidth - 320));
const submitComposer = () => {
const body = composer.trim();
if (body && !createThread.isPending && props.baseRevisionId) createThread.mutate(body);
};
return (
<div
ref={cardRef}
role="dialog"
aria-label={props.pendingAnchor ? "Add annotation comment" : "Annotation thread"}
data-testid="document-annotation-popover"
className="absolute z-(--z-20) w-80 max-w-full rounded-lg border border-border bg-popover text-popover-foreground shadow-xl"
style={{ top, left }}
>
{mutationError ? <p className="border-b border-border bg-destructive/10 px-3 py-2 text-xs text-destructive">{mutationError}</p> : null}
{props.pendingAnchor ? (
<div className="p-3">
<blockquote className="mb-2 line-clamp-2 rounded-md bg-muted px-2 py-1 text-xs italic text-muted-foreground">
{truncate(props.pendingAnchor.selectedText, 120)}
</blockquote>
<Textarea
ref={composerRef}
data-testid="document-annotation-popover-composer"
rows={3}
value={composer}
onChange={(event) => setComposer(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
submitComposer();
}
}}
placeholder="Write a comment…"
disabled={props.newCommentDisabled || createThread.isPending}
className="resize-y text-sm"
/>
<div className="mt-2 flex justify-end gap-2">
<Button type="button" size="sm" variant="ghost" onClick={props.onClose}>Cancel</Button>
<Button type="button" size="sm" disabled={!composer.trim() || createThread.isPending || props.newCommentDisabled || !props.baseRevisionId} onClick={submitComposer}>
{createThread.isPending ? "Posting…" : "Comment"}
</Button>
</div>
</div>
) : props.thread ? (
<ul className="p-2">
<ThreadCard
thread={props.thread}
expanded
focusedCommentId={props.focusedCommentId}
onFocus={() => props.onFocusThread(props.thread!.id)}
replyDraft={reply}
onReplyChange={setReply}
onSubmitReply={() => {
const body = reply.trim();
if (body) addReply.mutate({ threadId: props.thread!.id, body });
}}
onResolveToggle={() => updateStatus.mutate({ threadId: props.thread!.id, status: props.thread!.status === "resolved" ? "open" : "resolved" })}
onCopyLink={() => copyAnnotationLink(props.documentKey, props.thread!.id)}
pendingReply={addReply.isPending}
pendingStatus={updateStatus.isPending}
agentMap={props.agentMap}
userProfileMap={props.userProfileMap}
/>
</ul>
) : null}
</div>
);
}
function visibleBottomWithinContainer(container: HTMLElement | null): number {
if (!container || typeof window === "undefined") return Number.POSITIVE_INFINITY;
const containerRect = container.getBoundingClientRect();
let visibleBottom = window.innerHeight;
let parent = container.parentElement;
while (parent) {
const style = window.getComputedStyle(parent);
if ([style.overflow, style.overflowY].some((value) => value === "auto" || value === "scroll" || value === "hidden" || value === "clip")) {
visibleBottom = Math.min(visibleBottom, parent.getBoundingClientRect().bottom);
}
parent = parent.parentElement;
}
return visibleBottom - containerRect.top;
}