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:
scotttong 2026-08-12 11:41:40 -07:00 committed by GitHub
parent ff5fd62d07
commit 0ee0543e5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1603 additions and 664 deletions

View File

@ -34,16 +34,25 @@ export interface PendingAnchor {
selectedText: string;
}
export interface AnnotationAnchorRect {
top: number;
left: number;
width: number;
height: number;
}
export interface AnnotationLayerProps {
containerRef: React.RefObject<HTMLElement | null>;
markdown: string;
threads: AnnotationOverlayThread[];
focusedThreadId: string | null;
onThreadFocus: (threadId: string) => void;
onThreadFocus: (threadId: string, rect: AnnotationAnchorRect) => void;
/** Tracks the most recently captured pending selection. */
pendingAnchor: PendingAnchor | null;
onPendingAnchorChange: (anchor: PendingAnchor | null) => void;
onRequestComment: (anchor: PendingAnchor) => void;
onRequestComment: (anchor: PendingAnchor, rect: AnnotationAnchorRect) => void;
/** Publishes refreshed geometry for the currently open popover. */
onAnchorRectChange?: (rect: AnnotationAnchorRect | null) => void;
/** Disables the "add comment" affordance when set. */
newCommentDisabled?: boolean;
newCommentDisabledReason?: string | null;
@ -236,6 +245,7 @@ export function DocumentAnnotationLayer({
pendingAnchor,
onPendingAnchorChange,
onRequestComment,
onAnchorRectChange,
newCommentDisabled = false,
newCommentDisabledReason = null,
hideResolved = true,
@ -247,6 +257,7 @@ export function DocumentAnnotationLayer({
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const lastCaptureSelectionRequestIdRef = useRef<number>(0);
const lastSelectionRectRef = useRef<AnnotationAnchorRect | null>(null);
const reactId = useId();
const nativeHighlightInstanceId = useMemo(
() => `document-annotation-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
@ -340,7 +351,16 @@ export function DocumentAnnotationLayer({
}
setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges);
setHighlightRects(next);
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, pendingHighlightText, visibleThreads]);
const activeId = pendingHighlightText ? PENDING_HIGHLIGHT_THREAD_ID : focusedThreadId;
const activeRects = activeId ? next.filter((rect) => rect.threadId === activeId) : [];
const activeRect = activeRects.find((rect) => rect.isTail) ?? activeRects[0];
onAnchorRectChange?.(activeRect ? {
top: activeRect.top,
left: activeRect.left,
width: activeRect.width,
height: activeRect.height,
} : null);
}, [containerRef, focusedThreadId, nativeHighlightInstanceId, onAnchorRectChange, pendingHighlightText, visibleThreads]);
useLayoutEffect(() => {
computeHighlightRects();
@ -428,6 +448,12 @@ export function DocumentAnnotationLayer({
const top = Math.max(0, rect.top - overlayRect.top - 36);
const left = Math.max(0, rect.left - overlayRect.left + rect.width / 2 - 80);
setToolbarPosition({ top, left });
lastSelectionRectRef.current = {
top: rect.top - overlayRect.top,
left: rect.left - overlayRect.left,
width: rect.width,
height: rect.height,
};
return {
selector: anchor.selector,
selectedText: containerOffset.selectedText,
@ -467,12 +493,12 @@ export function DocumentAnnotationLayer({
const anchor = captureSelection();
if (anchor) {
onPendingAnchorChange(anchor);
onRequestComment(anchor);
if (lastSelectionRectRef.current) onRequestComment(anchor, lastSelectionRectRef.current);
}
}, [captureSelectionRequestId, captureSelection, onPendingAnchorChange, onRequestComment]);
const handleAddComment = () => {
if (pendingAnchor) onRequestComment(pendingAnchor);
if (pendingAnchor && lastSelectionRectRef.current) onRequestComment(pendingAnchor, lastSelectionRectRef.current);
};
const content = (
@ -551,7 +577,12 @@ export function DocumentAnnotationLayer({
}
onMouseDown={(event) => {
event.preventDefault();
onThreadFocus(rect.threadId);
onThreadFocus(rect.threadId, {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
});
}}
/>
);

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)}`;

View File

@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { createRef } from "react";
import { createRoot } from "react-dom/client";
import type { DocumentAnnotationThreadWithComments } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DocumentAnnotationPopover } from "./DocumentAnnotationPopover";
const mutations = vi.hoisted(() => ({
create: vi.fn(),
reply: vi.fn(),
status: vi.fn(),
}));
vi.mock("@/hooks/useDocumentAnnotationMutations", () => ({
useDocumentAnnotationMutations: () => ({
createThread: { mutate: mutations.create, isPending: false },
addReply: { mutate: mutations.reply, isPending: false },
updateStatus: { mutate: mutations.status, isPending: false },
mutationError: null,
}),
}));
vi.mock("./MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <p>{children}</p> }));
const pendingAnchor = {
selector: {
quote: { exact: "selected plan text", prefix: "", suffix: "" },
position: { normalizedStart: 0, normalizedEnd: 18, markdownStart: 0, markdownEnd: 18 },
},
selectedText: "selected plan text",
};
function thread(): DocumentAnnotationThreadWithComments {
return {
id: "thread-1",
status: "open",
anchorState: "active",
selectedText: "selected plan text",
comments: [{
id: "comment-1",
threadId: "thread-1",
body: "Initial comment",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
}],
} as unknown as DocumentAnnotationThreadWithComments;
}
describe("DocumentAnnotationPopover", () => {
let host: HTMLDivElement;
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
vi.clearAllMocks();
host = document.createElement("div");
container = document.createElement("div");
Object.defineProperties(container, { clientWidth: { value: 500 }, clientHeight: { value: 500 } });
host.appendChild(container);
document.body.appendChild(host);
root = createRoot(container);
});
afterEach(async () => {
await vi.waitFor(() => root.unmount());
host.remove();
});
const render = async (overrides: Partial<React.ComponentProps<typeof DocumentAnnotationPopover>> = {}) => {
const onClose = vi.fn();
const props: React.ComponentProps<typeof DocumentAnnotationPopover> = {
anchorRect: { top: 40, left: 60, width: 100, height: 20 },
containerRef: createRef<HTMLDivElement>(),
target: { kind: "issue", issueId: "issue-1", documentKey: "plan" },
documentKey: "plan",
baseRevisionId: "rev-1",
baseRevisionNumber: 1,
pendingAnchor,
thread: null,
focusedCommentId: null,
onFocusThread: vi.fn(),
onClose,
onThreadCreated: vi.fn(),
...overrides,
};
props.containerRef.current = container;
root.render(<DocumentAnnotationPopover {...props} />);
await vi.waitFor(() => expect(container.querySelector('[data-testid="document-annotation-popover"]')).not.toBeNull());
return { onClose };
};
it("anchors compose mode and submits with the keyboard shortcut", async () => {
await render();
const card = container.querySelector('[data-testid="document-annotation-popover"]') as HTMLElement;
expect(card.style.top).toBe("66px");
expect(card.style.left).toBe("60px");
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
setter?.call(textarea, "Looks good");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true }));
await vi.waitFor(() => expect(mutations.create).toHaveBeenCalledWith("Looks good"));
});
it("dismisses on Escape and outside pointer down", async () => {
const first = await render();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
expect(first.onClose).toHaveBeenCalledTimes(1);
document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
expect(first.onClose).toHaveBeenCalledTimes(2);
});
it("replies to and resolves a focused thread", async () => {
await render({ pendingAnchor: null, thread: thread() });
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
setter?.call(textarea, "A reply");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
await vi.waitFor(() => expect(textarea.value).toBe("A reply"));
const buttons = Array.from(container.querySelectorAll("button"));
buttons.find((button) => button.textContent?.includes("Reply"))?.click();
buttons.find((button) => button.textContent?.includes("Resolve"))?.click();
await vi.waitFor(() => {
expect(mutations.reply).toHaveBeenCalledWith({ threadId: "thread-1", body: "A reply" });
expect(mutations.status).toHaveBeenCalledWith({ threadId: "thread-1", status: "resolved" });
});
});
});

View File

@ -0,0 +1,154 @@
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;
}

View File

@ -135,4 +135,89 @@ describe("InlineEntitySelector", () => {
root.unmount();
});
});
it("does not open the popover when disabled", async () => {
const root = createRoot(container);
const onChange = vi.fn();
act(() => {
root.render(
<InlineEntitySelector
value=""
options={[{ id: "agent:agent-1", label: "CodexCoder" }]}
placeholder="Responsible"
noneLabel="No responsible"
searchPlaceholder="Search responsible..."
emptyMessage="No responsible found."
onChange={onChange}
disabled
/>,
);
});
const trigger = container.querySelector("button") as HTMLButtonElement | null;
expect(trigger).not.toBeNull();
expect(trigger?.disabled).toBe(true);
await act(async () => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.querySelector('input[placeholder="Search responsible..."]')).toBeNull();
expect(onChange).not.toHaveBeenCalled();
act(() => {
root.unmount();
});
});
it("filters options as the user types in the search box", async () => {
const root = createRoot(container);
const onChange = vi.fn();
act(() => {
root.render(
<InlineEntitySelector
value=""
options={[
{ id: "agent:agent-1", label: "CodexCoder" },
{ id: "agent:agent-2", label: "DesignBot" },
]}
placeholder="Responsible"
noneLabel="No responsible"
searchPlaceholder="Search responsible..."
emptyMessage="No responsible found."
onChange={onChange}
/>,
);
});
const trigger = container.querySelector("button") as HTMLButtonElement | null;
await act(async () => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const searchInput = document.querySelector('input[placeholder="Search responsible..."]') as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
const nativeInputValue = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
nativeInputValue?.call(searchInput, "design");
searchInput?.dispatchEvent(new Event("input", { bubbles: true }));
});
const optionLabels = Array.from(document.querySelectorAll("[role='dialog'] button, .max-h-56 button")).map(
(el) => el.textContent ?? "",
);
const joined = optionLabels.join("|");
expect(joined).toContain("DesignBot");
expect(joined).not.toContain("CodexCoder");
act(() => {
root.unmount();
});
});
});

View File

@ -27,6 +27,10 @@ interface InlineEntitySelectorProps {
disablePortal?: boolean;
/** Open the popover when the trigger receives keyboard/programmatic focus. */
openOnFocus?: boolean;
/** Disable the trigger and prevent the popover from opening. */
disabled?: boolean;
/** Optional test id forwarded to the trigger button. */
triggerTestId?: string;
}
const EMPTY_RECENT_OPTION_IDS: string[] = [];
@ -48,6 +52,8 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
recentOptionIds = EMPTY_RECENT_OPTION_IDS,
disablePortal,
openOnFocus = true,
disabled = false,
triggerTestId,
},
ref,
) {
@ -104,6 +110,7 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
<Popover
open={open}
onOpenChange={(next) => {
if (disabled) return;
setOpen(next);
if (!next) setQuery("");
}}
@ -112,12 +119,15 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
<button
ref={ref}
type="button"
disabled={disabled}
data-testid={triggerTestId}
className={cn(
"inline-flex min-w-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"inline-flex min-w-0 items-center gap-1 rounded-md border border-border bg-muted/40 px-2 py-1 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50 disabled:pointer-events-none",
className,
)}
onPointerDown={() => { isPointerDownRef.current = true; }}
onFocus={() => {
if (disabled) return;
if (openOnFocus && !isPointerDownRef.current) setOpen(true);
isPointerDownRef.current = false;
}}

View File

@ -100,7 +100,8 @@ vi.mock("./DocumentAnnotationLayer", () => ({
DocumentAnnotationLayer: (props: {
newCommentDisabled?: boolean;
onPendingAnchorChange: (anchor: typeof mockPendingAnchor | null) => void;
onRequestComment: (anchor: typeof mockPendingAnchor) => void;
onRequestComment: (anchor: typeof mockPendingAnchor, rect: { top: number; left: number; width: number; height: number }) => void;
onThreadFocus: (threadId: string, rect: { top: number; left: number; width: number; height: number }) => void;
}) => (
<>
<button
@ -109,12 +110,19 @@ vi.mock("./DocumentAnnotationLayer", () => ({
disabled={props.newCommentDisabled}
onClick={() => {
props.onPendingAnchorChange(mockPendingAnchor);
props.onRequestComment(mockPendingAnchor);
props.onRequestComment(mockPendingAnchor, { top: 24, left: 32, width: 80, height: 18 });
props.onPendingAnchorChange(null);
}}
>
Mock selection
</button>
<button
type="button"
data-testid="mock-annotation-thread"
onClick={() => props.onThreadFocus("thread-1", { top: 42, left: 48, width: 90, height: 18 })}
>
Mock thread
</button>
<button
type="button"
data-testid="mock-annotation-selection-only"
@ -294,7 +302,7 @@ function Harness({
historicalPreview?: boolean;
locationHash?: string;
initialPanelOpen?: boolean;
panelPlacement?: "floating" | "inline";
panelPlacement?: "floating" | "inline" | "popover";
}) {
const [open, setOpen] = useState(initialPanelOpen);
return (
@ -366,8 +374,11 @@ describe("IssueDocumentAnnotations", () => {
expect(panel).not.toBeNull();
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]');
expect(anchor).not.toBeNull();
expect(anchor?.className).toContain("fixed");
expect(anchor?.className).toContain("z-(--z-60)");
// The desktop panel docks into an in-flow gutter column beside the doc; it no
// longer floats over the viewport with position: fixed (PAP-504).
expect(anchor?.className).not.toContain("fixed");
expect(anchor?.className).toContain("lg:block");
expect(anchor?.querySelector(".sticky")).not.toBeNull();
});
it("stacks an inline panel below the document instead of floating over its host", async () => {
@ -394,114 +405,62 @@ describe("IssueDocumentAnnotations", () => {
expect(panel?.className).toContain("w-full");
});
it("keeps the desktop annotation panel inside the issue content area when properties are visible", async () => {
it("opens anchored compose and thread popovers in popover placement", async () => {
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const rectFor = (left: number, top: number, right: number, bottom: number) => ({
x: left,
y: top,
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
toJSON: () => ({}),
});
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
if (this instanceof HTMLElement && this.id === "main-content") {
return rectFor(0, 0, 900, 800);
}
if (
this instanceof HTMLElement
&& this.getAttribute("data-testid") === "document-annotation-body-plan"
) {
return rectFor(80, 120, 640, 620);
}
return originalGetBoundingClientRect.call(this);
});
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
try {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<main id="main-content">
<Harness doc={doc} initialPanelOpen />
</main>
</QueryClientProvider>,
);
});
await waitFor(() => {
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
const panel = container.querySelector('[data-testid="document-annotation-panel"]') as HTMLElement | null;
expect(anchor).not.toBeNull();
expect(panel).not.toBeNull();
expect(anchor!.style.left).toBe("524px");
expect(anchor!.style.width).toBe("360px");
expect(panel!.style.width).toBe("360px");
expect(parseFloat(anchor!.style.left) + parseFloat(anchor!.style.width)).toBeLessThanOrEqual(884);
});
} finally {
rectSpy.mockRestore();
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Harness doc={makeDoc()} panelPlacement="popover" />
</QueryClientProvider>,
);
});
await waitFor(() => expect(container.querySelector('[data-testid="mock-annotation-selection"]')).not.toBeNull());
await act(async () => (container.querySelector('[data-testid="mock-annotation-selection"]') as HTMLButtonElement).click());
await waitFor(() => {
expect(container.querySelector('[data-testid="document-annotation-popover"]')).not.toBeNull();
expect(container.querySelector('[data-testid="document-annotation-popover-composer"]')).not.toBeNull();
expect(container.querySelector('[data-testid="document-annotation-panel-inline"]')).toBeNull();
});
await act(async () => (container.querySelector('[aria-label="Add annotation comment"] button') as HTMLButtonElement).click());
await act(async () => (container.querySelector('[data-testid="mock-annotation-thread"]') as HTMLButtonElement).click());
await waitFor(() => {
expect(container.querySelector('[data-testid="document-annotation-popover"] [data-thread-id="thread-1"]')).not.toBeNull();
});
});
it("offsets the desktop annotation panel from the document with a left margin when there is room", async () => {
it("docks the desktop annotation panel into an in-flow gutter column beside the document", async () => {
mockAnnotationsApi.list.mockResolvedValue([makeThread()]);
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
const rectFor = (left: number, top: number, right: number, bottom: number) => ({
x: left,
y: top,
left,
top,
right,
bottom,
width: right - left,
height: bottom - top,
toJSON: () => ({}),
});
const rectSpy = vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
if (this instanceof HTMLElement && this.id === "main-content") {
return rectFor(0, 0, 1400, 800);
}
if (
this instanceof HTMLElement
&& this.getAttribute("data-testid") === "document-annotation-body-plan"
) {
return rectFor(80, 120, 640, 620);
}
return originalGetBoundingClientRect.call(this);
});
const root = createRoot(container);
const queryClient = makeQueryClient();
const doc = makeDoc();
try {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<main id="main-content">
<Harness doc={doc} initialPanelOpen />
</main>
</QueryClientProvider>,
);
});
await waitFor(() => {
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
expect(anchor).not.toBeNull();
// The document body ends at 640; the panel should clear it with a margin
// rather than sitting flush against the document's right edge.
expect(parseFloat(anchor!.style.left)).toBeGreaterThan(640);
expect(anchor!.style.left).toBe("664px");
});
} finally {
rectSpy.mockRestore();
}
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<main id="main-content">
<Harness doc={doc} initialPanelOpen />
</main>
</QueryClientProvider>,
);
});
await waitFor(() => {
const anchor = container.querySelector('[data-testid="document-annotation-panel-anchor"]') as HTMLElement | null;
const panel = container.querySelector('[data-testid="document-annotation-panel"]') as HTMLElement | null;
expect(anchor).not.toBeNull();
expect(panel).not.toBeNull();
// Gutter column: fixed width, in normal flow (no absolute/fixed positioning),
// and its host row is a flex layout so the doc sits to its left.
expect(anchor!.style.width).toBe("360px");
expect(anchor!.className).not.toContain("fixed");
expect(anchor!.style.left).toBe("");
const host = container.querySelector(".paperclip-doc-annotation-host") as HTMLElement | null;
expect(host!.className).toContain("lg:flex");
// The panel is sticky inside the gutter so it stays beside the doc while scrolling.
expect(anchor!.querySelector(".sticky")).not.toBeNull();
expect(panel!.style.width).toBe("360px");
});
});
it("auto-opens the panel and focuses the thread when deep-linked", async () => {

View File

@ -12,14 +12,15 @@ import {
isSelectionDebugEnabled,
recordAnnotationCommit,
} from "@/lib/document-annotation-debug";
import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer";
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;
const DESKTOP_ANNOTATION_PANEL_MIN_WIDTH = 280;
const DESKTOP_ANNOTATION_PANEL_GAP = 24;
const DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN = 16;
type AnnotationDocument = Pick<IssueDocument, "key" | "latestRevisionId" | "latestRevisionNumber">;
@ -43,7 +44,7 @@ export interface IssueDocumentAnnotationsProps {
panelOpen: boolean;
onPanelOpenChange: (open: boolean) => void;
/** Keep the panel in document flow for narrow hosts such as the task properties pane. */
panelPlacement?: "floating" | "inline";
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. */
@ -82,13 +83,8 @@ export function IssueDocumentAnnotations({
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 [desktopPanelFrame, setDesktopPanelFrame] = useState<{
left: number;
top: number;
maxHeight: number;
width: number;
} | null>(null);
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);
@ -106,74 +102,6 @@ export function IssueDocumentAnnotations({
return undefined;
}, []);
useEffect(() => {
if (!panelOpen || panelPlacement === "inline" || isMobile || typeof window === "undefined") {
setDesktopPanelFrame(null);
return;
}
const updatePanelFrame = () => {
const container = containerRef.current;
const rect = container?.getBoundingClientRect();
if (!container || !rect) {
setDesktopPanelFrame(null);
return;
}
const boundaryRect = container.closest("main")?.getBoundingClientRect();
const boundaryLeft = boundaryRect?.left ?? 0;
const boundaryRight = boundaryRect?.right ?? window.innerWidth;
const boundaryWidth = Math.max(0, boundaryRight - boundaryLeft);
const maxPanelWidth = Math.max(
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
boundaryWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
);
const desiredWidth = Math.min(DESKTOP_ANNOTATION_PANEL_WIDTH, maxPanelWidth);
// Clamp the panel below the sticky top nav (the scroll container's top edge)
// so the comments thread never tucks under the nav bar while scrolling.
const boundaryTop = boundaryRect?.top ?? DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
const minTop = Math.max(DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN, boundaryTop)
+ DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
const top = Math.max(minTop, rect.top);
const desiredLeft = rect.right + DESKTOP_ANNOTATION_PANEL_GAP;
const spaceRightOfDocument = boundaryRight
- desiredLeft
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
const width = spaceRightOfDocument >= DESKTOP_ANNOTATION_PANEL_MIN_WIDTH
? Math.min(desiredWidth, spaceRightOfDocument)
: desiredWidth;
const maxVisibleLeft = boundaryRight
- width
- DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN;
setDesktopPanelFrame({
left: Math.max(
boundaryLeft + DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
Math.min(desiredLeft, maxVisibleLeft),
),
top,
width,
maxHeight: Math.max(240, window.innerHeight - top - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN),
});
};
updatePanelFrame();
window.addEventListener("resize", updatePanelFrame);
window.addEventListener("scroll", updatePanelFrame, true);
const resizeObserver = typeof window.ResizeObserver === "function"
? new window.ResizeObserver(updatePanelFrame)
: null;
const observedContainer = containerRef.current;
if (resizeObserver && observedContainer) {
resizeObserver.observe(observedContainer);
const main = observedContainer.closest("main");
if (main) resizeObserver.observe(main);
}
return () => {
window.removeEventListener("resize", updatePanelFrame);
window.removeEventListener("scroll", updatePanelFrame, true);
resizeObserver?.disconnect();
};
}, [doc.key, isMobile, panelOpen, panelPlacement]);
const annotationsQuery = useQuery({
queryKey: target?.kind === "routine"
? queryKeys.routines.documentAnnotations(target.routineId, target.documentKey, "all")
@ -213,17 +141,26 @@ export function IssueDocumentAnnotations({
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) => {
const handleRequestComment = useCallback((anchor: PendingAnchor, rect?: AnnotationAnchorRect) => {
if (newCommentDisabled) return;
setSelectionAnchor(null);
setComposerAnchor(anchor);
setFocusedThreadId(null);
if (rect) setPopoverAnchorRect(rect);
onPanelOpenChange(true);
}, [newCommentDisabled, onPanelOpenChange]);
@ -237,14 +174,20 @@ export function IssueDocumentAnnotations({
onInitialComposerAnchorConsumed?.();
}, [initialComposerAnchor, newCommentDisabled, onInitialComposerAnchorConsumed, onPanelOpenChange]);
const handleThreadFocus = useCallback((threadId: string | null) => {
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) {
@ -286,29 +229,13 @@ export function IssueDocumentAnnotations({
[allThreads],
);
const fallbackDesktopPanelFrame = useMemo(() => {
if (!panelOpen || panelPlacement === "inline" || isMobile || desktopPanelFrame || typeof window === "undefined") return null;
const width = Math.min(
DESKTOP_ANNOTATION_PANEL_WIDTH,
Math.max(
DESKTOP_ANNOTATION_PANEL_MIN_WIDTH,
window.innerWidth - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
),
);
return {
left: Math.max(
DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
window.innerWidth - width - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
),
top: DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN,
maxHeight: Math.max(
240,
window.innerHeight - DESKTOP_ANNOTATION_PANEL_VIEWPORT_MARGIN * 2,
),
width,
};
}, [desktopPanelFrame, isMobile, panelOpen, panelPlacement]);
const renderedDesktopPanelFrame = desktopPanelFrame ?? fallbackDesktopPanelFrame;
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
@ -341,20 +268,27 @@ export function IssueDocumentAnnotations({
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
isMobile={isMobile}
inline={panelPlacement === "inline"}
desktopWidth={renderedDesktopPanelFrame?.width}
inline={isInlinePlacement || isPopoverPlacement}
desktopWidth={showDesktopGutter ? DESKTOP_ANNOTATION_PANEL_WIDTH : undefined}
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
) : null;
const content = (
<div className="paperclip-doc-annotation-host relative">
<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="relative min-w-0"
className={cn("relative min-w-0", showDesktopGutter && "lg:flex-1")}
data-testid={`document-annotation-body-${doc.key}`}
>
<div className="relative z-(--z-1)">
@ -370,6 +304,9 @@ export function IssueDocumentAnnotations({
pendingAnchor={selectionAnchor}
onPendingAnchorChange={handleSelectionAnchorChange}
onRequestComment={handleRequestComment}
onAnchorRectChange={isPopoverPlacement && panelOpen && (composerAnchor || focusedThreadId)
? handleAnchorRectChange
: undefined}
newCommentDisabled={newCommentDisabled}
newCommentDisabledReason={newCommentDisabledReason}
hideResolved
@ -377,24 +314,52 @@ export function IssueDocumentAnnotations({
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 && panelPlacement === "inline" && !isMobile ? (
{panelOpen && (isInlinePlacement || (isPopoverPlacement && !showPopover)) && !isMobile ? (
<div className="mt-3" data-testid="document-annotation-panel-inline">
{annotationPanel}
</div>
) : null}
{panelOpen && !isMobile && renderedDesktopPanelFrame ? (
{showDesktopGutter ? (
<div
data-testid="document-annotation-panel-anchor"
className="pointer-events-auto fixed z-(--z-60) hidden lg:block"
style={{
left: renderedDesktopPanelFrame.left,
maxHeight: renderedDesktopPanelFrame.maxHeight,
top: renderedDesktopPanelFrame.top,
width: renderedDesktopPanelFrame.width,
}}
className="hidden shrink-0 lg:block"
style={{ width: DESKTOP_ANNOTATION_PANEL_WIDTH }}
>
{annotationPanel}
{/* 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}
@ -408,6 +373,10 @@ export function IssueDocumentAnnotations({
) : 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;

View File

@ -789,7 +789,13 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("always exposes the add sub-issue action", async () => {
it("exposes the classic-layout add sub-issue pill action", async () => {
// The chat shell hosts the full tree in the center pane; the slim pill row
// + its Add sub-task button only render in the classic layout (PAP-496).
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableClassicTaskInterface: true,
});
const onAddSubIssue = vi.fn();
const root = renderProperties(container, {
issue: createIssue(),
@ -797,10 +803,13 @@ describe("IssueProperties", () => {
onAddSubIssue,
onUpdate: vi.fn(),
});
await flush();
// Wait for the classic-layout settings query to resolve (the pane starts in
// the chat shell until it does).
await waitForAssertion(() => {
expect(container.textContent).toContain("Add sub-task");
});
expect(container.textContent).toContain("Sub-tasks");
expect(container.textContent).toContain("Add sub-task");
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add sub-task"));
@ -815,6 +824,20 @@ describe("IssueProperties", () => {
act(() => root.unmount());
});
it("does not duplicate sub-tasks in the properties pane in the chat shell", async () => {
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate: vi.fn(),
});
await flush();
expect(container.textContent).not.toContain("Add sub-task");
expect(container.textContent).not.toContain("Sub-tasks");
act(() => root.unmount());
});
it("hides watchdog setup controls while the experimental flag is off", async () => {
const root = renderProperties(container, {
issue: createIssue(),
@ -1114,6 +1137,12 @@ describe("IssueProperties", () => {
});
it("collapses long blocked-by and sub-task lists until the more button is clicked", async () => {
// The sub-task pill row (with its collapse control) is classic-layout only
// now — the chat shell promotes sub-tasks to their own pane tab (PAP-496).
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableClassicTaskInterface: true,
});
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
id: `blocker-${index + 1}`,
identifier: `BLOCK-${index + 1}`,
@ -1134,11 +1163,14 @@ describe("IssueProperties", () => {
onUpdate: vi.fn(),
inline: true,
});
await flush();
// Wait for the classic-layout settings query to resolve so the sub-task
// pill row renders (the pane starts in the chat shell until it does).
await waitForAssertion(() => {
expect(container.textContent).toContain("SUB-5");
});
expect(container.textContent).toContain("BLOCK-5");
expect(container.textContent).not.toContain("BLOCK-6");
expect(container.textContent).toContain("SUB-5");
expect(container.textContent).not.toContain("SUB-6");
expect(
Array.from(container.querySelectorAll("button")).filter((button) =>

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useQuery } from "@tanstack/react-query";
import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router";
import { Sidebar } from "./Sidebar";
@ -637,6 +637,20 @@ export function Layout() {
id="main-content"
ref={mainContentRef}
tabIndex={-1}
// Publish the pinned-composer bottom offset to descendants
// (PAP-495): while the auto-hiding mobile nav is on screen, raise
// it to the nav height so a sticky composer clears the nav; drop
// it back to the safe-area dock when the nav hides. Desktop leaves
// the token at its :root default.
style={
isMobile
? ({
"--tc-composer-bottom": mobileNavVisible
? "var(--sz-calc-14)"
: "var(--sz-calc-8)",
} as CSSProperties)
: undefined
}
className={cn(
"flex-1 p-4 outline-none md:p-6",
// Reserve the scrollbar gutter on desktop so pages whose height

View File

@ -9,12 +9,13 @@ import { ThemeProvider } from "@/context/ThemeContext";
import { TaskChatThread } from "./TaskChatThread";
const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map() }));
const sidebarState = vi.hoisted(() => ({ isMobile: false }));
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => transcriptState,
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
useSidebar: () => ({ isMobile: sidebarState.isMobile }),
}));
vi.mock("@/hooks/useIssuePlanDocument", () => ({
useIssuePlanDocument: () => ({ data: null }),
@ -35,6 +36,7 @@ let root: Root | null = null;
beforeEach(() => {
localStorage.clear();
transcriptState.transcriptByRun.clear();
sidebarState.isMobile = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@ -68,6 +70,38 @@ describe("TaskChatThread draft pass-through", () => {
});
});
describe("TaskChatThread composer alignment (PAP-498)", () => {
it("keeps the composer dock at 80% of the thread width", () => {
render(<TaskChatThread comments={[]} onAdd={async () => {}} />);
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
expect(dock?.className).toContain("w-(--pct-80)");
expect(dock?.className).not.toContain("w-full");
});
});
describe("TaskChatThread mobile composer dock (PAP-495)", () => {
it("pins the composer to the nav-aware bottom offset so its action row clears the auto-hiding bottom nav", () => {
sidebarState.isMobile = true;
render(<TaskChatThread comments={[]} onAdd={async () => {}} draftKey="task-chat-draft:issue-mobile" />);
const dock = container
.querySelector('[data-testid="mock-editor"]')
?.closest("div.sticky") as HTMLElement | null;
expect(dock).not.toBeNull();
// Bottom offset comes from --tc-composer-bottom (Layout raises it to the nav
// height while the nav is on screen) — NOT the raw safe-area dock, which is
// what let the nav occlude the action row before PAP-495.
expect(dock?.className).toContain("bottom-(--tc-composer-bottom)");
expect(dock?.className).not.toContain("bottom-(--sz-calc-8)");
});
});
describe("TaskChatThread live transcript", () => {
it("renders in-flight output through TaskChatLiveTail, dropping the debug plumbing (PAP-463 C1)", () => {
// Interleave the exact noise the old RunTranscriptView tail surfaced (init
@ -108,8 +142,11 @@ describe("TaskChatThread live transcript", () => {
const tail = container.querySelector('[data-testid="task-chat-live-transcript"]');
expect(tail).not.toBeNull();
// Clean content survives: streamed reply markdown + tool row.
// Clean content survives: streamed reply markdown + compact phase summary.
expect(tail!.textContent).toContain("Streaming through the shared renderer");
const phaseSummary = tail!.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]');
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false");
flushSync(() => phaseSummary!.click());
expect(tail!.textContent).toContain("src/app.ts");
// None of the debug plumbing reaches the thread.
for (const noise of ["INITMARKER", "SYSTEMNOISE", "STDOUTNOISE", "STDERRNOISE"]) {

View File

@ -42,10 +42,8 @@ import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow"
import { useSidebar } from "@/context/SidebarContext";
import { cn } from "@/lib/utils";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { latestSameRunHandoffTimestamp, type IssueChatComment } from "@/lib/issue-chat-messages";
import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages";
import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds";
import { workModeInEffectAt } from "@/lib/issue-timeline-events";
import { workModeMetaFor } from "@/lib/work-mode-meta";
function toMs(value: Date | string | null | undefined): number {
if (!value) return 0;
@ -119,7 +117,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
onSubmitInteractionVerdicts,
externalReferences,
threadHeader,
workModeChanges,
issueBrief,
feedbackVotes,
feedbackDataSharingPreference = "prompt",
@ -134,27 +131,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
return map;
}, [linkedRuns]);
// Each agent reply is tagged with the mode its request ran under: the
// issue's work mode at the reply's run start (comment.runId linkage),
// reconstructed from the activity feed's work-mode switch history — not the
// issue's current mode, which the user may have changed since.
const agentModeLabelFor = useCallback(
(comment: IssueChatComment) => {
const runMeta = comment.runId ? linkedRunMetaById.get(comment.runId) : undefined;
const atMs = toMs(runMeta?.startedAt ?? runMeta?.createdAt ?? comment.createdAt);
return workModeMetaFor(workModeInEffectAt(workModeChanges ?? [], atMs, issueWorkMode)).label;
},
[linkedRunMetaById, workModeChanges, issueWorkMode],
);
const commentItems = useMemo(
() => commentsToTaskChatItems(comments, {
agentMap,
userLabelMap,
currentUserId,
issueAssigneeAgentId,
agentModeLabelFor,
}),
[comments, agentMap, userLabelMap, currentUserId, issueAssigneeAgentId, agentModeLabelFor],
[comments, agentMap, userLabelMap, currentUserId, issueAssigneeAgentId],
);
// Every run we might need a transcript for (history + live), deduped by id.
@ -629,10 +613,20 @@ export function TaskChatThread(props: TaskChatThreadProps) {
className={cn(
"sticky",
// Mobile mirrors the flag-off thread's dock: lifted above the
// safe-area inset (and clear of the auto-hiding bottom nav), above
// page content in the document-flow stacking context.
isMobile ? "bottom-(--sz-calc-8) z-20" : "bottom-0 z-10",
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
// safe-area inset and clear of the auto-hiding bottom nav, above
// page content in the document-flow stacking context. The bottom
// offset (--tc-composer-bottom) tracks the nav: Layout raises it to
// the nav height while the nav is visible so the composer's action
// row is never occluded, and drops it back to the safe-area dock
// when the nav auto-hides (PAP-495). transition-[bottom] rides the
// nav's own 200ms slide; the offset only changes on nav toggles, so
// it never animates mid-scroll.
isMobile
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
: "bottom-0 z-10",
// Keep the composer visibly narrower than the thread while its
// accessories and footer continue to share the same column.
"mx-auto flex w-(--pct-80) max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-4 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
)}
>
{composerAccessory}

View File

@ -2231,30 +2231,35 @@ export function IssueProperties({
)}
</PropertyRow>
<PropertyRow label="Sub-tasks" wrap>
<div className="flex flex-wrap items-center gap-1.5">
{childIssues.length > 0
? visibleChildIssues.map((child) => (
<IssueReferencePill key={child.id} issue={child} />
))
: null}
<ExpandRelationListButton
hiddenCount={hiddenChildIssueCount}
expanded={subTasksExpanded}
onClick={() => setSubTasksExpanded((expanded) => !expanded)}
/>
{onAddSubIssue ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={onAddSubIssue}
>
<Plus className="h-3 w-3" />
Add sub-task
</button>
) : null}
</div>
</PropertyRow>
{/* Chat shell promotes sub-tasks to their own pane tab (the full tree),
so the slim pill row here would duplicate that home (PAP-496). Keep
the pill row only for the classic center-column layout. */}
{taskChatShellEnabled ? null : (
<PropertyRow label="Sub-tasks" wrap>
<div className="flex flex-wrap items-center gap-1.5">
{childIssues.length > 0
? visibleChildIssues.map((child) => (
<IssueReferencePill key={child.id} issue={child} />
))
: null}
<ExpandRelationListButton
hiddenCount={hiddenChildIssueCount}
expanded={subTasksExpanded}
onClick={() => setSubTasksExpanded((expanded) => !expanded)}
/>
{onAddSubIssue ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
onClick={onAddSubIssue}
>
<Plus className="h-3 w-3" />
Add sub-task
</button>
) : null}
</div>
</PropertyRow>
)}
{relatedTasks.length > 0 ? (
<PropertyRow label="Related tasks" wrap>
@ -2563,7 +2568,8 @@ export function IssueProperties({
// Fall back to Properties if the selected tab's content went away (or the
// selection was made on another issue).
const activePaneTab =
(paneTab === "plans" && !hasPlanTab) || (paneTab === "artifacts" && !hasArtifactsTab)
(paneTab === "plans" && !hasPlanTab)
|| (paneTab === "artifacts" && !hasArtifactsTab)
? "properties"
: paneTab;
// In the pane header the strip stretches to the bar's full height and the

View File

@ -113,7 +113,7 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps)
locationHash={location.hash}
panelOpen={annotationPanelOpen}
onPanelOpenChange={setAnnotationPanelOpen}
panelPlacement="inline"
panelPlacement="popover"
>
<MarkdownBody>{planDocument.body}</MarkdownBody>
</IssueDocumentAnnotations>

View File

@ -0,0 +1,27 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
describe("TaskChatActivityPhase", () => {
afterEach(() => { document.body.innerHTML = ""; });
it("defaults collapsed, exposes aria state, and unmounts collapsed children", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
flushSync(() => root.render(<TaskChatActivityPhase item={{
id: "phase-1", kind: "activity_phase", active: false, summary: "Read 1 file",
items: [{ id: "tool-1", kind: "tool", name: "Read", status: "completed" }],
}} renderChild={(child) => <button>{child.id}</button>} />));
const summary = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]')!;
expect(summary.getAttribute("aria-expanded")).toBe("false");
expect(container.textContent).not.toContain("tool-1");
flushSync(() => summary.click());
expect(summary.getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("tool-1");
flushSync(() => summary.click());
expect(container.querySelector('[data-testid="task-chat-phase-children"]')).toBeNull();
flushSync(() => root.unmount());
});
});

View File

@ -0,0 +1,46 @@
import { useState, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { MarkdownBody } from "@/components/MarkdownBody";
import type { TaskChatActivityPhaseItem } from "./task-chat-model";
export function TaskChatActivityPhase({
item,
renderChild,
}: {
item: TaskChatActivityPhaseItem;
renderChild: (child: TaskChatActivityPhaseItem["items"][number]) => ReactNode;
}) {
const [open, setOpen] = useState(false);
const expandable = item.items.length > 0;
return (
<div className="flex min-w-0 flex-col gap-1" data-testid="task-chat-activity-phase">
{item.interstitial ? (
<div className="min-w-0 px-1 text-sm text-foreground/90" data-testid="task-chat-phase-interstitial">
<MarkdownBody softBreaks linkIssueReferences>{item.interstitial.text}</MarkdownBody>
</div>
) : null}
{expandable ? (
<button
type="button"
aria-expanded={open}
aria-label={`${open ? "Collapse" : "Expand"} activity: ${item.summary}`}
onClick={() => setOpen((value) => !value)}
className={cn(
"group flex min-w-0 items-center gap-1.5 rounded-sm px-1 py-0.5 text-left text-xs transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
item.active ? "text-foreground" : "text-muted-foreground",
)}
data-testid="task-chat-phase-summary"
>
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-90")} aria-hidden />
<span className="min-w-0 break-words">{item.summary}</span>
</button>
) : null}
{open ? (
<div className="flex min-w-0 flex-col gap-2 pl-2" data-testid="task-chat-phase-children">
{item.items.map((child) => <div className="min-w-0" key={child.id}>{renderChild(child)}</div>)}
</div>
) : null}
</div>
);
}

View File

@ -108,6 +108,36 @@ describe("TaskChatBubble accent-bubble text color", () => {
});
});
describe("TaskChatBubble agent page-surface treatment", () => {
it("renders agent prose without a card background or constrained width", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
flushSync(() =>
root.render(
<ThemeProvider>
<TaskChatBubble
item={{ id: "agent-message", kind: "message", author: "agent", authorName: "Codex", text: "A long-form agent response" }}
/>
</ThemeProvider>,
),
);
const bubble = container.querySelector('[data-testid="task-chat-agent-bubble"]');
expect(bubble).not.toBeNull();
expect(bubble?.className).toContain("w-full");
expect(bubble?.className).toContain("bg-transparent");
expect(bubble?.className).toContain("px-1");
expect(bubble?.className).not.toContain("rounded-2xl");
expect(bubble?.className).not.toContain("bg-(--bubble-agent)");
expect(bubble?.className).not.toContain("max-w-(--pct-85)");
flushSync(() => root.unmount());
container.remove();
});
});
describe("TaskChatBubble interstitial self-talk (PAP-357)", () => {
let container: HTMLDivElement;
let root: Root | null = null;

View File

@ -46,9 +46,9 @@ function initialsForName(name: string) {
/**
* Author-typed message row the primary legibility signal. Human messages sit
* right in a solid accent bubble; agent messages sit left in a neutral card
* bubble with an avatar author header (the agent's assigned icon + name · mode
* chip); system notices are centered and recede.
* right in a solid accent bubble; agent messages sit directly on the page
* surface with an avatar author header (the agent's assigned icon + name);
* system notices are centered and recede.
*/
function galleryItemForImage(src: string, name?: string): GalleryMediaItem {
return {
@ -112,20 +112,19 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
userName={item.onBehalfOfUserName}
/>
) : null}
{item.modeLabel ? (
<span className="rounded-full border border-border px-2 py-px text-(length:--text-micro) font-medium text-muted-foreground">
{item.modeLabel}
</span>
) : null}
</span>
) : null}
{bodyText.length > 0 ? (
<div
// Stable hook so the TaskChatLab bubble-treatment explorations
// (PAP-501) can scope background/border overrides to the agent
// bubble body without touching the live thread.
data-testid={isHuman ? "task-chat-human-bubble" : "task-chat-agent-bubble"}
className={cn(
"max-w-(--pct-85) break-words px-3.5 py-2 text-sm",
"break-words py-2 text-sm",
isHuman
? "rounded-2xl rounded-br-sm bg-(--liveness-blue) text-white"
: "rounded-2xl rounded-bl-sm bg-(--bubble-agent) text-foreground",
? "max-w-(--pct-85) rounded-2xl rounded-br-sm bg-(--liveness-blue) px-3.5 text-white"
: "w-full bg-transparent px-1 text-foreground",
item.optimistic ? "opacity-80" : null,
)}
>

View File

@ -244,6 +244,17 @@ function autocompleteOption(matchText: string) {
}
describe("TaskChatComposer", () => {
it("adds 10px to the composer's original 8px interior padding", () => {
render(<TaskChatComposer onAdd={async () => {}} workMode="standard" />);
const composer = container
.querySelector('[data-testid="task-chat-composer-input"]')
?.parentElement;
expect(composer?.className).toContain("p-(--sz-18px)");
expect(composer?.className).not.toContain("p-2");
});
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);

View File

@ -28,7 +28,7 @@ import {
import { fileKindForName, formatFileSize } from "./task-chat-attachments";
import { MarkdownEditor, type MarkdownEditorRef } from "@/components/MarkdownEditor";
import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
import type { MentionOption } from "@/components/MarkdownEditor";
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
@ -371,7 +371,7 @@ export function TaskChatComposer({
return (
<div
className={cn(
"rounded-xl border border-input bg-card p-2 shadow-(--shadow-extract-7) transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15",
"rounded-xl border border-input bg-card p-(--sz-18px) shadow-(--shadow-extract-7) transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15",
)}
onKeyDownCapture={(e) => {
// Shift+Tab cycles the pending mode; captured on the wrapper so it
@ -523,27 +523,24 @@ export function TaskChatComposer({
<div className="flex-1" />
{showAssignee ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className="flex h-8 min-w-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50"
data-testid="task-chat-composer-assignee"
>
<InlineEntitySelector
value={assigneeValue}
options={reassignOptions ?? []}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees…"
emptyMessage="No matches."
onChange={setPendingAssignee}
disabled={disabled}
triggerTestId="task-chat-composer-assignee"
className="h-8 gap-1.5 bg-transparent px-2.5 text-xs hover:bg-accent"
renderTriggerValue={() => (
<>
<span className="max-w-40 truncate">{assigneeLabel}</span>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{(reassignOptions ?? []).map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setPendingAssignee(option.id)}>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.id === assigneeValue ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
/>
) : null}
<button

View File

@ -49,9 +49,12 @@ describe("TaskChatLiveTail", () => {
]);
render(items);
expect(container.querySelector('[data-testid="task-chat-live-text"]')?.textContent).toContain(
expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')?.textContent).toContain(
"Looking into the failing test.",
);
const phaseSummary = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]');
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false");
flushSync(() => phaseSummary!.click());
// Tool row renders with its name + mono target.
expect(container.textContent).toContain("Read");
expect(container.textContent).toContain("src/app.ts");
@ -65,6 +68,9 @@ describe("TaskChatLiveTail", () => {
]);
render(items);
const phaseSummary = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]');
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false");
flushSync(() => phaseSummary!.click());
expect(container.textContent).toContain("const x = 1;");
expect(container.textContent).toContain("+1 1");
});
@ -95,6 +101,12 @@ describe("TaskChatLiveTail", () => {
]);
render(items);
expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')?.textContent).toContain(
"Here is the real reply.",
);
const phaseSummary = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-phase-summary"]');
expect(phaseSummary?.getAttribute("aria-expanded")).toBe("false");
flushSync(() => phaseSummary!.click());
const text = container.textContent ?? "";
expect(text).toContain("Here is the real reply.");
expect(text).toContain("pnpm test");

View File

@ -3,6 +3,8 @@ import { MarkdownBody } from "@/components/MarkdownBody";
import type { TaskChatItem } from "./task-chat-model";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { buildActivityPhases } from "./transcript-adapter";
/**
* Live-tail body for the experimental chat-style view (PAP-463, Workstream C1
@ -18,10 +20,9 @@ import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
* no "Streaming" chip, no uppercase "USED TERMINAL" cards. The status pill above
* this body (`TaskChatLiveRunPill`) owns the run-status affordance.
*
* Live and settle-gap render identically both feed their parsed items here
* (`running: true` while in flight, `false` through the settle gap) so the
* tail never restyles when a run finishes; the hand-off to the folded settled
* turn is the only visible transition.
* Stable assistant boundaries compact the rows into activity phases. The
* trailing streaming assistant chunk stays in the status pill's self-talk
* slot, while historical interstitials remain readable above their summaries.
*/
export function TaskChatLiveTail({
items,
@ -31,7 +32,7 @@ export function TaskChatLiveTail({
/** Shown when nothing renderable has streamed yet (queued / pre-first-token). */
emptyMessage?: string;
}) {
const rows = items
const rows = buildActivityPhases(items, true)
.map((item) => renderTailRow(item))
.filter((row): row is ReactElement => row != null);
@ -76,6 +77,14 @@ function renderTailRow(item: TaskChatItem): ReactElement | null {
<TaskChatUsageReadout item={item} />
</div>
);
case "activity_phase":
return (
<TaskChatActivityPhase
key={item.id}
item={item}
renderChild={(child) => child.kind === "tool" ? <TaskChatToolCard item={child} /> : <TaskChatUsageReadout item={child} />}
/>
);
// Thinking never renders as a row (PAP-361): its live signal is the status
// pill, and the text stays in the run log / classic transcript. Every other
// kind (markers, interactions, briefs, statuses, turns, and the dropped

View File

@ -11,6 +11,7 @@ import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskMessageScroller } from "./TaskMessageScroller";
interface TaskChatThreadViewProps {
@ -100,6 +101,8 @@ function renderItem(
);
case "usage":
return <TaskChatUsageReadout item={item} />;
case "activity_phase":
return <TaskChatActivityPhase item={item} renderChild={(child) => renderItem(child, onApprovalDecision)} />;
case "interaction":
return renderInteraction ? renderInteraction(item) : null;
case "brief":
@ -138,7 +141,7 @@ export function TaskChatThreadView({
scroll = true,
}: TaskChatThreadViewProps) {
const body = (
<div className={cn("mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-3 px-4 py-4", className)}>
<div className={cn("mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-5 px-4 py-4", className)}>
{header ? (
<div className="flex flex-col gap-6 pb-2" data-testid="task-chat-thread-header">
{header}
@ -181,6 +184,9 @@ function signatureOf(it: TaskChatItem): number {
: 0;
return it.items.reduce((n, child) => n + signatureOf(child), it.items.length + headerSig);
}
if (it.kind === "activity_phase") {
return it.items.reduce((n, child) => n + signatureOf(child), it.summary.length + (it.interstitial?.text.length ?? 0));
}
return 1;
}

View File

@ -97,7 +97,18 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: Ta
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
<span className="font-mono text-(length:--text-micro)">{turnSummaryMetrics(item.summary)}</span>
// Time/tools/tokens is demoted, not deleted (PAP-502): it stays in the
// DOM (and the accessible tree) but fades in only on hover/focus so the
// settled line reads as "2:34 PM · ✓ Worked" at rest. Revealed too when
// the fold is open, so the metrics don't vanish while you read below.
<span
className={cn(
"font-mono text-(length:--text-micro) transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100",
open ? "opacity-100" : "opacity-0",
)}
>
{turnSummaryMetrics(item.summary)}
</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
</button>

View File

@ -89,6 +89,13 @@ describe("TaskMessageScroller", () => {
expect(el.scrollTop).toBe(el.scrollHeight);
});
it("keeps the scrollbar at the full-width thread viewport edge", () => {
render();
const frame = scroller().parentElement;
expect(frame?.className).toBe("relative min-h-0 flex-1");
});
it("auto-follows content instantly while pinned", async () => {
render(1);
const el = scroller();

View File

@ -21,12 +21,6 @@ export interface TaskChatAdapterContext {
* writes and get a "for {user}" attribution chip (the open cross-task write design (attribution)).
*/
issueAssigneeAgentId?: string | null;
/**
* Capitalized mode chip for agent-authored bubbles ("Agent mode" / "Plan
* mode" / "Ask mode") resolved per comment, so each reply is tagged with
* the mode its request actually ran under (not the issue's current mode).
*/
agentModeLabelFor?: (comment: IssueChatComment) => string | undefined;
}
function effectiveAgentId(comment: IssueChatComment): string | null {
@ -98,7 +92,6 @@ export function commentsToTaskChatItems(
optimistic,
agentIcon,
onBehalfOfUserName,
modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined,
// System notices carry their structured hints through to the render
// layer (PAP-443); other authors keep the item lean.
presentation: kind === "system" ? comment.presentation ?? null : undefined,

View File

@ -50,7 +50,7 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
surface: "thread",
items: [
...exchangePrefix(),
{ id: "m-agent-1", kind: "message", author: "agent", authorName: AGENT, agentIcon: "bot", modeLabel: "Agent mode", text: "On it — I'll add a token-bucket limiter and wire it into the login route.", timestamp: "2:31 PM" },
{ id: "m-agent-1", kind: "message", author: "agent", authorName: AGENT, agentIcon: "bot", text: "On it — I'll add a token-bucket limiter and wire it into the login route.", timestamp: "2:31 PM" },
],
};
case "thinking":
@ -207,7 +207,6 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
author: "agent",
authorName: AGENT,
agentIcon: "bot",
modeLabel: "Agent mode",
text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.",
timestamp: "2:34 PM",
attachedTurn: {
@ -243,6 +242,43 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
},
],
};
case "activity-phases": {
const phase = (id: string, text: string | undefined, active: boolean, tools: TaskChatItem[]) => ({
id,
kind: "activity_phase" as const,
active,
interstitial: text ? { id: `${id}:message`, kind: "message" as const, author: "agent" as const, authorName: AGENT, text, interstitial: true } : undefined,
items: tools.filter((item): item is Extract<TaskChatItem, { kind: "tool" | "usage" }> => item.kind === "tool" || item.kind === "usage"),
summary: active ? "Ran 1 command, called 1 tool" : id.endsWith("opening") ? "Called 2 tools" : "Read 3 files, edited 1 file",
});
return {
surface: "thread",
items: [
...exchangePrefix(),
{
id: "turn-long-run", kind: "turn", settled: false,
summary: { toolCount: 8, added: 4, removed: 1 },
liveStatus: { id: "long-status", kind: "status", status: "working", label: "Running tests", detail: "Bash · vitest", toolName: "Bash", startedAtMs: Date.now() - 48_000 },
items: [
phase("phase-opening", undefined, false, [
{ id: "generic-1", kind: "tool", name: "Tool", rawName: "tool call", status: "completed" },
{ id: "generic-2", kind: "tool", name: "Tool", rawName: "acp_tool", status: "failed", detail: "Adapter interrupted" },
]),
phase("phase-read", "I found the relevant adapter and am tracing its render boundary.", false, [
{ id: "read-1", kind: "tool", name: "Read", status: "completed", target: "ui/src/components/task-chat/transcript-adapter.ts" },
{ id: "read-2", kind: "tool", name: "Read", status: "completed", target: "ui/src/components/task-chat/TaskChatTurn.tsx" },
{ id: "read-3", kind: "tool", name: "Read", status: "completed", target: "ui/src/components/task-chat/TaskChatThreadView.tsx" },
{ id: "edit-1", kind: "tool", name: "Edit", status: "completed", target: "ui/src/components/task-chat/task-chat-model.ts" },
]),
phase("phase-active", "The grouping is wired; Im running focused checks now.", true, [
{ id: "bash-1", kind: "tool", name: "Bash", status: "in_progress", target: "vitest task-chat" },
{ id: "mcp-1", kind: "tool", name: "Search", rawName: "mcp__docs__search", status: "completed" },
]),
],
},
],
};
}
case "plan-todo":
return { surface: "plan", items: [], plan: SAMPLE_PLAN };
case "interrupted":

View File

@ -70,11 +70,6 @@ export interface TaskChatMessageItem {
streaming?: boolean;
/** Optimistic local echo state (matches IssueChatComment.clientStatus). */
optimistic?: "pending" | "queued";
/**
* Per-message mode tag ("Agent mode" / "Plan mode" / "Ask mode"). Shown as a
* chip in the agent header and under a sent human bubble (v6 decision).
*/
modeLabel?: string;
/** Assigned agent icon name (AgentIconName) for the avatar header. */
agentIcon?: string | null;
/**
@ -201,6 +196,19 @@ export interface TaskChatUsageItem {
usage: TaskChatTokenUsage;
}
export interface TaskChatActivityPhaseItem {
id: string;
kind: "activity_phase";
/** Historical assistant update that introduced this phase. */
interstitial?: TaskChatMessageItem;
/** Chronological tool/usage rows owned exclusively by this phase. */
items: Array<TaskChatToolItem | TaskChatUsageItem>;
/** Deterministic, taxonomy-based summary (for example "Read 3 files, ran 1 command"). */
summary: string;
/** The tail phase of an in-flight run stays foregrounded. */
active: boolean;
}
/**
* The task description rendered as the requester's first chat bubble
* (PAP-375). A placeholder kind only the host supplies the render
@ -232,7 +240,8 @@ export type TaskChatTurnChildItem =
| TaskChatToolItem
| TaskChatStatusItem
| TaskChatMarkerItem
| TaskChatUsageItem;
| TaskChatUsageItem
| TaskChatActivityPhaseItem;
/**
* One agent turn's activity (thinking/tools/diffs) grouped so a finished turn
@ -276,6 +285,7 @@ export type TaskChatItem =
| TaskChatStatusItem
| TaskChatMarkerItem
| TaskChatUsageItem
| TaskChatActivityPhaseItem
| TaskChatInteractionItem
| TaskChatTurnItem
| TaskChatBriefItem;

View File

@ -24,6 +24,7 @@ export const TASK_CHAT_STATES = [
"working",
"running",
"completed",
"activity-phases",
"awaiting-approval",
"plan-todo",
"interrupted",
@ -124,6 +125,13 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
surface: "thread",
protocol: "acpx.result (StopReason in subtype)",
},
"activity-phases": {
id: "activity-phases",
label: "Long-run activity phases",
tier: "live",
surface: "thread",
protocol: "assistant boundaries + chronological tool calls",
},
"awaiting-approval": {
id: "awaiting-approval",
label: "Awaiting approval",

View File

@ -375,15 +375,35 @@ describe("settledRunChildren (PAP-361)", () => {
];
const parsed = transcriptToTaskChatItems(transcript, { runId: "run-1", running: false });
it("keeps exactly the tool rows — messages AND thinking are excluded", () => {
it("groups tools under the historical assistant boundary and excludes the final reply", () => {
const children = settledRunChildren(parsed);
expect(children.map((c) => c.kind)).toEqual(["tool", "tool"]);
expect(children.map((c) => c.kind)).toEqual(["activity_phase"]);
const phase = children[0];
expect(phase.kind === "activity_phase" && phase.interstitial?.text).toBe("Checking the adapter first.");
expect(phase.kind === "activity_phase" && phase.items.map((item) => item.kind)).toEqual(["tool", "tool"]);
});
it("matches the folded summary's tool count exactly (row-count parity)", () => {
const children = settledRunChildren(parsed);
const summary = buildTurnSummary(transcript);
expect(children.filter((c) => c.kind === "tool")).toHaveLength(summary.toolCount);
const phaseToolCount = children.reduce(
(count, child) => count + (child.kind === "activity_phase" ? child.items.filter((item) => item.kind === "tool").length : 0),
0,
);
expect(phaseToolCount).toBe(summary.toolCount);
});
it("creates a stable opening phase for calls before the first interstitial", () => {
const opening = transcriptToTaskChatItems([
toolCall("Read", { file_path: "a.ts" }),
{ kind: "assistant", ts: TS, text: "Now editing." } as TranscriptEntry,
toolCall("Edit", { file_path: "a.ts" }),
{ kind: "assistant", ts: TS, text: "Done." } as TranscriptEntry,
], { runId: "run-opening", running: false });
const phases = settledRunChildren(opening);
expect(phases).toHaveLength(2);
expect(phases[0].id).toContain(":phase:opening");
expect(phases[1].kind === "activity_phase" && phases[1].summary).toBe("Edited 1 file");
});
});

View File

@ -8,6 +8,7 @@
import type { TranscriptEntry } from "@/adapters";
import type {
TaskChatDiff,
TaskChatActivityPhaseItem,
TaskChatItem,
TaskChatToolItem,
TaskChatTurnChildItem,
@ -38,7 +39,7 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean
* in the thread outside.
*/
export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem {
return item.kind === "tool" || item.kind === "usage";
return item.kind === "tool" || item.kind === "usage" || item.kind === "activity_phase";
}
/**
@ -328,18 +329,80 @@ export function transcriptToTaskChatItems(
}
/**
* A settled run's nested children (PAP-361): exactly the tool rows (plus usage
* readouts) in transcript order, under the "✓ Worked ·" summary parity with
* the summary's tool count. Messages are excluded: the final reply already
* landed as the run's posted comment bubble, and interstitial updates are
* ephemeral (they take the live line while streaming, then vanish). Thinking
* is excluded too the run log / classic transcript remain its archive.
* A settled run's nested children: activity phases containing chronological
* tool rows and their historical interstitial boundary. The final reply is
* excluded because its posted comment is canonical. Thinking stays in the
* run log / classic transcript.
*/
export function settledRunChildren(parsed: readonly TaskChatItem[]): TaskChatTurnChildItem[] {
return parsed.filter(
(it): it is TaskChatTurnChildItem =>
it.kind !== "turn" && it.kind !== "message" && it.kind !== "thinking",
);
return buildActivityPhases(parsed, false);
}
function phaseSummary(items: readonly (TaskChatToolItem | { kind: "usage" })[]): string {
const counts = new Map<string, number>();
let generic = 0;
for (const item of items) {
if (item.kind !== "tool") continue;
if (isGenericToolName(item.rawName ?? item.name)) {
generic += 1;
continue;
}
const family = toolTaxonomy(item.rawName ?? item.name).family;
counts.set(family, (counts.get(family) ?? 0) + 1);
}
const phrases: string[] = [];
const add = (family: string, verb: string, singular: string, plural: string) => {
const count = counts.get(family) ?? 0;
if (count) phrases.push(`${verb} ${count} ${count === 1 ? singular : plural}`);
};
add("read", "Read", "file", "files");
add("edit", "Edited", "file", "files");
add("terminal", "Ran", "command", "commands");
const searched = (counts.get("grep") ?? 0) + (counts.get("search") ?? 0);
if (searched) phrases.push(`Searched ${searched} ${searched === 1 ? "time" : "times"}`);
const known = new Set(["read", "edit", "terminal", "grep", "search"]);
const other = [...counts].reduce((n, [family, count]) => n + (known.has(family) ? 0 : count), 0) + generic;
if (other) phrases.push(`Called ${other} ${other === 1 ? "tool" : "tools"}`);
return phrases.join(", ") || "No tool activity";
}
/** Segment parsed transcript rows at assistant boundaries with stable run-derived ids. */
export function buildActivityPhases(
parsed: readonly TaskChatItem[],
running: boolean,
): TaskChatActivityPhaseItem[] {
const phases: TaskChatActivityPhaseItem[] = [];
let current: TaskChatActivityPhaseItem | null = null;
const ensureOpening = (seed: string) => {
if (!current) {
current = { id: `${seed}:phase:opening`, kind: "activity_phase", items: [], summary: "", active: false };
phases.push(current);
}
return current;
};
const lastVisible = [...parsed].reverse().find((item) => item.kind !== "thinking");
for (const item of parsed) {
if (item.kind === "message") {
// A settled transcript's trailing assistant text is the posted reply.
// Live/settle-gap tails keep it visible until that canonical reply lands.
if (!running && item === lastVisible) continue;
current = {
id: `${item.id}:phase`,
kind: "activity_phase",
interstitial: item,
items: [],
summary: "",
active: false,
};
phases.push(current);
} else if (item.kind === "tool" || item.kind === "usage") {
ensureOpening(item.id).items.push(item);
}
}
for (const phase of phases) phase.summary = phaseSummary(phase.items);
const meaningful = phases.filter((phase) => phase.interstitial || phase.items.length > 0);
if (running && meaningful.length) meaningful[meaningful.length - 1].active = true;
return meaningful;
}
function formatDurationLabel(ms: number): string | undefined {

View File

@ -0,0 +1,130 @@
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;
}

View File

@ -260,8 +260,11 @@
--tc-mode-plan: var(--status-task-todo);
--tc-mode-ask: #3b82f6;
/* Agent-comment bubble fill (v7 grammar): quiet gray card, no border. */
--bubble-agent: oklch(0.955 0 0);
/* Agent-comment comparison fill used by TaskChatLab. The chosen production
treatment sits directly on the page surface; this token keeps the darker
card available as an explicit lab alternative. */
--bubble-agent: oklch(0.97 0 0);
--bubble-agent-card-padding-inline: 0.875rem;
/* Redesigned thread viewport bound: lets the thread own its scroll (auto-
follow + pinned composer) inside the page flow. The issue header lives
@ -273,6 +276,13 @@
/* Redesigned chat-shell column cap (phase 2c): one token caps the page
wrapper, thread body, and pinned composer so they widen in lockstep. */
--tc-shell-max-w: 60rem;
/* Mobile pinned-composer bottom offset (PAP-495). Default clears just the
safe-area inset (+20px); Layout raises it to the bottom-nav height
(--sz-calc-14) while the auto-hiding mobile nav is on screen, so the
composer's action row never hides behind the nav. Desktop and the classic
thread never override it and fall through to the safe-area default. */
--tc-composer-bottom: var(--sz-calc-8);
}
.dark {
@ -284,7 +294,7 @@
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--bubble-agent: oklch(0.24 0 0);
--bubble-agent: oklch(0.185 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@ -464,6 +474,13 @@
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
background: transparent !important;
}
/* The task-chat shell owns its desktop scroll viewport. Release Layout's
otherwise-useful reserved main scrollbar gutter so that viewport can meet
the properties-pane border instead of stopping one scrollbar-width early. */
#main-content:has([data-task-chat-shell]) {
scrollbar-gutter: auto;
}
/* Light mode scrollbar on hover */
.scrollbar-auto-hide:hover {
scrollbar-color: oklch(0.7 0 0) transparent;
@ -596,6 +613,43 @@
.tc-approval { animation: tc-approval-pulse var(--motion-approval-pulse) var(--motion-ease-standard) infinite; }
.tc-cursor { animation: tc-cursor-blink var(--motion-streaming-cursor-blink) step-end infinite; }
.tc-enter-plan-entry { animation: tc-fade-rise var(--motion-plan-check) var(--motion-ease-out-expo) both; }
/*
Agent-bubble treatment explorations (PAP-501). Scoped to the TaskChatLab
stage via [data-bubble-variant]; the live thread never sets that attribute,
so these overrides remain lab-only comparisons. Feedback: the former
dark-mode agent card (oklch 0.24) read too light against the near-black page
for the bulk of the text. The production treatment now sits directly on
the page background; the card alternatives remain available for comparison.
*/
/* Former production treatment, retained as a direct before/after comparison
for the darker default. */
[data-bubble-variant="former"] { --bubble-agent: oklch(0.955 0 0); }
.dark [data-bubble-variant="former"] { --bubble-agent: oklch(0.24 0 0); }
/* B · Hairline flush near-page fill + a 1px hairline for gentle definition
without any brightness lift. */
[data-bubble-variant="hairline"] { --bubble-agent: var(--background); }
[data-bubble-variant="hairline"] [data-testid="task-chat-agent-bubble"] {
border: 1px solid var(--border);
}
.dark [data-bubble-variant="hairline"] { --bubble-agent: oklch(0.17 0 0); }
.dark [data-bubble-variant="hairline"] [data-testid="task-chat-agent-bubble"] {
border-color: oklch(1 0 0 / 8%);
}
/* Card variants restore the shared card geometry that production no longer
applies to agent prose. */
[data-bubble-variant="former"] [data-testid="task-chat-agent-bubble"],
[data-bubble-variant="darker"] [data-testid="task-chat-agent-bubble"],
[data-bubble-variant="hairline"] [data-testid="task-chat-agent-bubble"] {
background: var(--bubble-agent);
border-radius: var(--radius-2xl);
padding-left: var(--bubble-agent-card-padding-inline);
padding-right: var(--bubble-agent-card-padding-inline);
max-width: var(--pct-85);
}
/* Live-line viewport (PAP-361): exactly one line-height tall, clipping the
wrapped interstitial block. On wrap, the inner block translates up whole
line-heights (set inline as translateY(-(n1)·1lh)) so the completed line
@ -2156,6 +2210,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--rad-24: 24px; /* Extracted from ui/src/pages/InviteUxLab.tsx (rounded-[24px]). */
--rad-32: 32px; /* Extracted from ui/src/pages/InviteUxLab.tsx (rounded-[32px]). */
--pct-90: 90%; /* Extracted from ui/src/components/DocumentDiffModal.tsx (max-w-[90%]). */
--pct-80: 80%; /* TaskChatThread composer width (PAP-498 follow-up). */
--pct-50: 50%; /* Extracted from ui/src/components/ImageGalleryModal.tsx (max-w-[50%]). */
--pct-85: 85%; /* Extracted from ui/src/components/IssueChatThread.test.tsx (max-w-[85%]). */
--pct-70: 70%; /* Extracted from ui/src/components/CaseFieldsPanel.tsx (max-w-[70%]). */
@ -2301,6 +2356,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--tp-box-shadow: box-shadow; /* Extracted from ui/src/pages/ProjectDetail.tsx (transition-[box-shadow]). */
--tp-transform-box-shadow: transform,box-shadow; /* Extracted from ui/src/pages/ProjectDetail.tsx (transition-[transform,box-shadow]). */
--z-2: 2; /* Extracted from ui/src/components/DocumentAnnotationLayer.tsx (z-[2]). */
--z-20: 20; /* Anchored document annotation popover. */
--z-60: 60; /* Extracted from ui/src/components/DocumentAnnotationPanel.tsx (z-[60]). */
--z-1: 1; /* Extracted from ui/src/components/IssueDocumentAnnotations.tsx (z-[1]). */
--z-200: 200; /* Extracted from ui/src/components/Layout.tsx (z-[200]). */

View File

@ -188,12 +188,18 @@ vi.mock("../context/CompanyContext", () => ({
}),
}));
const mockOpenNewIssue = vi.hoisted(() => vi.fn());
const mockOpenNewProject = vi.hoisted(() => vi.fn());
const mockOpenNewGoal = vi.hoisted(() => vi.fn());
vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
openNewIssue: vi.fn(),
openNewIssue: mockOpenNewIssue,
}),
useDialogActions: () => ({
openNewIssue: vi.fn(),
openNewIssue: mockOpenNewIssue,
openNewProject: mockOpenNewProject,
openNewGoal: mockOpenNewGoal,
}),
}));
@ -1077,6 +1083,9 @@ describe("IssueDetail", () => {
mockImageGalleryRender.mockClear();
mockIssueWorkspaceCardRender.mockClear();
mockNavigate.mockClear();
mockOpenNewIssue.mockClear();
mockOpenNewProject.mockClear();
mockOpenNewGoal.mockClear();
mockLocation.pathname = "/issues/PAP-1";
mockLocation.search = "";
mockLocation.hash = "";
@ -1118,6 +1127,133 @@ describe("IssueDetail", () => {
).toBe(false);
});
it("renders the full sub-task tree below the title in the chat center pane", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockResolvedValue([
createIssue({
id: "child-1",
parentId: "issue-1",
identifier: "PAP-2",
issueNumber: 2,
title: "Child task",
}),
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const title = Array.from(container.querySelectorAll("div")).find(
(element) => element.textContent === "Issue detail smoke",
);
const subTasks = Array.from(container.querySelectorAll("div")).find(
(element) => element.textContent === "Sub-issues",
);
expect(title).toBeDefined();
expect(subTasks).toBeDefined();
expect(title!.compareDocumentPosition(subTasks!) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
expect(mockIssuesListRender).toHaveBeenCalledWith(
expect.objectContaining({
createIssueLabel: "Sub-task",
showProgressSummary: true,
}),
);
});
it("hides the full sub-task tree when the task has no subtasks", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockResolvedValue([]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).not.toContain("Sub-issues");
expect(mockIssuesListRender.mock.calls).not.toContainEqual([
expect.objectContaining({ isLoading: false }),
]);
});
it("keeps the properties panel stable across unrelated chat-detail renders", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
const detail = (
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>
);
await act(async () => {
root.render(detail);
});
await flushReact();
await flushReact();
const panelOpenCount = mockOpenPanel.mock.calls.length;
expect(panelOpenCount).toBeGreaterThan(0);
// React Query returns a new mutation result object on render. The panel
// effect must depend on the stable mutate function rather than that wrapper
// object, or openPanel's state update recursively renders
// IssueDetail until React throws "Maximum update depth exceeded".
await act(async () => {
root.render(detail);
});
await flushReact();
expect(mockOpenPanel).toHaveBeenCalledTimes(panelOpenCount);
});
it("does not loop openPanel when the sub-task list query is still loading (PAP-508)", async () => {
// While the descendant-issues query is still in flight, `data` is undefined.
// A literal `= []` default for that `data` mints a new array reference on
// every render, which destabilizes the child-derived panel key, re-firing
// openPanel each render until
// React throws "Maximum update depth exceeded". Keep the list query pending
// so `data` stays undefined and the stabilization of the empty default is
// the only thing preventing the loop. A fresh root element is rendered each
// pass so React actually re-renders IssueDetail (a reused element reference
// lets the reconciler bail out, masking the loop).
const pendingListRequest = createDeferred<Issue[]>();
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.list.mockReturnValue(pendingListRequest.promise);
const renderDetail = () => (
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>
);
await act(async () => {
root.render(renderDetail());
});
await flushReact();
await flushReact();
const panelOpenCount = mockOpenPanel.mock.calls.length;
expect(panelOpenCount).toBeGreaterThan(0);
await act(async () => {
root.render(renderDetail());
});
await flushReact();
expect(mockOpenPanel).toHaveBeenCalledTimes(panelOpenCount);
pendingListRequest.resolve([]);
await flushReact();
});
it("does not load or render decision sections in the issue header", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "in_review",

View File

@ -234,6 +234,12 @@ import {
workspaceFileRefSchema,
} from "@paperclipai/shared";
// Stable empty array for React Query `data` defaults. A literal `= []` default
// creates a new array reference on every render while `data` is undefined
// (loading/idle), which destabilizes downstream memos and panel keys that
// depend on it. Reusing one shared reference keeps those values stable.
const EMPTY_ISSUES: Issue[] = [];
type StopAndFinalizeRunError = Error & {
runCancelledBeforeStatusUpdateFailed?: boolean;
};
@ -664,33 +670,76 @@ function IssueSectionSkeleton({
);
}
/**
* One chat-bubble placeholder mirroring TaskChatBubble's anatomy: agent replies
* sit left under an avatar + name author row, human messages sit right with no
* header. The bubble reuses the real rounding (rounded-2xl with a squared tail
* corner) so the skeleton reads as a conversation, not a stack of cards.
*/
function ChatBubbleSkeleton({
side,
className,
}: {
side: "agent" | "human";
className?: string;
}) {
const isHuman = side === "human";
return (
<div className={cn("flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
{isHuman ? null : (
<span className="flex items-center gap-2 px-1">
<Skeleton className="h-6 w-6 rounded-full" />
<Skeleton className="h-3 w-24" />
</span>
)}
<Skeleton
className={cn(
"max-w-(--pct-85)",
isHuman ? "rounded-2xl rounded-br-sm" : "rounded-2xl rounded-bl-sm",
className,
)}
/>
</div>
);
}
/**
* Composer placeholder mirroring TaskChatComposer's docked card (a bordered
* rounded input area with a plus, a mode chip, and a send affordance) so the
* foot of the loading state matches the real chat shell.
*/
function IssueChatComposerSkeleton({ className }: { className?: string }) {
return (
<div
className={cn("rounded-xl border border-input bg-card p-2", className)}
data-testid="issue-chat-composer-skeleton"
>
<div className="min-h-(--sz-48px) space-y-2 px-1 py-1">
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-1/3" />
</div>
<div className="mt-1 flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-24 rounded-md" />
<div className="flex-1" />
<Skeleton className="h-8 w-8 rounded-md" />
</div>
</div>
);
}
/**
* Alternating chat-bubble placeholders for the thread body. Widths and heights
* vary so the skeleton mirrors a real back-and-forth (TaskChatThreadView)
* rather than the pre-chat bordered card it replaced.
*/
function IssueChatSkeleton() {
return (
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<Skeleton className="h-20 w-full rounded-xl" />
</div>
<div className="space-y-2">
<div className="flex items-center justify-end gap-2">
<div className="space-y-2 text-right">
<Skeleton className="ml-auto h-3 w-20" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
<Skeleton className="h-8 w-8 rounded-full" />
</div>
<Skeleton className="ml-auto h-16 w-(--pct-85) rounded-xl" />
</div>
<div className="space-y-2 border-t border-border pt-3">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
<div className="flex flex-col gap-3" data-testid="issue-chat-skeleton">
<ChatBubbleSkeleton side="agent" className="h-16 w-3/4" />
<ChatBubbleSkeleton side="human" className="h-9 w-1/2" />
<ChatBubbleSkeleton side="agent" className="h-24 w-4/5" />
<ChatBubbleSkeleton side="human" className="h-8 w-2/5" />
</div>
);
}
@ -775,17 +824,29 @@ function IssueDetailLoadingState({
)}
</div>
<Skeleton className="h-28 w-full rounded-lg border border-border" />
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
{taskChatShellEnabled ? (
// Chat shell: the thread is the whole surface — alternating bubble
// placeholders followed by the docked composer, no tab strip or
// properties-card chrome (those don't exist in the chat layout).
<div className="space-y-6">
<IssueChatSkeleton />
<IssueChatComposerSkeleton />
</div>
<IssueChatSkeleton />
</div>
) : (
<>
<Skeleton className="h-28 w-full rounded-lg border border-border" />
<IssueSectionSkeleton titleWidth="w-24" rows={3} />
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
<IssueChatSkeleton />
</div>
<IssueSectionSkeleton titleWidth="w-24" rows={3} />
</>
)}
</div>
);
}
@ -1233,7 +1294,16 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
the header so nothing sits above the thread in the page flow. */}
{classicTaskInterfaceEnabled ? loadOlderButton : null}
{commentsInitialLoading && commentsWithRunMeta.length === 0 && interactions.length === 0 ? (
<IssueChatSkeleton />
classicTaskInterfaceEnabled ? (
<IssueChatSkeleton />
) : (
// Chat shell: center the bubbles at the thread cap (mirrors
// TaskChatThreadView) and dock a composer placeholder beneath them.
<div className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-3 px-4 py-4">
<IssueChatSkeleton />
<IssueChatComposerSkeleton className="mt-3" />
</div>
)
) : (
<ThreadComponent
composerRef={composerRef}
@ -1802,7 +1872,7 @@ export function IssueDetail() {
[issueId, location.state, location.search],
);
const { data: rawChildIssues = [], isLoading: childIssuesLoading } = useQuery({
const { data: rawChildIssuesData, isLoading: childIssuesLoading } = useQuery({
queryKey:
issue?.id && resolvedCompanyId
? queryKeys.issues.listByDescendantRoot(resolvedCompanyId, issue.id)
@ -1811,8 +1881,9 @@ export function IssueDetail() {
enabled: !!resolvedCompanyId && !!issue?.id,
placeholderData: keepPreviousDataForSameQueryTail<Issue[]>(issue?.id ?? "pending"),
});
const rawChildIssues: Issue[] = rawChildIssuesData ?? EMPTY_ISSUES;
const {
data: rawSiblingIssues = [],
data: rawSiblingIssuesData,
isLoading: siblingIssuesLoading,
isError: siblingIssuesError,
} = useQuery({
@ -1823,6 +1894,7 @@ export function IssueDetail() {
queryFn: () => issuesApi.list(resolvedCompanyId!, { parentId: issue!.parentId!, includeBlockedBy: true }),
enabled: !!resolvedCompanyId && !!issue?.parentId,
});
const rawSiblingIssues: Issue[] = rawSiblingIssuesData ?? EMPTY_ISSUES;
const companyLiveRunsQueryKey = resolvedCompanyId ? queryKeys.liveRuns(resolvedCompanyId) : ["live-runs", "pending"] as const;
const sharedCompanyLiveRuns = useSharedPollingQuery<LiveRunForIssue[]>({
companyId: resolvedCompanyId,
@ -2546,7 +2618,49 @@ export function IssueDetail() {
});
const handleChildIssueUpdate = useCallback((id: string, data: Record<string, unknown>) => {
updateChildIssue.mutate({ id, data });
}, [updateChildIssue]);
}, [updateChildIssue.mutate]);
// PAP-496: the chat shell keeps the full sub-task tree directly below the
// title in the center column. This is the tree's single chat-shell home; the
// Properties pane does not duplicate it. Classic mode keeps its existing
// center-column section below the header.
const subTasksTree = useMemo(
() =>
taskChatShellEnabled && issue && showRichSubIssuesSection ? (
<IssuesList
issues={childIssues}
isLoading={childIssuesLoading}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
projectId={issue.projectId ?? undefined}
viewStateKey={`paperclip:issue-detail:${issue.id}:subissues-view`}
issueLinkState={resolvedIssueDetailState ?? location.state}
searchFilters={{ descendantOf: issue.id, includeBlockedBy: true }}
searchWithinLoadedIssues
baseCreateIssueDefaults={buildSubIssueDefaultsForViewer(issue, currentUserId)}
createIssueLabel="Sub-task"
defaultSortField="workflow"
showProgressSummary
parentIssueIdForCostSummary={issue.id}
onUpdateIssue={handleChildIssueUpdate}
/>
) : null,
[
taskChatShellEnabled,
issue,
showRichSubIssuesSection,
childIssues,
childIssuesLoading,
agents,
projects,
liveIssueIds,
resolvedIssueDetailState,
location.state,
currentUserId,
handleChildIssueUpdate,
],
);
const checkIssueMonitorNow = useMutation({
mutationFn: () => issuesApi.checkMonitorNow(issueId!),
@ -4635,6 +4749,8 @@ export function IssueDetail() {
className={taskChatShellEnabled ? "text-base font-semibold" : "text-xl font-bold"}
/>
{taskChatShellEnabled ? subTasksTree : null}
<IssueMonitorBanner
issue={issue}
onCheckNow={() => checkIssueMonitorNow.mutate()}
@ -4720,6 +4836,7 @@ export function IssueDetail() {
return (
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
<div
data-task-chat-shell={taskChatShellEnabled ? "" : undefined}
className={
taskChatShellEnabled
? isMobile

View File

@ -138,8 +138,23 @@ function useStreamingReplay(
* post-baseline iteration cockpit: state switcher, streaming replay, a
* 0.1×10× speed control, and the live motion tweak panel.
*/
/**
* Agent-bubble background treatments explored for PAP-501 (feedback: the
* dark-mode agent card reads too light against the near-black page). Each id
* maps to a `[data-bubble-variant]` scope in index.css; "" is the chosen
* page-surface treatment.
*/
const BUBBLE_VARIANTS = [
{ id: "", label: "Chosen · C · On bg" },
{ id: "former", label: "Former" },
{ id: "darker", label: "A · Darker" },
{ id: "hairline", label: "B · Hairline" },
] as const;
type BubbleVariantId = (typeof BUBBLE_VARIANTS)[number]["id"];
export function TaskChatLab() {
const [selected, setSelected] = useState<TaskChatStateId>("agent-message");
const [bubbleVariant, setBubbleVariant] = useState<BubbleVariantId>("");
const [speed, setSpeed] = useState(1);
const [playing, setPlaying] = useState(true);
const [playToken, setPlayToken] = useState(0);
@ -224,10 +239,34 @@ export function TaskChatLab() {
/>
<span className="w-10 tabular-nums">{speed.toFixed(1)}×</span>
</label>
<span className="ml-auto font-mono text-(length:--text-micro) text-muted-foreground">{meta.protocol}</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-muted-foreground">Agent bubble</span>
<div className="flex items-center gap-0.5 rounded border border-border p-0.5" role="group" aria-label="Agent bubble treatment">
{BUBBLE_VARIANTS.map((v) => (
<button
key={v.id || "current"}
type="button"
data-bubble-variant-id={v.id || "current"}
onClick={() => setBubbleVariant(v.id)}
className={cn(
"rounded px-2 py-0.5 text-(length:--text-micro)",
bubbleVariant === v.id ? "bg-primary text-primary-foreground" : "hover:bg-accent",
)}
>
{v.label}
</button>
))}
</div>
<span className="font-mono text-(length:--text-micro) text-muted-foreground">{meta.protocol}</span>
</div>
</div>
<div ref={targetRef} className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-stage">
<div
ref={targetRef}
className="flex min-h-0 flex-1 flex-col"
data-testid="task-chat-stage"
data-bubble-variant={bubbleVariant || undefined}
>
{scenario.surface === "plan" && scenario.plan ? (
<div className="mx-auto max-w-2xl px-4">
<TaskChatPlanView plan={scenario.plan} />