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>
This commit is contained in:
authored and GitHub committed 2026-08-12 11:41:40 -07:00
1 parent ff5fd62d07
commit 0ee0543e5b
36 files changed
+1603 -664

No files matched your search

+18 -265
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef, useState, useEffect } from "react";
import type {
DocumentAnnotationComment,
DocumentAnnotationThreadStatus,
DocumentAnnotationThreadWithComments,
} from "@paperclipai/shared";
import {
@@ -23,9 +21,7 @@ import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, relativeTime } from "@/lib/utils";
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import type { DocumentAnnotationTarget } from "@/api/document-annotations";
import { copyTextToClipboard } from "@/lib/clipboard";
import { AgentIcon } from "./AgentIconPicker";
import { deriveInitials } from "./Identity";
@@ -33,6 +29,7 @@ import { MarkdownBody } from "./MarkdownBody";
import type { PendingAnchor } from "./DocumentAnnotationLayer";
import type { Agent } from "@paperclipai/shared";
import type { CompanyUserProfile } from "@/lib/company-members";
import { useDocumentAnnotationMutations } from "@/hooks/useDocumentAnnotationMutations";
export interface AnnotationPanelProps {
open: boolean;
@@ -105,11 +102,9 @@ export function DocumentAnnotationPanel(props: AnnotationPanelProps) {
);
}
function AnnotationPanelBody(props: AnnotationPanelProps) {
const queryClient = useQueryClient();
export function AnnotationPanelBody(props: AnnotationPanelProps) {
const [composerValue, setComposerValue] = useState("");
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const bodyTestId = props.isMobile ? "document-annotation-panel" : undefined;
const annotationTarget = useMemo<DocumentAnnotationTarget>(() => {
@@ -118,19 +113,18 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
return { kind: "issue", issueId: props.issueId, documentKey: props.documentKey };
}, [props.documentKey, props.issueId, props.target]);
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
staleTime: 5 * 60_000,
const { createThread, addReply, updateStatus, mutationError, currentUser } = useDocumentAnnotationMutations({
target: annotationTarget,
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
pendingAnchor: props.pendingAnchor,
onFocusThread: props.onFocusThread,
onThreadCreated: () => {
props.onClearPendingAnchor();
setComposerValue("");
},
onReplyAdded: (threadId) => setReplyDrafts((current) => ({ ...current, [threadId]: "" })),
});
const currentUser = useMemo(() => {
const user = session?.user;
return {
id: user?.id ?? null,
name: user?.name?.trim() || user?.email?.trim() || "You",
image: user?.image ?? null,
};
}, [session]);
// Show every thread that can be anchored in the document (orphaned threads have
// lost their anchor). Filters were removed in favour of a single simple list.
@@ -147,164 +141,6 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
[props.threads],
);
const annotationsQueryKey = useMemo(
() => annotationTarget.kind === "routine"
? queryKeys.routines.documentAnnotations(annotationTarget.routineId, annotationTarget.documentKey, "all")
: annotationTarget.kind === "case"
? queryKeys.cases.documentAnnotations(annotationTarget.caseId, annotationTarget.documentKey, "all")
: queryKeys.issues.documentAnnotations(annotationTarget.issueId, annotationTarget.documentKey, "all"),
[annotationTarget],
);
const invalidateAll = useCallback(() => {
queryClient.invalidateQueries({
predicate: (query) => {
if (!Array.isArray(query.queryKey)) return false;
if (annotationTarget.kind === "routine") {
return query.queryKey[0] === "routines"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === annotationTarget.routineId
&& query.queryKey[3] === annotationTarget.documentKey;
}
if (annotationTarget.kind === "case") {
return query.queryKey[0] === "cases"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === annotationTarget.caseId
&& query.queryKey[3] === annotationTarget.documentKey;
}
return query.queryKey[0] === "issues"
&& query.queryKey[1] === "document-annotations"
&& query.queryKey[2] === annotationTarget.issueId
&& query.queryKey[3] === annotationTarget.documentKey;
},
});
}, [annotationTarget, queryClient]);
const createThread = useMutation({
mutationFn: async (body: string) => {
if (!props.pendingAnchor) throw new Error("No selection to anchor to.");
if (!props.baseRevisionId) throw new Error("Document has no revision yet.");
return documentAnnotationsApi.createForTarget(annotationTarget, {
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
selector: props.pendingAnchor.selector,
body,
});
},
// Optimistically drop the new thread into the cache so submission feels instant.
onMutate: async (body: string) => {
const anchor = props.pendingAnchor;
if (!anchor || !props.baseRevisionId) return undefined;
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
const optimisticThread = buildOptimisticThread({
body,
selectedText: anchor.selectedText,
target: annotationTarget,
documentKey: annotationTarget.documentKey,
baseRevisionId: props.baseRevisionId,
baseRevisionNumber: props.baseRevisionNumber,
normalizedStart: anchor.selector.position.normalizedStart,
markdownStart: anchor.selector.position.markdownStart,
author: currentUser,
});
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => [...(current ?? []), optimisticThread],
);
props.onFocusThread(optimisticThread.id);
return { previous, optimisticId: optimisticThread.id };
},
onError: (error, _body, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to create comment.");
},
onSuccess: (thread, _body, context) => {
// Swap the optimistic placeholder for the real thread before refetch settles.
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((entry) =>
entry.id === context?.optimisticId ? thread : entry,
),
);
props.onClearPendingAnchor();
setComposerValue("");
setMutationError(null);
props.onFocusThread(thread.id);
},
onSettled: () => invalidateAll(),
});
const addReply = useMutation({
mutationFn: ({ threadId, body }: { threadId: string; body: string }) =>
documentAnnotationsApi.addCommentForTarget(annotationTarget, threadId, { body }),
// Optimistically append the reply so it stays on screen through the round-trip.
onMutate: async ({ threadId, body }) => {
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
const optimisticComment = buildOptimisticComment({
body,
threadId,
target: annotationTarget,
author: currentUser,
});
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((thread) =>
thread.id === threadId
? { ...thread, comments: [...thread.comments, optimisticComment], updatedAt: optimisticComment.createdAt }
: thread,
),
);
return { previous };
},
onError: (error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to add reply.");
},
onSuccess: (_comment, variables) => {
setReplyDrafts((current) => ({ ...current, [variables.threadId]: "" }));
setMutationError(null);
},
onSettled: () => invalidateAll(),
});
const updateStatus = useMutation({
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) =>
documentAnnotationsApi.updateStatusForTarget(annotationTarget, threadId, status),
onMutate: async ({ threadId, status }) => {
setMutationError(null);
await queryClient.cancelQueries({ queryKey: annotationsQueryKey });
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(annotationsQueryKey);
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(
annotationsQueryKey,
(current) => (current ?? []).map((thread) =>
thread.id === threadId ? { ...thread, status } : thread,
),
);
return { previous };
},
onError: (error, _variables, context) => {
if (context?.previous) {
queryClient.setQueryData(annotationsQueryKey, context.previous);
}
setMutationError(error instanceof Error && error.message
? error.message
: "Failed to update comment status.");
},
onSuccess: () => setMutationError(null),
onSettled: () => invalidateAll(),
});
useEffect(() => {
if (!props.open) {
@@ -478,7 +314,7 @@ function AnnotationPanelBody(props: AnnotationPanelProps) {
);
}
function ThreadCard(props: {
export function ThreadCard(props: {
thread: DocumentAnnotationThreadWithComments;
expanded: boolean;
focusedCommentId: string | null;
@@ -695,95 +531,12 @@ function resolveAuthor(
return { name: comment.authorType === "agent" ? "Agent" : "Board", role: comment.authorType === "agent" ? "agent" : "board" };
}
interface OptimisticAuthor {
id: string | null;
name: string;
image: string | null;
}
function optimisticId(prefix: string): string {
const random = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
return `${prefix}-${random}`;
}
function buildOptimisticComment(input: {
body: string;
threadId: string;
target: DocumentAnnotationTarget;
author: OptimisticAuthor;
}): DocumentAnnotationComment {
const now = new Date();
return {
id: optimisticId("optimistic-comment"),
companyId: "",
threadId: input.threadId,
issueId: input.target.kind === "issue" ? input.target.issueId : null,
routineId: input.target.kind === "routine" ? input.target.routineId : null,
caseId: input.target.kind === "case" ? input.target.caseId : null,
documentId: "",
body: input.body,
authorType: "user",
authorAgentId: null,
authorUserId: input.author.id,
createdByRunId: null,
issueCommentId: null,
createdAt: now,
updatedAt: now,
};
}
function buildOptimisticThread(input: {
body: string;
selectedText: string;
target: DocumentAnnotationTarget;
documentKey: string;
baseRevisionId: string;
baseRevisionNumber: number;
normalizedStart: number;
markdownStart: number;
author: OptimisticAuthor;
}): DocumentAnnotationThreadWithComments {
const id = optimisticId("optimistic-thread");
const now = new Date();
const comment = buildOptimisticComment({
body: input.body,
threadId: id,
target: input.target,
author: input.author,
});
// Only the fields the panel + overlay read need to be accurate; the optimistic
// thread is swapped for the server copy on success. Cast through unknown so we
// don't have to fabricate every backend-only column.
return {
id,
issueId: input.target.kind === "issue" ? input.target.issueId : null,
routineId: input.target.kind === "routine" ? input.target.routineId : null,
caseId: input.target.kind === "case" ? input.target.caseId : null,
documentKey: input.documentKey,
status: "open",
anchorState: "active",
selectedText: input.selectedText,
normalizedStart: input.normalizedStart,
markdownStart: input.markdownStart,
originalRevisionId: input.baseRevisionId,
originalRevisionNumber: input.baseRevisionNumber,
currentRevisionId: input.baseRevisionId,
currentRevisionNumber: input.baseRevisionNumber,
createdByUserId: input.author.id,
createdAt: now,
updatedAt: now,
comments: [comment],
} as unknown as DocumentAnnotationThreadWithComments;
}
function truncate(value: string, limit: number) {
export function truncate(value: string, limit: number) {
if (value.length <= limit) return value;
return `${value.slice(0, limit - 1)}…`;
}
async function copyAnnotationLink(documentKey: string, threadId: string) {
export async function copyAnnotationLink(documentKey: string, threadId: string) {
if (typeof window === "undefined") return;
const { pathname } = window.location;
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;