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:
parent
ff5fd62d07
commit
0ee0543e5b
|
|
@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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" />);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; I’m 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":
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(-(n−1)·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]). */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue