mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +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>
131 lines
8.1 KiB
TypeScript
131 lines
8.1 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { DocumentAnnotationComment, DocumentAnnotationThreadStatus, DocumentAnnotationThreadWithComments } from "@paperclipai/shared";
|
|
import { authApi } from "@/api/auth";
|
|
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import type { PendingAnchor } from "@/components/DocumentAnnotationLayer";
|
|
|
|
interface MutationOptions {
|
|
target: DocumentAnnotationTarget;
|
|
baseRevisionId: string | null;
|
|
baseRevisionNumber: number;
|
|
pendingAnchor: PendingAnchor | null;
|
|
onFocusThread: (threadId: string | null) => void;
|
|
onThreadCreated?: (thread: DocumentAnnotationThreadWithComments) => void;
|
|
onReplyAdded?: (threadId: string) => void;
|
|
}
|
|
|
|
export function useDocumentAnnotationMutations(options: MutationOptions) {
|
|
const queryClient = useQueryClient();
|
|
const [mutationError, setMutationError] = useState<string | null>(null);
|
|
const { data: session } = useQuery({
|
|
queryKey: queryKeys.auth.session,
|
|
queryFn: () => authApi.getSession(),
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
const currentUser = useMemo(() => ({
|
|
id: session?.user?.id ?? null,
|
|
name: session?.user?.name?.trim() || session?.user?.email?.trim() || "You",
|
|
image: session?.user?.image ?? null,
|
|
}), [session]);
|
|
const queryKey = useMemo(() => options.target.kind === "routine"
|
|
? queryKeys.routines.documentAnnotations(options.target.routineId, options.target.documentKey, "all")
|
|
: options.target.kind === "case"
|
|
? queryKeys.cases.documentAnnotations(options.target.caseId, options.target.documentKey, "all")
|
|
: queryKeys.issues.documentAnnotations(options.target.issueId, options.target.documentKey, "all"), [options.target]);
|
|
const invalidateAll = useCallback(() => queryClient.invalidateQueries({
|
|
predicate: (query) => Array.isArray(query.queryKey)
|
|
&& query.queryKey[1] === "document-annotations"
|
|
&& query.queryKey[2] === (options.target.kind === "issue" ? options.target.issueId : options.target.kind === "case" ? options.target.caseId : options.target.routineId)
|
|
&& query.queryKey[3] === options.target.documentKey,
|
|
}), [options.target, queryClient]);
|
|
|
|
const createThread = useMutation({
|
|
mutationFn: async (body: string) => {
|
|
if (!options.pendingAnchor) throw new Error("No selection to anchor to.");
|
|
if (!options.baseRevisionId) throw new Error("Document has no revision yet.");
|
|
return documentAnnotationsApi.createForTarget(options.target, {
|
|
baseRevisionId: options.baseRevisionId,
|
|
baseRevisionNumber: options.baseRevisionNumber,
|
|
selector: options.pendingAnchor.selector,
|
|
body,
|
|
});
|
|
},
|
|
onMutate: async (body) => {
|
|
if (!options.pendingAnchor || !options.baseRevisionId) return undefined;
|
|
setMutationError(null);
|
|
await queryClient.cancelQueries({ queryKey });
|
|
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(queryKey);
|
|
const optimistic = buildOptimisticThread(body, options, currentUser);
|
|
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(queryKey, (current) => [...(current ?? []), optimistic]);
|
|
options.onFocusThread(optimistic.id);
|
|
return { previous, optimisticId: optimistic.id };
|
|
},
|
|
onError: (error, _body, context) => {
|
|
if (context?.previous) queryClient.setQueryData(queryKey, context.previous);
|
|
setMutationError(messageFor(error, "Failed to create comment."));
|
|
},
|
|
onSuccess: (thread, _body, context) => {
|
|
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(queryKey, (current) =>
|
|
(current ?? []).map((entry) => entry.id === context?.optimisticId ? thread : entry));
|
|
setMutationError(null);
|
|
options.onFocusThread(thread.id);
|
|
options.onThreadCreated?.(thread);
|
|
},
|
|
onSettled: invalidateAll,
|
|
});
|
|
|
|
const addReply = useMutation({
|
|
mutationFn: ({ threadId, body }: { threadId: string; body: string }) => documentAnnotationsApi.addCommentForTarget(options.target, threadId, { body }),
|
|
onMutate: async ({ threadId, body }) => {
|
|
setMutationError(null);
|
|
await queryClient.cancelQueries({ queryKey });
|
|
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(queryKey);
|
|
const optimistic = buildOptimisticComment(body, threadId, options.target, currentUser.id);
|
|
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(queryKey, (current) => (current ?? []).map((thread) =>
|
|
thread.id === threadId ? { ...thread, comments: [...thread.comments, optimistic], updatedAt: optimistic.createdAt } : thread));
|
|
return { previous };
|
|
},
|
|
onError: (error, _variables, context) => {
|
|
if (context?.previous) queryClient.setQueryData(queryKey, context.previous);
|
|
setMutationError(messageFor(error, "Failed to add reply."));
|
|
},
|
|
onSuccess: (_comment, variables) => {
|
|
setMutationError(null);
|
|
options.onReplyAdded?.(variables.threadId);
|
|
},
|
|
onSettled: invalidateAll,
|
|
});
|
|
|
|
const updateStatus = useMutation({
|
|
mutationFn: ({ threadId, status }: { threadId: string; status: DocumentAnnotationThreadStatus }) => documentAnnotationsApi.updateStatusForTarget(options.target, threadId, status),
|
|
onMutate: async ({ threadId, status }) => {
|
|
setMutationError(null);
|
|
await queryClient.cancelQueries({ queryKey });
|
|
const previous = queryClient.getQueryData<DocumentAnnotationThreadWithComments[]>(queryKey);
|
|
queryClient.setQueryData<DocumentAnnotationThreadWithComments[]>(queryKey, (current) => (current ?? []).map((thread) => thread.id === threadId ? { ...thread, status } : thread));
|
|
return { previous };
|
|
},
|
|
onError: (error, _variables, context) => {
|
|
if (context?.previous) queryClient.setQueryData(queryKey, context.previous);
|
|
setMutationError(messageFor(error, "Failed to update comment status."));
|
|
},
|
|
onSuccess: () => setMutationError(null),
|
|
onSettled: invalidateAll,
|
|
});
|
|
return { createThread, addReply, updateStatus, mutationError, currentUser };
|
|
}
|
|
|
|
function messageFor(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; }
|
|
function optimisticId(prefix: string) { return `${prefix}-${typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`}`; }
|
|
function buildOptimisticComment(body: string, threadId: string, target: DocumentAnnotationTarget, userId: string | null): DocumentAnnotationComment {
|
|
const now = new Date();
|
|
return { id: optimisticId("optimistic-comment"), companyId: "", threadId, issueId: target.kind === "issue" ? target.issueId : null, routineId: target.kind === "routine" ? target.routineId : null, caseId: target.kind === "case" ? target.caseId : null, documentId: "", body, authorType: "user", authorAgentId: null, authorUserId: userId, createdByRunId: null, issueCommentId: null, createdAt: now, updatedAt: now };
|
|
}
|
|
function buildOptimisticThread(body: string, options: MutationOptions, user: { id: string | null }): DocumentAnnotationThreadWithComments {
|
|
const id = optimisticId("optimistic-thread");
|
|
const now = new Date();
|
|
return { id, issueId: options.target.kind === "issue" ? options.target.issueId : null, routineId: options.target.kind === "routine" ? options.target.routineId : null, caseId: options.target.kind === "case" ? options.target.caseId : null, documentKey: options.target.documentKey, status: "open", anchorState: "active", selectedText: options.pendingAnchor!.selectedText, normalizedStart: options.pendingAnchor!.selector.position.normalizedStart, markdownStart: options.pendingAnchor!.selector.position.markdownStart, originalRevisionId: options.baseRevisionId!, originalRevisionNumber: options.baseRevisionNumber, currentRevisionId: options.baseRevisionId!, currentRevisionNumber: options.baseRevisionNumber, createdByUserId: user.id, createdAt: now, updatedAt: now, comments: [buildOptimisticComment(body, id, options.target, user.id)] } as unknown as DocumentAnnotationThreadWithComments;
|
|
}
|