mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## 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>
155 lines
6.8 KiB
TypeScript
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;
|
|
}
|