mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(ui): task-chat phase 2c — center/widen shell, inline interaction cards, plan doc in Plan tab
All flag-gated behind enableTaskChatRedesign; flag OFF stays byte-identical. - --tc-shell-max-w (60rem) token; IssueDetail wrapper + skeleton become mx-auto w-full max-w-(--tc-shell-max-w) when the flag is on, and the thread body + pinned composer caps switch to the same token - interaction kind added to the task-chat model; TaskChatThread merges interactions chronologically (same sort keys + same-run handoff shift as buildIssueChatMessages) and renders them via TaskChatInteractionCard, a v7-grammar wrapper over the shared IssueThreadInteractionCard (expired confirmations demote to a marker row) - useIssuePlanDocument hook (404 → null) shared by the Plan tab, which now renders the plan document via MarkdownBody with a revision line above the decompositions, and by the thread's Plan updated/created marker Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e76584b95b
commit
a5bdb851ea
8 files changed
+191
-45
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
@@ -9,14 +9,24 @@ import {
|
||||
transcriptToTaskChatItems,
|
||||
} from "@/components/task-chat/transcript-adapter";
|
||||
import type {
|
||||
TaskChatInteractionItem,
|
||||
TaskChatItem,
|
||||
TaskChatTurnChildItem,
|
||||
TaskChatTurnItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
|
||||
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
|
||||
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { latestSameRunHandoffTimestamp } from "@/lib/issue-chat-messages";
|
||||
import { workModeMetaFor } from "@/lib/work-mode-meta";
|
||||
|
||||
function toMs(value: Date | string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
|
||||
/**
|
||||
@@ -44,6 +54,9 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const {
|
||||
comments,
|
||||
interactions,
|
||||
timelineEvents,
|
||||
issueId = null,
|
||||
agentMap,
|
||||
userLabelMap,
|
||||
currentUserId,
|
||||
@@ -65,6 +78,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
issueStatus,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
onSubmitInteractionVerdicts,
|
||||
externalReferences,
|
||||
} = props;
|
||||
|
||||
const agentModeLabel = workModeMetaFor(issueWorkMode).label;
|
||||
@@ -148,6 +167,64 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return map;
|
||||
}, [linkedRuns]);
|
||||
|
||||
const { data: planDocument } = useIssuePlanDocument(issueId);
|
||||
|
||||
// Comments, interactions, and the plan-doc marker merged into one
|
||||
// chronological backbone (same sort keys and same-run handoff shift as the
|
||||
// legacy buildIssueChatMessages), so plan-mode confirmation/question cards
|
||||
// land where they happened in the conversation.
|
||||
const orderedEntries = useMemo(() => {
|
||||
const entries: { ms: number; order: number; id: string; item: TaskChatItem }[] = [];
|
||||
// commentsToTaskChatItems skips deleted comments — mirror its filter so the
|
||||
// two lists stay index-aligned.
|
||||
const visibleComments = comments.filter((comment) => !comment.deletedAt);
|
||||
visibleComments.forEach((comment, index) => {
|
||||
const item = commentItems[index];
|
||||
if (!item) return;
|
||||
entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item });
|
||||
});
|
||||
for (const interaction of interactions ?? []) {
|
||||
const createdAtMs = toMs(interaction.createdAt);
|
||||
const handoffAtMs =
|
||||
interaction.kind === "request_confirmation" && interaction.sourceRunId
|
||||
? latestSameRunHandoffTimestamp({
|
||||
interactionCreatedAtMs: createdAtMs,
|
||||
sourceRunId: interaction.sourceRunId,
|
||||
comments,
|
||||
timelineEvents: timelineEvents ?? [],
|
||||
linkedRuns: linkedRuns ?? [],
|
||||
liveRuns: liveRuns ?? [],
|
||||
})
|
||||
: null;
|
||||
const id = `interaction:${interaction.id}`;
|
||||
entries.push({
|
||||
ms: handoffAtMs ?? createdAtMs,
|
||||
order: 2,
|
||||
id,
|
||||
item: { id, kind: "interaction", interaction },
|
||||
});
|
||||
}
|
||||
if (planDocument) {
|
||||
const revision = planDocument.latestRevisionNumber ?? 1;
|
||||
const id = `plan-doc:${planDocument.latestRevisionId ?? planDocument.id}`;
|
||||
entries.push({
|
||||
ms: toMs(planDocument.updatedAt),
|
||||
order: 0,
|
||||
id,
|
||||
item: {
|
||||
id,
|
||||
kind: "marker",
|
||||
variant: "turn_boundary",
|
||||
label: revision > 1 ? "Plan updated" : "Plan created",
|
||||
detail: `rev ${revision} — see the Plan tab`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return entries.sort(
|
||||
(a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id),
|
||||
);
|
||||
}, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument]);
|
||||
|
||||
const items = useMemo<TaskChatItem[]>(() => {
|
||||
// Settled turns for every terminal run whose transcript we have. The
|
||||
// transcript's assistant text is excluded — it already landed as the run's
|
||||
@@ -202,10 +279,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
}
|
||||
|
||||
const out: TaskChatItem[] = [];
|
||||
for (const item of commentItems) {
|
||||
const preceding = turnsByAnchor.get(item.id);
|
||||
for (const entry of orderedEntries) {
|
||||
const preceding = turnsByAnchor.get(entry.id);
|
||||
if (preceding) out.push(...preceding);
|
||||
out.push(item);
|
||||
out.push(entry.item);
|
||||
}
|
||||
out.push(...unanchored);
|
||||
|
||||
@@ -243,7 +320,37 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
return out;
|
||||
// `tick` is intentionally a dependency so elapsed advances each second.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [commentItems, runs, liveRun, transcriptByRun, linkedRunMetaById, firstCommentIdByRun, tick]);
|
||||
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, firstCommentIdByRun, tick]);
|
||||
|
||||
const renderInteraction = useCallback(
|
||||
(item: TaskChatInteractionItem) => (
|
||||
<TaskChatInteractionCard
|
||||
item={item}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
onUploadImage={imageUploadHandler}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
),
|
||||
[
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
onSubmitInteractionVerdicts,
|
||||
imageUploadHandler,
|
||||
externalReferences,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -254,11 +361,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
) : (
|
||||
<TaskChatThreadView items={items} />
|
||||
<TaskChatThreadView items={items} renderInteraction={renderInteraction} />
|
||||
)}
|
||||
</div>
|
||||
{showComposer ? (
|
||||
<div className="sticky bottom-0 z-10 mx-auto flex w-full max-w-3xl flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="sticky 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">
|
||||
{composerAccessory}
|
||||
<TaskChatComposer
|
||||
onAdd={onAdd}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Issue } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
|
||||
interface IssuePropertiesPlansTabProps {
|
||||
issue: Issue;
|
||||
@@ -11,30 +13,48 @@ interface IssuePropertiesPlansTabProps {
|
||||
/**
|
||||
* Plans tab of the redesigned properties pane (flag: enableTaskChatRedesign).
|
||||
*
|
||||
* Migrates the accepted-plan decomposition history (formerly pinned above the
|
||||
* tabs via IssuePlanDecompositionsSection) into a dedicated tab. Structured live
|
||||
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in the
|
||||
* /dev/task-chat-lab harness); until it lands, the tab shows accepted-plan
|
||||
* revisions or an empty state.
|
||||
* Owns the plan surface with the flag ON: the `plan` document itself (formerly
|
||||
* pinned above the tabs via IssueDocumentsSection, which the chat shell gates
|
||||
* off) rendered above the accepted-plan decomposition history. Structured live
|
||||
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in
|
||||
* the /dev/task-chat-lab harness).
|
||||
*/
|
||||
export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
|
||||
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
|
||||
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
|
||||
});
|
||||
const hasPlans = (data?.length ?? 0) > 0;
|
||||
|
||||
if (!hasPlans) {
|
||||
if (!planDocument && !hasPlans) {
|
||||
return (
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
No plan yet. Accepted plans and their revisions will appear here.
|
||||
{planDocumentLoading
|
||||
? "Loading plan…"
|
||||
: "No plan yet. The plan document, accepted plans, and their revisions will appear here."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
|
||||
<div className="space-y-4 py-2">
|
||||
{planDocument ? (
|
||||
<section data-testid="issue-plan-document" className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Revision ${planDocument.latestRevisionNumber ?? 1} · updated ${new Date(planDocument.updatedAt).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}`}
|
||||
</div>
|
||||
<MarkdownBody>{planDocument.body}</MarkdownBody>
|
||||
</section>
|
||||
) : null}
|
||||
{hasPlans ? (
|
||||
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,9 +22,6 @@ function createRequestConfirmation(
|
||||
summary: "Review and approve the latest plan.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
resolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
createdByAgentId: "agent-1",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
@@ -78,26 +75,6 @@ describe("TaskChatInteractionCard", () => {
|
||||
expect(container.textContent).toContain("Approve the plan");
|
||||
});
|
||||
|
||||
it("puts the primary CTA on the right via a reversed action row", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatInteractionCard item={interactionItem(createRequestConfirmation())} />
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const confirm = buttons.find((button) => button.textContent === "Confirm");
|
||||
const decline = buttons.find((button) => button.textContent === "Decline");
|
||||
expect(confirm).not.toBeUndefined();
|
||||
expect(decline).not.toBeUndefined();
|
||||
const row = confirm?.parentElement;
|
||||
expect(row).toBe(decline?.parentElement);
|
||||
expect(row?.className).toContain("flex-row-reverse");
|
||||
});
|
||||
|
||||
it("demotes an expired confirmation to a marker row", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
|
||||
@@ -38,7 +38,7 @@ export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteract
|
||||
className="tc-enter-bubble w-full"
|
||||
data-testid="task-chat-interaction"
|
||||
>
|
||||
<IssueThreadInteractionCard interaction={interaction} primaryActionOnRight {...cardProps} />
|
||||
<IssueThreadInteractionCard interaction={interaction} {...cardProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
import type { TaskChatInteractionItem, TaskChatItem } from "./task-chat-model";
|
||||
import { TaskChatTurn } from "./TaskChatTurn";
|
||||
import { TaskChatBubble } from "./TaskChatBubble";
|
||||
import { TaskChatMarker } from "./TaskChatMarker";
|
||||
@@ -12,6 +13,12 @@ import { TaskMessageScroller } from "./TaskMessageScroller";
|
||||
interface TaskChatThreadViewProps {
|
||||
items: TaskChatItem[];
|
||||
onApprovalDecision?: (statusItemId: string, optionId: string) => void;
|
||||
/**
|
||||
* Renders an interleaved issue-thread interaction (the live thread supplies
|
||||
* TaskChatInteractionCard bound to its accept/reject handlers). Interaction
|
||||
* items render nothing without it — the harness has no control plane.
|
||||
*/
|
||||
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode;
|
||||
className?: string;
|
||||
/** When false, render the list without the scroll container (e.g. previews). */
|
||||
scroll?: boolean;
|
||||
@@ -20,6 +27,7 @@ interface TaskChatThreadViewProps {
|
||||
function renderItem(
|
||||
item: TaskChatItem,
|
||||
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
|
||||
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
|
||||
) {
|
||||
switch (item.kind) {
|
||||
case "message":
|
||||
@@ -39,6 +47,8 @@ function renderItem(
|
||||
);
|
||||
case "usage":
|
||||
return <TaskChatUsageReadout item={item} />;
|
||||
case "interaction":
|
||||
return renderInteraction ? renderInteraction(item) : null;
|
||||
case "turn":
|
||||
return (
|
||||
<TaskChatTurn
|
||||
@@ -63,13 +73,14 @@ function renderItem(
|
||||
export function TaskChatThreadView({
|
||||
items,
|
||||
onApprovalDecision,
|
||||
renderInteraction,
|
||||
className,
|
||||
scroll = true,
|
||||
}: TaskChatThreadViewProps) {
|
||||
const body = (
|
||||
<div className={cn("mx-auto flex w-full max-w-3xl 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-3 px-4 py-4", className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>{renderItem(item, onApprovalDecision)}</div>
|
||||
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* inventory for the mapping. No timing/motion values live here — those are
|
||||
* CSS motion tokens in ui/src/index.css.
|
||||
*/
|
||||
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
|
||||
|
||||
/** Who authored a thread row — the primary legibility signal. */
|
||||
export type TaskChatAuthorKind = "human" | "agent" | "system";
|
||||
@@ -147,6 +148,18 @@ export interface TaskChatUsageItem {
|
||||
usage: TaskChatTokenUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* An issue-thread interaction (plan confirmation, question card, suggested
|
||||
* tasks…) interleaved chronologically into the thread. The payload is the
|
||||
* existing control-plane IssueThreadInteraction — the render layer wraps the
|
||||
* shared IssueThreadInteractionCard rather than re-modeling the five kinds.
|
||||
*/
|
||||
export interface TaskChatInteractionItem {
|
||||
id: string;
|
||||
kind: "interaction";
|
||||
interaction: IssueThreadInteraction;
|
||||
}
|
||||
|
||||
/** Items a turn can group — everything except another turn. */
|
||||
export type TaskChatTurnChildItem =
|
||||
| TaskChatMessageItem
|
||||
@@ -190,6 +203,7 @@ export type TaskChatItem =
|
||||
| TaskChatStatusItem
|
||||
| TaskChatMarkerItem
|
||||
| TaskChatUsageItem
|
||||
| TaskChatInteractionItem
|
||||
| TaskChatTurnItem;
|
||||
|
||||
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
|
||||
|
||||
@@ -256,6 +256,10 @@
|
||||
follow + pinned composer) inside the page flow. 21rem ≈ chrome above the
|
||||
thread (topbar + title row + tabs) plus page padding. */
|
||||
--tc-thread-max-h: calc(100dvh - 21rem);
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
||||
@@ -685,9 +685,16 @@ function IssueDetailLoadingState({
|
||||
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
|
||||
}) {
|
||||
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
|
||||
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? "mx-auto w-full max-w-(--tc-shell-max-w) space-y-6"
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
|
||||
@@ -4073,7 +4080,13 @@ export function IssueDetail() {
|
||||
|
||||
return (
|
||||
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? "mx-auto w-full max-w-(--tc-shell-max-w) space-y-6"
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
{/* Parent chain breadcrumb */}
|
||||
{ancestors.length > 0 && (
|
||||
<nav className="flex items-center gap-1 text-xs text-muted-foreground flex-wrap">
|
||||
|
||||
Reference in new issue
Block a user