mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
feat(ui): task-chat phase 2e — pane tabs in header bar, chat-only center, non-sticky header, plan CTA action bar
Flag-gated (enableTaskChatRedesign), flag OFF byte-identical: - Properties | Plan | Artifacts tab strip portals from IssueProperties into the pane's header bar (new slot left of the maximize/close controls). - Center Chat/Activity/Related-work tab strip hidden — the thread is the page; deep links that switched tabs stay on chat. - Issue header (parent breadcrumb, title/badge block, plugin toolbars, load- earlier button) renders INSIDE the thread scroll viewport, so it scrolls away with the messages instead of staying pinned. - --tc-thread-max-h reserve 21rem → 8rem: composer now sits ~26px above the viewport bottom. - New IssuePlanConfirmationActionBar: while a plan confirmation is pending, its Confirm/Decline CTAs (with decline-reason flow) pin to a footer slot below the pane's scroll area on the Plan tab. Verified: ui tsc clean; motion gate (25 files) + token gates clean; 218 tests across task-chat/pane/IssueDetail/IssueChatThread (1 pre-existing TZ flake in IssueProperties monitor row, fails on base); Playwright on :3093 light+dark — tabs in pane header, header scrolls away/returns, thread bottom 923/950, plan bar pinned at pane bottom in footer slot, zero page errors; flag-off run shows legacy tabs/pane title, 0 redesign testids. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ad018a9224
commit
9a5f7e26e4
7 files changed
+249
-126
No files matched your search
@@ -47,6 +47,19 @@ export function PropertiesPanel() {
|
||||
* variant. Everything below renders only when the flag is ON.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Portal target in the redesigned pane's header bar: hosted content (the
|
||||
* Properties | Plan | Artifacts tab strip) renders here, left of the window
|
||||
* controls. See IssueProperties' flag-ON shell.
|
||||
*/
|
||||
export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot";
|
||||
/**
|
||||
* Portal target pinned below the pane's scroll area: hosted content (the plan
|
||||
* confirmation action bar) renders here so it stays visible while the pane
|
||||
* body scrolls.
|
||||
*/
|
||||
export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot";
|
||||
|
||||
const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth";
|
||||
const DEFAULT_PANE_WIDTH = 322;
|
||||
const MIN_PANE_WIDTH = 260;
|
||||
@@ -340,9 +353,13 @@ function ResizablePropertiesPanel({
|
||||
className={cn("flex-1 flex flex-col min-h-0", isFixed && "w-full")}
|
||||
style={isFixed ? undefined : { width, minWidth: width }}
|
||||
>
|
||||
{/* No pane title: the tab strip below (Properties/…) already names the
|
||||
surface — this row is just the window controls. */}
|
||||
<div className="flex items-center justify-end px-4 py-2 border-b border-border">
|
||||
{/* No pane title: the tab strip (portaled into the slot by
|
||||
IssueProperties) names the surface; window controls sit right. */}
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-border">
|
||||
<div
|
||||
id={PROPERTIES_PANE_HEADER_SLOT_ID}
|
||||
className="flex min-w-0 flex-1 items-center self-stretch"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -371,6 +388,7 @@ function ResizablePropertiesPanel({
|
||||
{panelContent}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div id={PROPERTIES_PANE_FOOTER_SLOT_ID} className="shrink-0" />
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@@ -84,6 +84,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
onCancelInteraction,
|
||||
onSubmitInteractionVerdicts,
|
||||
externalReferences,
|
||||
threadHeader,
|
||||
} = props;
|
||||
|
||||
const agentModeLabel = workModeMetaFor(issueWorkMode).label;
|
||||
@@ -359,9 +360,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{threadHeader ? (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4 pt-4"
|
||||
data-testid="task-chat-thread-header"
|
||||
>
|
||||
{threadHeader}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatThreadView items={items} renderInteraction={renderInteraction} />
|
||||
<TaskChatThreadView items={items} header={threadHeader} renderInteraction={renderInteraction} />
|
||||
)}
|
||||
</div>
|
||||
{showComposer ? (
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { issueStatusText } from "@/lib/status-colors";
|
||||
import { Link } from "@/lib/router";
|
||||
@@ -170,6 +172,18 @@ export function IssueProperties({
|
||||
// OFF renders today's stacked sections verbatim (no Tabs wrapper). This pane
|
||||
// is always task-scoped, so the flag alone is a sufficient gate.
|
||||
const taskChatRedesignEnabled = experimentalSettings?.enableTaskChatRedesign === true;
|
||||
// When hosted by the redesigned PropertiesPanel, the tab strip portals into
|
||||
// the pane's header bar (left of the window controls). The slot only exists
|
||||
// once the panel has committed, hence the effect; inline hosts (mobile sheet)
|
||||
// keep the tab strip in place.
|
||||
const [paneHeaderSlot, setPaneHeaderSlot] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!taskChatRedesignEnabled || inline) {
|
||||
setPaneHeaderSlot(null);
|
||||
return;
|
||||
}
|
||||
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
|
||||
}, [taskChatRedesignEnabled, inline]);
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
/** When a run is live, a selection is staged here until the operator confirms
|
||||
@@ -2458,17 +2472,24 @@ export function IssueProperties({
|
||||
|
||||
// Flag ON: wrap the same body in a Properties | Plan | Artifacts tab shell
|
||||
// (v5 decision: singular "Plan", Docs merged into Artifacts). The Properties
|
||||
// tab is unchanged.
|
||||
// tab is unchanged. Panel hosts portal the strip into the pane header bar;
|
||||
// portals keep React context, so the Tabs root still drives it.
|
||||
const tabStrip = (
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={paneHeaderSlot ? "justify-start gap-1" : "w-full justify-start gap-1"}
|
||||
>
|
||||
<TabsTrigger value="properties">Properties</TabsTrigger>
|
||||
<TabsTrigger value="plans">Plan</TabsTrigger>
|
||||
<TabsTrigger value="artifacts">Artifacts</TabsTrigger>
|
||||
</TabsList>
|
||||
);
|
||||
return (
|
||||
<Tabs defaultValue="properties" className="flex min-h-0 flex-col gap-3">
|
||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||
<TabsTrigger value="properties">Properties</TabsTrigger>
|
||||
<TabsTrigger value="plans">Plan</TabsTrigger>
|
||||
<TabsTrigger value="artifacts">Artifacts</TabsTrigger>
|
||||
</TabsList>
|
||||
{paneHeaderSlot ? createPortal(tabStrip, paneHeaderSlot) : tabStrip}
|
||||
<TabsContent value="properties">{propertiesBody}</TabsContent>
|
||||
<TabsContent value="plans">
|
||||
<IssuePropertiesPlansTab issue={issue} />
|
||||
<IssuePropertiesPlansTab issue={issue} inline={inline} />
|
||||
</TabsContent>
|
||||
<TabsContent value="artifacts">
|
||||
<IssuePropertiesArtifactsTab issue={issue} />
|
||||
|
||||
@@ -5,9 +5,13 @@ import { queryKeys } from "@/lib/queryKeys";
|
||||
import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar";
|
||||
|
||||
interface IssuePropertiesPlansTabProps {
|
||||
issue: Issue;
|
||||
/** True when hosted outside the properties panel (mobile sheet) — the plan
|
||||
* confirmation bar then renders in place instead of the pane footer slot. */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +23,7 @@ interface IssuePropertiesPlansTabProps {
|
||||
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in
|
||||
* the /dev/task-chat-lab harness).
|
||||
*/
|
||||
export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
|
||||
export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) {
|
||||
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
|
||||
@@ -39,6 +43,9 @@ export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Pending plan confirmation: its CTAs pin to the pane's footer slot so
|
||||
they stay visible while the plan scrolls. */}
|
||||
{planDocument ? <IssuePlanConfirmationActionBar issue={issue} inline={inline} /> : null}
|
||||
{planDocument ? (
|
||||
<section data-testid="issue-plan-document" className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -12,6 +12,12 @@ import { TaskMessageScroller } from "./TaskMessageScroller";
|
||||
|
||||
interface TaskChatThreadViewProps {
|
||||
items: TaskChatItem[];
|
||||
/**
|
||||
* Content rendered above the first message INSIDE the scroll viewport (the
|
||||
* issue header when hosted by the live thread) — it scrolls away with the
|
||||
* messages instead of staying pinned above the thread.
|
||||
*/
|
||||
header?: ReactNode;
|
||||
onApprovalDecision?: (statusItemId: string, optionId: string) => void;
|
||||
/**
|
||||
* Renders an interleaved issue-thread interaction (the live thread supplies
|
||||
@@ -72,6 +78,7 @@ function renderItem(
|
||||
*/
|
||||
export function TaskChatThreadView({
|
||||
items,
|
||||
header,
|
||||
onApprovalDecision,
|
||||
renderInteraction,
|
||||
className,
|
||||
@@ -79,6 +86,11 @@ export function TaskChatThreadView({
|
||||
}: 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)}>
|
||||
{header ? (
|
||||
<div className="flex flex-col gap-6 pb-2" data-testid="task-chat-thread-header">
|
||||
{header}
|
||||
</div>
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction)}</div>
|
||||
))}
|
||||
|
||||
+5
-3
@@ -253,9 +253,11 @@
|
||||
--bubble-agent: oklch(0.955 0 0);
|
||||
|
||||
/* Redesigned thread viewport bound: lets the thread own its scroll (auto-
|
||||
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);
|
||||
follow + pinned composer) inside the page flow. The issue header lives
|
||||
INSIDE the scroll viewport (it scrolls away with the messages), so the
|
||||
only chrome outside the thread is the top bar plus page padding ≈ 8rem —
|
||||
this is what keeps the composer pinned near the viewport bottom. */
|
||||
--tc-thread-max-h: calc(100dvh - 8rem);
|
||||
|
||||
/* Redesigned chat-shell column cap (phase 2c): one token caps the page
|
||||
wrapper, thread body, and pinned composer so they widen in lockstep. */
|
||||
|
||||
+162
-110
@@ -903,6 +903,12 @@ type IssueDetailChatTabProps = {
|
||||
composerRef: Ref<IssueChatComposerHandle>;
|
||||
/** Optional node rendered inline directly above the reply composer (e.g. the monitor strip). */
|
||||
composerAccessory?: ReactNode;
|
||||
/**
|
||||
* Issue header (title row, badges, plugin toolbars) that the redesigned
|
||||
* thread renders inside its scroll viewport so it scrolls away with the
|
||||
* messages (flag: enableTaskChatRedesign). Ignored by the legacy thread.
|
||||
*/
|
||||
threadHeader?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
||||
@@ -991,6 +997,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onWorkModeChange,
|
||||
composerRef,
|
||||
composerAccessory,
|
||||
threadHeader,
|
||||
footer,
|
||||
feedbackVotes,
|
||||
feedbackDataSharingPreference,
|
||||
@@ -1170,24 +1177,36 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
[resolvedActivity],
|
||||
);
|
||||
|
||||
const loadOlderButton = hasOlderComments ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={commentsLoadingOlder}
|
||||
onClick={onLoadOlderComments}
|
||||
>
|
||||
{commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{hasOlderComments ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={commentsLoadingOlder}
|
||||
onClick={onLoadOlderComments}
|
||||
>
|
||||
{commentsLoadingOlder ? "Loading earlier comments..." : "Load earlier comments"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Redesign: the button rides inside the thread's scroll viewport with the
|
||||
header so nothing sits above the thread in the page flow. */}
|
||||
{taskChatRedesignEnabled ? null : loadOlderButton}
|
||||
<ThreadComponent
|
||||
composerRef={composerRef}
|
||||
composerAccessory={composerAccessory}
|
||||
threadHeader={
|
||||
taskChatRedesignEnabled && (threadHeader || loadOlderButton) ? (
|
||||
<>
|
||||
{threadHeader}
|
||||
{loadOlderButton}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
comments={commentsWithRunMeta}
|
||||
interactions={interactions}
|
||||
feedbackVotes={feedbackVotes}
|
||||
@@ -1563,6 +1582,9 @@ export function IssueDetail() {
|
||||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const [fileViewerPromptOpen, setFileViewerPromptOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("chat");
|
||||
// Redesign: the center tab strip is hidden, so chat is the only surface —
|
||||
// deep links that would switch tabs (e.g. #document- hashes) stay on chat.
|
||||
const resolvedDetailTab = taskChatShellEnabled ? "chat" : detailTab;
|
||||
const [handoffFocusSignal, setHandoffFocusSignal] = useState(0);
|
||||
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
||||
approvalId: string;
|
||||
@@ -4084,17 +4106,12 @@ export function IssueDetail() {
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
|
||||
<div
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? "w-full space-y-6"
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
{/* Parent chain breadcrumb */}
|
||||
{ancestors.length > 0 && (
|
||||
// Task Chat Redesign ("not sticky" header): the parent breadcrumb, the
|
||||
// title/badge block, and the plugin toolbars render INSIDE the thread's
|
||||
// scroll viewport, so they scroll away with the messages and the composer
|
||||
// stays near the viewport bottom. Flag OFF renders the same nodes in the
|
||||
// page flow, in their original order relative to the alert banners.
|
||||
const ancestorsNav = ancestors.length > 0 && (
|
||||
<nav className={cn("flex items-center gap-1 text-xs text-muted-foreground flex-wrap", shellSectionClass)}>
|
||||
{[...ancestors].reverse().map((ancestor, i) => (
|
||||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
@@ -4118,90 +4135,9 @@ export function IssueDetail() {
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
<span className="text-foreground/60 truncate max-w-(--sz-200px)">{issue.title}</span>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{issue.hiddenAt && (
|
||||
<div className={cn("flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive", shellSectionClass)}>
|
||||
<EyeOff className="h-4 w-4 shrink-0" />
|
||||
This task is hidden
|
||||
</div>
|
||||
)}
|
||||
{activePauseHold && (
|
||||
<div className={cn("rounded-md border border-amber-500/35 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200", shellSectionClass)}>
|
||||
{activePauseHold.isRoot ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
|
||||
</span>
|
||||
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
{childIssues.length === 0
|
||||
? "Task execution is held until resume. Human comments can still wake the assignee for triage."
|
||||
: "Root and descendant execution is held until resume. Human comments can still wake assignee agents for triage."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
{childIssues.length === 0
|
||||
? "1 task held"
|
||||
: `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`}
|
||||
{activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
|
||||
</div>
|
||||
{canShowSubtreeControls || canResumeLeafWork ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
{childIssues.length === 0 ? "Resume work" : "Resume subtree"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
View affected ({childIssues.length === 0 ? 1 : heldDescendantCount})
|
||||
</Button>
|
||||
{canShowSubtreeControls ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setTreeControlMode("cancel");
|
||||
setTreeControlCancelConfirmed(false);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
Cancel subtree...
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs">
|
||||
This task is paused by ancestor{" "}
|
||||
{activePauseHoldRoot?.identifier ? (
|
||||
<Link to={createIssueDetailPath(activePauseHoldRoot.identifier)} className="underline">
|
||||
{activePauseHoldRoot.identifier}
|
||||
</Link>
|
||||
) : (
|
||||
activePauseHold.rootIssueId.slice(0, 8)
|
||||
)}
|
||||
. Resume from the root task to deliver deferred work.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
|
||||
const issueHeaderBlock = (
|
||||
<div className={cn("space-y-3", shellSectionClass)}>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<StatusIcon
|
||||
@@ -4553,7 +4489,10 @@ export function IssueDetail() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const pluginOutletsBlock = (
|
||||
<>
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["toolbarButton", "contextMenuItem"]}
|
||||
entityType="issue"
|
||||
@@ -4594,6 +4533,114 @@ export function IssueDetail() {
|
||||
itemClassName="rounded-lg border border-border p-3"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const taskChatThreadHeader = taskChatShellEnabled ? (
|
||||
<>
|
||||
{ancestorsNav}
|
||||
{issueHeaderBlock}
|
||||
{pluginOutletsBlock}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
|
||||
<div
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? "w-full space-y-6"
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
{/* Parent chain breadcrumb (redesign: rendered inside the thread viewport) */}
|
||||
{taskChatShellEnabled ? null : ancestorsNav}
|
||||
|
||||
{issue.hiddenAt && (
|
||||
<div className={cn("flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive", shellSectionClass)}>
|
||||
<EyeOff className="h-4 w-4 shrink-0" />
|
||||
This task is hidden
|
||||
</div>
|
||||
)}
|
||||
{activePauseHold && (
|
||||
<div className={cn("rounded-md border border-amber-500/35 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200", shellSectionClass)}>
|
||||
{activePauseHold.isRoot ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
|
||||
</span>
|
||||
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
{childIssues.length === 0
|
||||
? "Task execution is held until resume. Human comments can still wake the assignee for triage."
|
||||
: "Root and descendant execution is held until resume. Human comments can still wake assignee agents for triage."}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
{childIssues.length === 0
|
||||
? "1 task held"
|
||||
: `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`}
|
||||
{activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
|
||||
</div>
|
||||
{canShowSubtreeControls || canResumeLeafWork ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
{childIssues.length === 0 ? "Resume work" : "Resume subtree"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
View affected ({childIssues.length === 0 ? 1 : heldDescendantCount})
|
||||
</Button>
|
||||
{canShowSubtreeControls ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setTreeControlMode("cancel");
|
||||
setTreeControlCancelConfirmed(false);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
Cancel subtree...
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs">
|
||||
This task is paused by ancestor{" "}
|
||||
{activePauseHoldRoot?.identifier ? (
|
||||
<Link to={createIssueDetailPath(activePauseHoldRoot.identifier)} className="underline">
|
||||
{activePauseHoldRoot.identifier}
|
||||
</Link>
|
||||
) : (
|
||||
activePauseHold.rootIssueId.slice(0, 8)
|
||||
)}
|
||||
. Resume from the root task to deliver deferred work.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{taskChatShellEnabled ? null : issueHeaderBlock}
|
||||
|
||||
{taskChatShellEnabled ? null : pluginOutletsBlock}
|
||||
|
||||
{taskChatShellEnabled ? null : showRichSubIssuesSection ? (
|
||||
<div className="space-y-3">
|
||||
@@ -4760,9 +4807,12 @@ export function IssueDetail() {
|
||||
);
|
||||
})()}
|
||||
|
||||
<Separator className={shellSectionClass} />
|
||||
{taskChatShellEnabled ? null : <Separator className={shellSectionClass} />}
|
||||
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
|
||||
<Tabs value={resolvedDetailTab} onValueChange={setDetailTab} className="space-y-3">
|
||||
{/* Redesign: the chat IS the page — the Chat/Activity/Related-work tab
|
||||
strip is hidden and the thread renders as the only surface. */}
|
||||
{taskChatShellEnabled ? null : (
|
||||
<TabsList variant="line" className={cn("w-full justify-start gap-1", shellSectionClass)}>
|
||||
<TabsTrigger value="chat" className="gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
@@ -4782,13 +4832,15 @@ export function IssueDetail() {
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
{/* Flag ON the thread viewport extends under main's horizontal padding
|
||||
(symmetric, so the centered column keeps the same axis) and the
|
||||
scrollbar sits flush against the properties-pane border. */}
|
||||
<TabsContent value="chat" className={taskChatShellEnabled ? "-mx-4 md:-mx-6" : undefined}>
|
||||
{detailTab === "chat" ? (
|
||||
{resolvedDetailTab === "chat" ? (
|
||||
<IssueDetailChatTab
|
||||
threadHeader={taskChatThreadHeader}
|
||||
issueId={issue.id}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId ?? null}
|
||||
|
||||
Reference in new issue
Block a user