mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
feat(ui): task-chat redesign baseline behind enableTaskChatRedesign
New ui/src/components/task-chat/ surface (model, TASK_CHAT_STATES inventory, fixtures, adapter, Bubble/Marker/StatusPill/ToolCard/ThinkingBlock/UsageReadout/ Scroller/PlanView/ThreadView, TweakPanel), motion tokens in index.css :root, useTaskChatRedesignEnabled hook + TaskChatRedesignGate, one-line ThreadComponent seam in IssueDetail.tsx, flag-scoped tabs in IssueProperties.tsx, dev harness /dev/task-chat-lab, gate script scripts/check-task-chat-motion.mjs. Flag wired through the 3 lockstep shared files (types/validators/feature-catalog), preference tier, default off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
677242344c
commit
d270ee47fd
24 files changed
+515
-3501
No files matched your search
@@ -97,9 +97,9 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableTaskChatRedesign: {
|
||||
title: "Chat-Style Tasks",
|
||||
title: "Task Chat Redesign",
|
||||
description:
|
||||
"Reimagines the task detail page as a live conversation with your agents: chat bubbles for people and agents, streaming activity — thinking, tool calls, diffs — that folds into a one-line summary when a turn finishes, inline plan/question/permission cards, a three-mode composer (Agent · Plan · Ask), and a resizable Properties · Plan · Artifacts pane.",
|
||||
"Render the task comment thread as a Claude Code-style session: agent lifecycle states, a plan/artifact properties pane, and a dev harness. Experimental; behind this flag the current thread is unchanged.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
@@ -135,6 +135,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableCloudSync: {
|
||||
title: "Cloud Sync",
|
||||
description:
|
||||
"Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableExternalObjects: {
|
||||
title: "External Objects",
|
||||
description:
|
||||
@@ -189,14 +197,6 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableSimplifiedEnglishInteractions: {
|
||||
title: "Simplified English Interactions",
|
||||
description:
|
||||
"Instruct agents to write user interactions (confirmations, questions, suggested tasks) in ASD-STE100 Simplified Technical English with brief decision context.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableServerInfoDebugView: {
|
||||
title: "Server Info Debug View",
|
||||
description:
|
||||
|
||||
@@ -27,7 +27,6 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const SCAN_DIRS = ["ui/src/components/task-chat"];
|
||||
const SCAN_FILES = [
|
||||
"ui/src/components/TaskChatThread.tsx",
|
||||
"ui/src/components/PropertiesPanel.tsx",
|
||||
"ui/src/components/TaskChatRedesignGate.tsx",
|
||||
"ui/src/pages/TaskChatLab.tsx",
|
||||
"ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx",
|
||||
|
||||
+21
-32
@@ -13,7 +13,6 @@ import { Cases } from "./pages/Cases";
|
||||
import { CaseDetail } from "./pages/CaseDetail";
|
||||
import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant";
|
||||
import { CloudAccessGate } from "./components/CloudAccessGate";
|
||||
import { PaperclipLoading } from "./components/AnimatedPaperclipIcon";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { DashboardLive } from "./pages/DashboardLive";
|
||||
import { Timeline } from "./pages/Timeline";
|
||||
@@ -41,17 +40,17 @@ import { GoalDetail } from "./pages/GoalDetail";
|
||||
import { Approvals } from "./pages/Approvals";
|
||||
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||
import { Costs } from "./pages/Costs";
|
||||
import { CompanyActivity } from "./pages/audit/CompanyActivity";
|
||||
import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { WhatNeedsMe } from "./pages/WhatNeedsMe";
|
||||
import { DecisionQueuePage } from "./pages/DecisionQueuePage";
|
||||
import { TrainingInspector, TrainingLibrary } from "./pages/Training";
|
||||
import { BoardChat } from "./pages/BoardChat";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { CloudUpstream } from "./pages/CloudUpstream";
|
||||
import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab";
|
||||
import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab";
|
||||
import { ResponsibleUserDenialUxLab } from "./pages/ResponsibleUserDenialUxLab";
|
||||
import { CrossIssueCollaborationUxLab } from "./pages/CrossIssueCollaborationUxLab";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
||||
import { AdvancedToolsRoute } from "./pages/tools/AdvancedToolsRoute";
|
||||
@@ -60,7 +59,6 @@ import { ProfileDetailRoute } from "./pages/tools/profiles/ProfileDetailRoute";
|
||||
import { Connections } from "./pages/apps/Connections";
|
||||
import { Browse } from "./pages/apps/Browse";
|
||||
import { AppsConnect } from "./pages/apps/AppsConnect";
|
||||
import { canEnterAppsConnect } from "./pages/apps/app-connect-policy";
|
||||
import { AppsReview } from "./pages/apps/AppsReview";
|
||||
import { AppDetail } from "./pages/apps/AppDetail";
|
||||
import { AppNotConnected } from "./pages/apps/AppNotConnected";
|
||||
@@ -110,7 +108,7 @@ function boardRoutes() {
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/environments" element={<Navigate to="/company/settings/instance/environments" replace />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<Navigate to="/company/export" replace />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/invites" element={<CompanyInvites />} />
|
||||
@@ -122,15 +120,14 @@ function boardRoutes() {
|
||||
<Route path="tools" element={<LegacyToolsRedirect />} />
|
||||
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
|
||||
<Route element={<AppsExperimentalGate />}>
|
||||
<Route path="apps" element={<Browse />} />
|
||||
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connections" element={<Connections />} />
|
||||
<Route path="apps" element={<Connections />} />
|
||||
<Route path="apps/browse" element={<Browse />} />
|
||||
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
|
||||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps/browse" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps/browse" replace />} />
|
||||
<Route path="apps/review" element={<AppsReview />} />
|
||||
{/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */}
|
||||
<Route path="apps/attention" element={<Navigate to="/apps/connections" replace />} />
|
||||
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/gateways" element={<GatewaysList />} />
|
||||
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
|
||||
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
|
||||
@@ -263,10 +260,7 @@ function boardRoutes() {
|
||||
<Route path="approvals/all" element={<Approvals />} />
|
||||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||
<Route path="costs" element={<Costs />} />
|
||||
<Route path="activity" element={<CompanyActivity />} />
|
||||
{/* `/audit` merged into the single Activity page (PAP-16302). Existing deep
|
||||
links keep working, preset to the agent-actions scope. */}
|
||||
<Route path="audit" element={<Navigate to="/activity?mode=agents" replace />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
{/* Conference Room Chat surfaces (PAP-136/PAP-137): routes stay
|
||||
registered but redirect to the company home while the experimental
|
||||
flag is off. The board-level `artifacts` mount below is the new
|
||||
@@ -276,16 +270,12 @@ function boardRoutes() {
|
||||
<Route path="board-chat" element={<BoardChat />} />
|
||||
<Route path="artifacts" element={<Artifacts />} />
|
||||
</Route>
|
||||
{/* Task Chat Redesign dev harness — dev builds only, and additionally
|
||||
gated by enableTaskChatRedesign (redirects to /dashboard when the
|
||||
flag is off). */}
|
||||
{import.meta.env.DEV ? (
|
||||
<Route element={<TaskChatRedesignGate />}>
|
||||
<Route path="dev/task-chat-lab" element={<TaskChatLab />} />
|
||||
</Route>
|
||||
) : null}
|
||||
{/* Task Chat Redesign dev harness — gated by enableTaskChatRedesign;
|
||||
redirects to /dashboard when the flag is off. */}
|
||||
<Route element={<TaskChatRedesignGate />}>
|
||||
<Route path="dev/task-chat-lab" element={<TaskChatLab />} />
|
||||
</Route>
|
||||
<Route path="decisions" element={<WhatNeedsMe />} />
|
||||
<Route path="decisions/queues/:key" element={<DecisionQueuePage />} />
|
||||
<Route path="decisions/training" element={<TrainingLibrary />} />
|
||||
<Route path="decisions/training/:id" element={<TrainingInspector />} />
|
||||
<Route path="training" element={<Navigate to="/decisions/training" replace />} />
|
||||
@@ -310,7 +300,7 @@ function boardRoutes() {
|
||||
function AppsConnectEntryRoute() {
|
||||
const location = useLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
return canEnterAppsConnect(searchParams) ? <AppsConnect /> : <Navigate to="/apps" replace />;
|
||||
return searchParams.get("byo") === "1" ? <AppsConnect /> : <Navigate to="/apps/browse" replace />;
|
||||
}
|
||||
|
||||
function InboxRootRedirect() {
|
||||
@@ -355,7 +345,7 @@ function LegacySettingsRedirect() {
|
||||
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
|
||||
|
||||
if (loading) {
|
||||
return <PaperclipLoading />;
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
const targetCompany =
|
||||
@@ -404,7 +394,7 @@ function LegacyToolsRedirect() {
|
||||
|
||||
function legacyToolsRedirectTarget(tab?: string) {
|
||||
if (!tab) return "/apps/advanced/profiles";
|
||||
if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections";
|
||||
if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps";
|
||||
return `/apps/advanced/${tab}`;
|
||||
}
|
||||
|
||||
@@ -463,7 +453,7 @@ function CompanyRootRedirect() {
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <PaperclipLoading />;
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||
@@ -494,7 +484,7 @@ function UnprefixedBoardRedirect() {
|
||||
const { companies, selectedCompany, loading } = useCompany();
|
||||
|
||||
if (loading) {
|
||||
return <PaperclipLoading />;
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
const targetCompany = selectedCompany ?? companies[0] ?? null;
|
||||
@@ -550,9 +540,9 @@ export function App() {
|
||||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
|
||||
<Route path="ux-lab/cloud-upstream" element={<CloudUpstreamUxLab />} />
|
||||
<Route path="ux-lab/bootstrap-setup" element={<BootstrapSetupUxLab />} />
|
||||
<Route path="ux-lab/responsible-user-denial" element={<ResponsibleUserDenialUxLab />} />
|
||||
<Route path="ux-lab/cross-issue-collaboration" element={<CrossIssueCollaborationUxLab />} />
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
<Route index element={<CompanyRootRedirect />} />
|
||||
@@ -580,7 +570,6 @@ export function App() {
|
||||
<Route path="pipelines/:pipelineId/items/:caseId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="pipelines/:pipelineId/cases/:caseId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="artifacts" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="audit" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="decisions" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="skills/studio" element={<UnprefixedBoardRedirect />} />
|
||||
|
||||
@@ -1,46 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
|
||||
import { useEffect, useMemo, 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";
|
||||
import {
|
||||
assembleThreadItems,
|
||||
attachSettledTurns,
|
||||
buildTurnSummary,
|
||||
coalesceSettledTurns,
|
||||
deriveRunStatusLabel,
|
||||
isNestableLiveChild,
|
||||
isTerminalRunStatus,
|
||||
prependIssueBrief,
|
||||
settledRunChildren,
|
||||
transcriptToTaskChatItems,
|
||||
type SettledTurnMergeMeta,
|
||||
} from "@/components/task-chat/transcript-adapter";
|
||||
import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble";
|
||||
import type {
|
||||
TaskChatInteractionItem,
|
||||
TaskChatItem,
|
||||
TaskChatMessageItem,
|
||||
TaskChatTurnItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
|
||||
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
|
||||
import type { FeedbackVoteValue } from "@paperclipai/shared";
|
||||
import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView";
|
||||
import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
|
||||
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
|
||||
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
|
||||
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 { 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;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
|
||||
@@ -56,30 +25,12 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
* - the live run transcript (useLiveRunTranscripts, the same poll+websocket
|
||||
* source the current thread uses) → the in-flight turn streams
|
||||
* thinking → tool → diff → responding, capped by a live "running" status
|
||||
* pill.
|
||||
*
|
||||
* Run activity is grouped into TaskChatTurnItem: the in-flight run renders as
|
||||
* ONE expandable parent row — its status line (whimsy gerund or in-flight
|
||||
* tool state + elapsed + tokens) is the turn's single visible line, with the
|
||||
* chronological tool activity nested behind an expand (PAP-354). Mid-run
|
||||
* agent text (interstitial updates between tool calls) is ephemeral
|
||||
* (PAP-361, round 9): while one streams it occupies a dedicated one-line row
|
||||
* PERMANENTLY RESERVED above the status line (so the layout above never
|
||||
* jumps), and when it finishes the text slides out and the slot sits empty —
|
||||
* nothing persists; the run log and the classic transcript remain the
|
||||
* archive. When the run terminates, its settled turn anchors after the run's
|
||||
* last comment (comment.runId linkage) and — when it directly follows that
|
||||
* reply bubble — attaches to it: the "✓ Worked · …" summary renders appended
|
||||
* to the bubble's always-visible timestamp line (round 9), still expandable
|
||||
* to the tool history. Turns without a reply bubble keep the standalone
|
||||
* folded row. flag-OFF remains byte-for-byte IssueChatThread.
|
||||
* pill. Only the non-terminal run streams; once it finishes its output
|
||||
* lands as a comment. flag-OFF remains byte-for-byte IssueChatThread.
|
||||
*/
|
||||
export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const {
|
||||
comments,
|
||||
interactions,
|
||||
timelineEvents,
|
||||
issueId = null,
|
||||
agentMap,
|
||||
userLabelMap,
|
||||
currentUserId,
|
||||
@@ -95,56 +46,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
linkedRuns,
|
||||
liveRuns,
|
||||
activeRun,
|
||||
onAttachImage,
|
||||
imageUploadHandler,
|
||||
mentions,
|
||||
enableReassign,
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
issueStatus,
|
||||
issueAssigneeAgentId = null,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
onSubmitInteractionVerdicts,
|
||||
externalReferences,
|
||||
threadHeader,
|
||||
workModeChanges,
|
||||
issueBrief,
|
||||
feedbackVotes,
|
||||
feedbackDataSharingPreference = "prompt",
|
||||
feedbackTermsUrl = null,
|
||||
onVote,
|
||||
} = props;
|
||||
|
||||
const linkedRunMetaById = useMemo(() => {
|
||||
const map = new Map<string, NonNullable<TaskChatThreadProps["linkedRuns"]>[number]>();
|
||||
for (const run of linkedRuns ?? []) map.set(run.runId, run);
|
||||
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],
|
||||
() => commentsToTaskChatItems(comments, { agentMap, userLabelMap, currentUserId }),
|
||||
[comments, agentMap, userLabelMap, currentUserId],
|
||||
);
|
||||
|
||||
// Every run we might need a transcript for (history + live), deduped by id.
|
||||
@@ -185,333 +91,55 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
|
||||
// The single in-flight run whose turn we stream live (non-terminal).
|
||||
const liveRun = useMemo(() => {
|
||||
if (isTerminalIssueStatus(issueStatus)) return null;
|
||||
if (activeRun && isLiveIssueRun(activeRun, issueStatus)) return activeRun;
|
||||
return (liveRuns ?? []).find((r) => isLiveIssueRun(r, issueStatus)) ?? null;
|
||||
}, [activeRun, issueStatus, liveRuns]);
|
||||
if (activeRun && !isTerminalRunStatus(activeRun.status)) return activeRun;
|
||||
return (liveRuns ?? []).find((r) => !isTerminalRunStatus(r.status)) ?? null;
|
||||
}, [activeRun, liveRuns]);
|
||||
|
||||
// Runs observed non-terminal while mounted: their turns ANIMATE the fold when
|
||||
// they settle. Runs already terminal at mount collapse instantly.
|
||||
const liveSeenRef = useRef<Set<string>>(new Set());
|
||||
// Re-render on a 1s cadence while live so the status pill's elapsed advances
|
||||
// even between transcript chunks.
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (liveRun) liveSeenRef.current.add(liveRun.id);
|
||||
if (!liveRun) return;
|
||||
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [liveRun]);
|
||||
|
||||
// Each terminal run's turn anchors immediately after the run's last comment
|
||||
// (its reply bubble), via the comment.runId linkage — the summary line lands
|
||||
// below the bubble, where the live "Running…" pill sat. A run with no linked
|
||||
// comment (stopped run, or reply not yet fetched) instead slots into the
|
||||
// backbone chronologically at its start time (PAP-367).
|
||||
const lastCommentIdByRun = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const comment of comments) {
|
||||
if (comment.deletedAt || !comment.runId || !comment.id) continue;
|
||||
map.set(comment.runId, comment.id);
|
||||
}
|
||||
return map;
|
||||
}, [comments]);
|
||||
|
||||
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]);
|
||||
|
||||
// Boolean gate (stable across the host's per-render brief objects) so the
|
||||
// heavy assembly memo doesn't recompute on every parent render.
|
||||
const hasBrief = Boolean(issueBrief);
|
||||
|
||||
const items = useMemo<TaskChatItem[]>(() => {
|
||||
// Settled turns for every terminal run whose transcript we have. Messages
|
||||
// and thinking are excluded entirely (PAP-361): the final reply already
|
||||
// landed as the run's comment bubble, interstitial updates are ephemeral
|
||||
// (live-line only), and thinking stays in the run log / classic
|
||||
// transcript — "Worked · N tools" expands to exactly the tool rows.
|
||||
const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; startMs: number }[] = [];
|
||||
// Raw summary inputs per turn id, so back-to-back same-agent runs can
|
||||
// coalesce into one "Worked" row in the final pass (PAP-362).
|
||||
const turnMergeMetaById = new Map<string, SettledTurnMergeMeta>();
|
||||
for (const source of runs) {
|
||||
if (!isTerminalRunStatus(source.status)) continue;
|
||||
if (liveRun && source.id === liveRun.id) continue;
|
||||
const entries = transcriptByRun.get(source.id) ?? [];
|
||||
if (entries.length === 0) continue;
|
||||
const meta = linkedRunMetaById.get(source.id);
|
||||
const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN;
|
||||
const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN;
|
||||
const durationMs =
|
||||
Number.isFinite(started) && Number.isFinite(finished)
|
||||
? Math.max(0, finished - started)
|
||||
: undefined;
|
||||
const parsed = transcriptToTaskChatItems(entries, {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
running: false,
|
||||
});
|
||||
const children = settledRunChildren(parsed);
|
||||
if (children.length === 0) continue;
|
||||
const failed = source.status !== "succeeded";
|
||||
const startSlotRaw = meta?.startedAt ?? meta?.createdAt;
|
||||
turnMergeMetaById.set(`${source.id}:turn`, {
|
||||
agentKey: meta?.agentId ?? "",
|
||||
agentName: meta?.agentName,
|
||||
parts: [{ entries, durationMs, failed }],
|
||||
});
|
||||
settledTurns.push({
|
||||
turn: {
|
||||
id: `${source.id}:turn`,
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
animateFold: liveSeenRef.current.has(source.id),
|
||||
items: children,
|
||||
summary: buildTurnSummary(entries, { durationMs, failed }),
|
||||
},
|
||||
anchorCommentId: lastCommentIdByRun.get(source.id) ?? null,
|
||||
// Chronological slot for a turn with no reply comment to anchor to
|
||||
// (PAP-367): the run's start time. A run with no linked meta yet
|
||||
// (just-settled, list not refetched) sits at the thread tail —
|
||||
// where it last rendered as the live turn — until meta arrives.
|
||||
startMs: startSlotRaw ? toMs(startSlotRaw) : Number.POSITIVE_INFINITY,
|
||||
});
|
||||
}
|
||||
settledTurns.sort((a, b) => (a.startMs < b.startMs ? -1 : a.startMs > b.startMs ? 1 : 0));
|
||||
|
||||
const turnsByAnchor = new Map<string, TaskChatTurnItem[]>();
|
||||
const unanchored: { turn: TaskChatTurnItem; startMs: number }[] = [];
|
||||
for (const { turn, anchorCommentId, startMs } of settledTurns) {
|
||||
if (anchorCommentId) {
|
||||
const list = turnsByAnchor.get(anchorCommentId) ?? [];
|
||||
list.push(turn);
|
||||
turnsByAnchor.set(anchorCommentId, list);
|
||||
} else {
|
||||
unanchored.push({ turn, startMs });
|
||||
}
|
||||
}
|
||||
|
||||
// Anchored turns follow their run's reply comment; comment-less turns
|
||||
// (stopped runs, or a reply not yet fetched) interleave chronologically at
|
||||
// their run's start time instead of piling up under the newest message
|
||||
// (PAP-367).
|
||||
const out = assembleThreadItems(orderedEntries, turnsByAnchor, unanchored);
|
||||
|
||||
if (liveRun) {
|
||||
const entries = transcriptByRun.get(liveRun.id) ?? [];
|
||||
const parsed = transcriptToTaskChatItems(entries, {
|
||||
runId: liveRun.id,
|
||||
agentName: liveRun.agentName,
|
||||
running: true,
|
||||
});
|
||||
// Parent-row model (PAP-354): the run's status line IS the live turn —
|
||||
// one expandable row owning the activity. Only tool/usage rows nest
|
||||
// inside it; a streaming interstitial update renders in the reserved row
|
||||
// above the status line (liveStatus.selfTalk, PAP-361/round 9) and
|
||||
// vanishes when it completes. The parent row sits last; on settle its
|
||||
// summary attaches to the reply bubble's timestamp line.
|
||||
const children = parsed.filter(isNestableLiveChild);
|
||||
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
|
||||
const queued = liveRun.status === "queued";
|
||||
const status = queued
|
||||
? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined }
|
||||
: deriveRunStatusLabel(entries);
|
||||
out.push({
|
||||
id: `${liveRun.id}:turn`,
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
items: children,
|
||||
summary: buildTurnSummary(entries),
|
||||
liveStatus: {
|
||||
id: `${liveRun.id}:status`,
|
||||
kind: "status",
|
||||
status: "running",
|
||||
label: status.label,
|
||||
detail: status.detail,
|
||||
toolName: status.toolName,
|
||||
selfTalk: status.selfTalk,
|
||||
startedAtMs: startedAt ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
// PAP-362: two runs replying back-to-back (same agent, nothing but the
|
||||
// agent's own bubbles between) fold into ONE "Worked" row below the last
|
||||
// reply; a user message, interaction, or the live turn keeps them apart.
|
||||
// Round 9: a settled turn directly following its own agent's reply bubble
|
||||
// then attaches to that bubble — the "Worked · …" summary renders on the
|
||||
// bubble's always-visible timestamp line instead of as a standalone row.
|
||||
// PAP-375: the description-as-first-bubble placeholder prepends LAST, after
|
||||
// every assembly/merge pass, so nothing can ever sort above it.
|
||||
return prependIssueBrief(
|
||||
attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
|
||||
hasBrief,
|
||||
);
|
||||
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
|
||||
|
||||
// Feedback votes keyed by the comment they target (targetType
|
||||
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
|
||||
// 👍/👎 state to each agent bubble by its comment id (PAP-413).
|
||||
const feedbackVoteByTargetId = useMemo(() => {
|
||||
const map = new Map<string, FeedbackVoteValue>();
|
||||
for (const feedbackVote of feedbackVotes ?? []) {
|
||||
if (feedbackVote.targetType !== "issue_comment") continue;
|
||||
map.set(feedbackVote.targetId, feedbackVote.vote);
|
||||
}
|
||||
return map;
|
||||
}, [feedbackVotes]);
|
||||
|
||||
// copy · 👍 · 👎 cluster for an agent bubble's footer line (PAP-413). Human
|
||||
// and system bubbles get nothing; copy is always available, and the feedback
|
||||
// buttons render only when the host wired a vote handler.
|
||||
const renderMessageActions = useCallback(
|
||||
(item: TaskChatMessageItem) => {
|
||||
if (item.author !== "agent" || item.optimistic) return null;
|
||||
return (
|
||||
<TaskChatBubbleActions
|
||||
copyText={item.text}
|
||||
feedback={
|
||||
onVote
|
||||
? {
|
||||
activeVote: feedbackVoteByTargetId.get(item.id) ?? null,
|
||||
sharingPreference: feedbackDataSharingPreference,
|
||||
termsUrl: feedbackTermsUrl,
|
||||
onVote: (vote, options) => onVote(item.id, vote, options),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl],
|
||||
);
|
||||
|
||||
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,
|
||||
],
|
||||
);
|
||||
|
||||
// Mobile (PAP-360): the app shell scrolls the DOCUMENT (Layout's main is
|
||||
// overflow-visible with auto height), so the desktop bounded h-dvh chain
|
||||
// collapses the absolute-inset transcript viewport to 0px. Render the thread
|
||||
// in document flow instead (the same scroll={false} path the previews use)
|
||||
// and track auto-follow against window scroll. Desktop stays byte-identical.
|
||||
const { isMobile } = useSidebar();
|
||||
useWindowAutoFollow(isMobile ? taskChatContentKey(items) : 0, isMobile);
|
||||
if (!liveRun) return commentItems;
|
||||
const entries = transcriptByRun.get(liveRun.id) ?? [];
|
||||
const turn = transcriptToTaskChatItems(entries, {
|
||||
runId: liveRun.id,
|
||||
agentName: liveRun.agentName,
|
||||
running: true,
|
||||
});
|
||||
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
|
||||
const elapsedMs = startedAt != null ? Math.max(0, Date.now() - startedAt) : undefined;
|
||||
const queued = liveRun.status === "queued";
|
||||
const status = queued ? { label: "Queued", detail: "Waiting to start" } : deriveRunStatusLabel(entries);
|
||||
const statusItem: TaskChatItem = {
|
||||
id: `${liveRun.id}:status`,
|
||||
kind: "status",
|
||||
status: "running",
|
||||
label: status.label,
|
||||
detail: status.detail,
|
||||
elapsedMs,
|
||||
};
|
||||
return [...commentItems, ...turn, statusItem];
|
||||
// `tick` is intentionally a dependency so elapsed advances each second.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [commentItems, liveRun, transcriptByRun, tick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col", !isMobile && "h-(--tc-thread-max-h) min-h-0 flex-1")}
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-thread">
|
||||
<div className="min-h-0 flex-1">
|
||||
{items.length === 0 ? (
|
||||
<div className={isMobile ? undefined : "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>
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
) : (
|
||||
<TaskChatThreadView
|
||||
items={items}
|
||||
header={threadHeader}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
|
||||
renderMessageActions={renderMessageActions}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
<TaskChatThreadView items={items} />
|
||||
)}
|
||||
</div>
|
||||
{showComposer ? (
|
||||
<div
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<div className="sticky bottom-0 z-10 flex 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}
|
||||
@@ -519,14 +147,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
disabled={Boolean(composerDisabledReason)}
|
||||
disabledReason={composerDisabledReason}
|
||||
onAttachImage={onAttachImage}
|
||||
onImageUpload={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
issueStatus={issueStatus}
|
||||
mobile={isMobile}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +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 { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { Link } from "@/lib/router";
|
||||
import { deriveOriginatingActor, type Issue, type IssueLabel } from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -13,7 +10,6 @@ import { authApi } from "../../api/auth";
|
||||
import { executionWorkspacesApi } from "../../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../../api/instanceSettings";
|
||||
import { issuesApi } from "../../api/issues";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { projectsApi } from "../../api/projects";
|
||||
import { useCompany } from "../../context/CompanyContext";
|
||||
import { queryKeys } from "../../lib/queryKeys";
|
||||
@@ -44,7 +40,6 @@ import { useRetryNowMutation } from "../../hooks/useRetryNowMutation";
|
||||
import { RetryErrorBand } from "../IssueScheduledRetryCard";
|
||||
import { StatusIcon } from "../StatusIcon";
|
||||
import { PriorityIcon } from "../PriorityIcon";
|
||||
import { SHOW_TASK_PRIORITY_UI } from "../../lib/ui-flags";
|
||||
import { Identity } from "../Identity";
|
||||
import { IssueReferencePill } from "../IssueReferencePill";
|
||||
import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils";
|
||||
@@ -100,7 +95,7 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: Compone
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await copyTextToClipboard(value);
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||
@@ -175,41 +170,6 @@ 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]);
|
||||
// Plan earns a tab as soon as an issue is in planning mode, even before the
|
||||
// plan document arrives. This keeps an expected plan surface visible and
|
||||
// lets its diagnostic empty state explain what is missing.
|
||||
// Same query keys as the tab bodies, so these share their cached fetches.
|
||||
const { data: paneTabPlanDocument } = useIssuePlanDocument(
|
||||
taskChatRedesignEnabled ? issue.id : null,
|
||||
);
|
||||
const { data: paneTabAcceptedPlans } = useQuery({
|
||||
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
|
||||
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
|
||||
enabled: taskChatRedesignEnabled,
|
||||
});
|
||||
const { data: paneTabAttachments } = useQuery({
|
||||
queryKey: queryKeys.issues.attachments(issue.id),
|
||||
queryFn: () => issuesApi.listAttachments(issue.id),
|
||||
enabled: taskChatRedesignEnabled,
|
||||
});
|
||||
const hasPlanTab =
|
||||
Boolean(paneTabPlanDocument)
|
||||
|| (paneTabAcceptedPlans?.length ?? 0) > 0
|
||||
|| issue.workMode === "planning";
|
||||
const hasArtifactsTab = (paneTabAttachments?.length ?? 0) > 0;
|
||||
const [paneTab, setPaneTab] = useState("properties");
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
/** When a run is live, a selection is staged here until the operator confirms
|
||||
@@ -2027,16 +1987,13 @@ export function IssueProperties({
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
{/* PAP-411: priority UI is hidden behind SHOW_TASK_PRIORITY_UI. Revive by flipping the flag. */}
|
||||
{SHOW_TASK_PRIORITY_UI && (
|
||||
<PropertyRow label="Priority">
|
||||
<PriorityIcon
|
||||
priority={issue.priority}
|
||||
onChange={(priority) => onUpdate({ priority })}
|
||||
showLabel
|
||||
/>
|
||||
</PropertyRow>
|
||||
)}
|
||||
<PropertyRow label="Priority">
|
||||
<PriorityIcon
|
||||
priority={issue.priority}
|
||||
onChange={(priority) => onUpdate({ priority })}
|
||||
showLabel
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
@@ -2499,83 +2456,23 @@ export function IssueProperties({
|
||||
// Flag OFF (or non-redesign hosts): today's stacked pane, byte-for-byte.
|
||||
if (!taskChatRedesignEnabled) return propertiesBody;
|
||||
|
||||
// Flag ON with nothing to switch between: no tab strip — the header bar
|
||||
// shows a plain title and the pane body is just the properties stack.
|
||||
if (!hasPlanTab && !hasArtifactsTab) {
|
||||
return (
|
||||
<>
|
||||
{paneHeaderSlot
|
||||
? createPortal(<span className="text-sm font-medium">Properties</span>, paneHeaderSlot)
|
||||
: null}
|
||||
{propertiesBody}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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. Panel hosts portal the strip into the pane header bar;
|
||||
// portals keep React context, so the Tabs root still drives it.
|
||||
// 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)
|
||||
? "properties"
|
||||
: paneTab;
|
||||
// In the pane header the strip stretches to the bar's full height and the
|
||||
// active underline drops to bottom-0, so it hugs the header's border line.
|
||||
const paneTabTriggerClass = paneHeaderSlot
|
||||
? "h-full group-data-[orientation=horizontal]/tabs:after:bottom-0"
|
||||
: undefined;
|
||||
const tabStrip = (
|
||||
<TabsList
|
||||
variant="line"
|
||||
className={
|
||||
paneHeaderSlot
|
||||
? "items-stretch justify-start gap-1 p-0 group-data-[orientation=horizontal]/tabs:h-full"
|
||||
: "w-full justify-start gap-1"
|
||||
}
|
||||
>
|
||||
<TabsTrigger value="properties" className={paneTabTriggerClass}>
|
||||
Properties
|
||||
</TabsTrigger>
|
||||
{hasPlanTab ? (
|
||||
<TabsTrigger value="plans" className={paneTabTriggerClass}>
|
||||
Plan
|
||||
</TabsTrigger>
|
||||
) : null}
|
||||
{hasArtifactsTab ? (
|
||||
<TabsTrigger value="artifacts" className={paneTabTriggerClass}>
|
||||
Artifacts
|
||||
</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
);
|
||||
// Flag ON: wrap the same body in a Properties | Plans | Artifacts tab shell.
|
||||
// Plans/Artifacts get their real content in a later step; the shell itself is
|
||||
// the deliverable here, and the Properties tab is unchanged.
|
||||
return (
|
||||
<Tabs value={activePaneTab} onValueChange={setPaneTab} className="flex min-h-0 flex-col gap-3">
|
||||
{paneHeaderSlot
|
||||
? createPortal(
|
||||
// Portals keep React context but break the DOM tree the Tailwind
|
||||
// group-selectors need: the active-tab underline is styled via
|
||||
// `group-data-[orientation=horizontal]/tabs:*`, so restore that
|
||||
// ancestor here (display: contents keeps it out of layout).
|
||||
<div className="group/tabs contents" data-orientation="horizontal">
|
||||
{tabStrip}
|
||||
</div>,
|
||||
paneHeaderSlot,
|
||||
)
|
||||
: tabStrip}
|
||||
<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">Plans</TabsTrigger>
|
||||
<TabsTrigger value="artifacts">Artifacts</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="properties">{propertiesBody}</TabsContent>
|
||||
{hasPlanTab ? (
|
||||
<TabsContent value="plans">
|
||||
<IssuePropertiesPlansTab issue={issue} inline={inline} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
{hasArtifactsTab ? (
|
||||
<TabsContent value="artifacts">
|
||||
<IssuePropertiesArtifactsTab issue={issue} />
|
||||
</TabsContent>
|
||||
) : null}
|
||||
<TabsContent value="plans">
|
||||
<IssuePropertiesPlansTab issue={issue} />
|
||||
</TabsContent>
|
||||
<TabsContent value="artifacts">
|
||||
<IssuePropertiesArtifactsTab issue={issue} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Issue, IssueThreadInteraction } from "@paperclipai/shared";
|
||||
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 { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { useLocation } from "@/lib/router";
|
||||
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;
|
||||
}
|
||||
|
||||
function hasPendingPlanConfirmation(interactions: IssueThreadInteraction[] | undefined): boolean {
|
||||
return (interactions ?? []).some(
|
||||
(interaction) =>
|
||||
interaction.kind === "request_confirmation"
|
||||
&& interaction.status === "pending"
|
||||
&& interaction.payload.target?.type === "issue_document"
|
||||
&& interaction.payload.target.key === "plan",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans tab of the redesigned properties pane (flag: enableTaskChatRedesign).
|
||||
*
|
||||
* 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).
|
||||
* 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.
|
||||
*/
|
||||
export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) {
|
||||
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
|
||||
const location = useLocation();
|
||||
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
|
||||
export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
|
||||
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
|
||||
});
|
||||
const { data: interactions } = useQuery({
|
||||
queryKey: queryKeys.issues.interactions(issue.id),
|
||||
queryFn: () => issuesApi.listInteractions(issue.id),
|
||||
});
|
||||
const hasPlans = (data?.length ?? 0) > 0;
|
||||
const pendingPlanConfirmation = hasPendingPlanConfirmation(interactions);
|
||||
|
||||
if (!planDocument && !hasPlans) {
|
||||
if (!hasPlans) {
|
||||
return (
|
||||
<>
|
||||
{/* This is deliberately outside the plan-document gate: an interaction
|
||||
can arrive before its plan document query resolves or persists. */}
|
||||
<IssuePlanConfirmationActionBar issue={issue} inline={inline} />
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
{planDocumentLoading ? (
|
||||
"Loading plan…"
|
||||
) : issue.workMode === "planning" ? (
|
||||
<div className="space-y-2">
|
||||
<p>This task is in plan mode but no plan document has been written yet.</p>
|
||||
{pendingPlanConfirmation ? (
|
||||
<p className="text-amber-foreground">
|
||||
A plan confirmation is pending, but the plan document it should confirm is missing.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
"No plan yet. The plan document, accepted plans, and their revisions will appear here."
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
No plan yet. Accepted plans and their revisions will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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. */}
|
||||
<IssuePlanConfirmationActionBar issue={issue} inline={inline} />
|
||||
{planDocument ? (
|
||||
<section data-testid="issue-plan-document" className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{`Revision ${planDocument.latestRevisionNumber ?? 1} · updated ${new Date(planDocument.updatedAt).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}`}
|
||||
<DocumentAnnotationsCountChip
|
||||
issueId={issue.id}
|
||||
docKey="plan"
|
||||
panelOpen={annotationPanelOpen}
|
||||
onToggle={() => setAnnotationPanelOpen((open) => !open)}
|
||||
/>
|
||||
</div>
|
||||
<IssueDocumentAnnotations
|
||||
issueId={issue.id}
|
||||
doc={{
|
||||
key: "plan",
|
||||
latestRevisionId: planDocument.latestRevisionId,
|
||||
latestRevisionNumber: planDocument.latestRevisionNumber,
|
||||
}}
|
||||
bodyMarkdown={planDocument.body}
|
||||
draftDirty={false}
|
||||
draftConflicted={false}
|
||||
historicalPreview={false}
|
||||
locationHash={location.hash}
|
||||
panelOpen={annotationPanelOpen}
|
||||
onPanelOpenChange={setAnnotationPanelOpen}
|
||||
panelPlacement="inline"
|
||||
>
|
||||
<MarkdownBody>{planDocument.body}</MarkdownBody>
|
||||
</IssueDocumentAnnotations>
|
||||
</section>
|
||||
) : null}
|
||||
{hasPlans ? (
|
||||
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
|
||||
) : null}
|
||||
<div className="py-2">
|
||||
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { CommentAttributionChip } from "@/components/CommentAttributionChip";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
} from "@/components/ui/attachment";
|
||||
import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments";
|
||||
import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
interface TaskChatBubbleProps {
|
||||
item: TaskChatMessageItem;
|
||||
/**
|
||||
* The settled run turn rendered on this bubble's footer line (round 9):
|
||||
* replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools"
|
||||
* (the timestamp leads the summary), expandable to the nested tool history.
|
||||
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
|
||||
*/
|
||||
attachedTurn?: ReactNode;
|
||||
/**
|
||||
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
|
||||
* Rendered here only for a runless reply (leading the bare timestamp); when
|
||||
* an attached turn is present it owns these via its `leading` slot instead,
|
||||
* so this bubble skips them. Human/system bubbles pass nothing.
|
||||
*/
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
function initialsForName(name: string) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* bubble with an author header; system notices are centered and recede.
|
||||
*/
|
||||
export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) {
|
||||
if (item.interstitial) {
|
||||
// Interstitial updates are ephemeral (PAP-361): while streaming the text
|
||||
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
|
||||
// once finished it renders nowhere — the run log / classic transcript
|
||||
// remain the archive. Never rendered as a bubble.
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
|
||||
if (item.author === "system") {
|
||||
return (
|
||||
<div className="tc-enter-bubble flex justify-center py-1">
|
||||
@@ -66,116 +20,31 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
|
||||
}
|
||||
|
||||
const isHuman = item.author === "human";
|
||||
// Non-image file references ("[name](/api/attachments/…/content)") render as
|
||||
// attachment chips under the bubble; link-only lines leave the body text.
|
||||
const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs(item.text);
|
||||
return (
|
||||
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
|
||||
{!isHuman && item.authorName ? (
|
||||
<span className="flex items-center gap-2 px-1">
|
||||
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
|
||||
{item.agentIcon ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={item.agentIcon} className="h-3.5 w-3.5" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<AvatarFallback>{initialsForName(item.authorName)}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
<span className="text-sm font-semibold text-foreground">{item.authorName}</span>
|
||||
{item.onBehalfOfUserName ? (
|
||||
<CommentAttributionChip
|
||||
agentName={item.authorName}
|
||||
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
|
||||
className={cn(
|
||||
"max-w-(--pct-85) break-words px-3.5 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",
|
||||
item.optimistic ? "opacity-80" : null,
|
||||
)}
|
||||
>
|
||||
<MarkdownBody
|
||||
// The human bubble sits on the solid --liveness-blue accent, so the
|
||||
// prose body text must follow the bubble's `text-white` rather than
|
||||
// the default light-mode prose color (which reads as black on blue).
|
||||
// `paperclip-markdown-on-accent` flips prose tokens to currentColor
|
||||
// (== inherited white) in both themes; dark mode was already correct
|
||||
// only because `prose-invert` happened to lighten the text.
|
||||
className={isHuman ? "paperclip-markdown-on-accent" : undefined}
|
||||
softBreaks
|
||||
linkIssueReferences
|
||||
>
|
||||
{bodyText}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
) : null}
|
||||
{attachmentRefs.length > 0 ? (
|
||||
<AttachmentGroup
|
||||
className="max-w-(--pct-85)"
|
||||
data-testid="task-chat-bubble-attachments"
|
||||
>
|
||||
{attachmentRefs.map((ref) => {
|
||||
const kind = fileKindForName(ref.name);
|
||||
const KindIcon = kind.icon;
|
||||
return (
|
||||
<Attachment key={ref.url} size="sm" className={cn(item.optimistic && "opacity-80")}>
|
||||
<AttachmentMedia>
|
||||
<KindIcon aria-hidden />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle className="max-w-48">{ref.name}</AttachmentTitle>
|
||||
<AttachmentDescription className="max-w-48">{kind.label}</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentTrigger
|
||||
aria-label={`Open ${ref.name}`}
|
||||
render={<a href={ref.url} target="_blank" rel="noreferrer" />}
|
||||
/>
|
||||
</Attachment>
|
||||
);
|
||||
})}
|
||||
</AttachmentGroup>
|
||||
<span className="px-1 text-xs font-medium text-muted-foreground">{item.authorName}</span>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-(--pct-85) whitespace-pre-wrap break-words px-3 py-2 text-sm",
|
||||
isHuman
|
||||
? "rounded-2xl rounded-br-sm bg-primary text-primary-foreground"
|
||||
: "rounded-2xl rounded-bl-sm border border-border bg-card text-foreground",
|
||||
item.optimistic ? "opacity-80" : null,
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
{item.streaming ? (
|
||||
<span className="tc-cursor ml-0.5 inline-block h-3.5 w-1.5 translate-y-0.5 bg-current align-baseline" />
|
||||
) : null}
|
||||
</div>
|
||||
{item.optimistic ? (
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{item.optimistic === "queued" ? "Queued" : "Sending…"}
|
||||
</span>
|
||||
) : attachedTurn ? (
|
||||
// The settled turn takes over the footer line: timestamp + "✓ Worked"
|
||||
// summary, always visible; expanding stretches beneath the bubble. The
|
||||
// copy/👍/👎 actions (PAP-413) ride the turn's summary row via its
|
||||
// `leading` slot — not this wrapper — so they stay anchored to the
|
||||
// summary line when the tool history expands beneath it.
|
||||
<div className="self-stretch" data-testid="task-chat-bubble-attached-turn">
|
||||
{attachedTurn}
|
||||
</div>
|
||||
) : actions ? (
|
||||
// Agent reply without run activity: the actions still lead the footer,
|
||||
// with the always-visible timestamp trailing (PAP-413).
|
||||
<div className="flex items-center gap-1">
|
||||
{actions}
|
||||
{item.timestamp ? (
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{item.timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : item.timestamp ? (
|
||||
// Timestamps are always visible (round 9) — no longer hover-revealed.
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{item.timestamp}
|
||||
</span>
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">{item.timestamp}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,135 +1,27 @@
|
||||
import {
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUp, Check, ChevronDown, Loader2, Plus, X } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
} from "@/components/ui/attachment";
|
||||
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 type { MentionOption } from "@/components/MarkdownEditor";
|
||||
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
|
||||
|
||||
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
|
||||
interface CommentReassignment {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
import { Check, ChevronDown, Send } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
|
||||
import type { IssueWorkMode } from "@paperclipai/shared";
|
||||
|
||||
interface TaskChatComposerProps {
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void> | void;
|
||||
onAdd: (body: string) => Promise<void> | void;
|
||||
workMode: IssueWorkMode;
|
||||
onWorkModeChange?: (mode: IssueWorkMode) => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
placeholder?: string;
|
||||
/** Preferred upload path: attaches the file to the task (mirrors legacy). */
|
||||
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
|
||||
/** Fallback upload path: returns a URL for inline image markdown. */
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
/** Mentionable entities for the editor's @-autocomplete. */
|
||||
mentions?: MentionOption[];
|
||||
enableReassign?: boolean;
|
||||
reassignOptions?: InlineEntityOption[];
|
||||
currentAssigneeValue?: string;
|
||||
issueStatus?: string;
|
||||
/** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
/** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */
|
||||
const MODE_HUE: Partial<Record<IssueWorkMode, string>> = {
|
||||
standard: "var(--tc-mode-agent)",
|
||||
planning: "var(--tc-mode-plan)",
|
||||
ask: "var(--tc-mode-ask)",
|
||||
};
|
||||
|
||||
function modeHue(mode: IssueWorkMode): string {
|
||||
return MODE_HUE[mode] ?? "var(--tc-mode-agent)";
|
||||
}
|
||||
|
||||
const MODE_DESCRIPTION: Partial<Record<IssueWorkMode, string>> = {
|
||||
standard: "Make changes and run work",
|
||||
planning: "Draft a plan before acting",
|
||||
ask: "Answer questions only, no changes",
|
||||
};
|
||||
|
||||
/** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */
|
||||
function modePlaceholder(mode: IssueWorkMode, agentName: string): string {
|
||||
switch (mode) {
|
||||
case "planning":
|
||||
return `Plan with ${agentName} — shapes the plan doc, no code changes…`;
|
||||
case "ask":
|
||||
return `Ask ${agentName} a question — read-only, nothing runs…`;
|
||||
default:
|
||||
return `Message ${agentName} — describe what you want done…`;
|
||||
}
|
||||
}
|
||||
|
||||
type ComposerAttachment = {
|
||||
id: string;
|
||||
name: string;
|
||||
size?: number;
|
||||
status: "uploading" | "attached" | "error";
|
||||
error?: string;
|
||||
/** Set once uploaded; the submit path appends `[name](contentPath)` lines. */
|
||||
contentPath?: string;
|
||||
};
|
||||
|
||||
/** Local duplicate of IssueChatThread's module-private helper (same rule). */
|
||||
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
|
||||
const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked";
|
||||
return resumesToTodo && assigneeValue.startsWith("agent:");
|
||||
}
|
||||
|
||||
function parseAssigneeValue(value: string): CommentReassignment | undefined {
|
||||
if (value.startsWith("agent:")) {
|
||||
const id = value.slice("agent:".length);
|
||||
return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined;
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
const id = value.slice("user:".length);
|
||||
return id ? { assigneeAgentId: null, assigneeUserId: id } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function escapeMarkdownLabel(name: string): string {
|
||||
return name.replace(/[[\]]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer for the redesigned thread (v7 spec): the shared MarkdownEditor
|
||||
* (rich lists, @-mentions, /-commands, inline pasted images) over a 32px
|
||||
* comp-bar of [attach] [mode chip] … [assignee] [send]. The mode chip is a
|
||||
* status-chip rectangle carrying the pending mode's hue; the composer chrome
|
||||
* itself stays neutral. Shift+Tab cycles modes (captured before Lexical);
|
||||
* Cmd/Ctrl+Enter posts via the editor's native onSubmit; plain Enter stays a
|
||||
* newline / next list item. Pasted or dropped images upload through
|
||||
* `onAttachImage` (or the `onImageUpload` fallback) and land inline at the
|
||||
* caret via the editor's image plugin; non-image files render as shadcn
|
||||
* base/attachment chips (kind icon · name · size, remove ×, uploading/error
|
||||
* states) between the editor and the comp-bar.
|
||||
* Lightweight composer for the redesigned thread. Covers the baseline
|
||||
* essentials: optimistic send (delegates to the existing onAdd mutation, which
|
||||
* already echoes locally) and the three modes — Agent / Plan / Ask — reusing
|
||||
* the app's work-mode metadata. The selected mode is applied on submit, mirroring
|
||||
* the existing composer's semantics. Advanced affordances (attachments, reassign,
|
||||
* mentions, drafts) are deferred; they're demonstrated on the legacy composer and
|
||||
* layered on post-baseline.
|
||||
*/
|
||||
export function TaskChatComposer({
|
||||
onAdd,
|
||||
@@ -137,176 +29,27 @@ export function TaskChatComposer({
|
||||
onWorkModeChange,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
placeholder,
|
||||
onAttachImage,
|
||||
onImageUpload,
|
||||
mentions,
|
||||
enableReassign = false,
|
||||
reassignOptions,
|
||||
currentAssigneeValue = "",
|
||||
issueStatus,
|
||||
mobile = false,
|
||||
placeholder = "Reply",
|
||||
}: TaskChatComposerProps) {
|
||||
const [body, setBody] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
|
||||
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
|
||||
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const editorRef = useRef<MarkdownEditorRef>(null);
|
||||
const bodyRef = useRef(body);
|
||||
bodyRef.current = body;
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const modeMeta = workModeMetaFor(pendingMode);
|
||||
const canAcceptFiles = Boolean(onAttachImage || onImageUpload);
|
||||
const showAssignee = Boolean(enableReassign && reassignOptions && reassignOptions.length > 0);
|
||||
const assigneeValue = pendingAssignee ?? currentAssigneeValue;
|
||||
const assigneeLabel =
|
||||
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
|
||||
const assigneeName = assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel;
|
||||
const effectivePlaceholder = placeholder ?? modePlaceholder(pendingMode, assigneeName);
|
||||
|
||||
/** Upload an image and return its URL for inline `` markdown. */
|
||||
async function uploadInlineImage(file: File): Promise<string> {
|
||||
if (onAttachImage) {
|
||||
const attachment = await onAttachImage(file);
|
||||
if (attachment?.contentPath) return attachment.contentPath;
|
||||
throw new Error("Upload did not return a file URL");
|
||||
}
|
||||
if (onImageUpload) return onImageUpload(file);
|
||||
throw new Error("This file type cannot be attached here");
|
||||
}
|
||||
|
||||
/** Non-image files: attach to the task and track in the chip row. */
|
||||
async function attachNonImageFile(file: File) {
|
||||
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
|
||||
setAttachments((prev) => [...prev, { id, name: file.name, size: file.size, status: "uploading" }]);
|
||||
try {
|
||||
if (!onAttachImage) {
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "error", error: "This file type cannot be attached here" }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const attachment = await onAttachImage(file);
|
||||
const name = attachment?.originalFilename ?? file.name;
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, name, status: "attached", contentPath: attachment?.contentPath }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed" }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Images picked from the + button go inline at the caret, like paste/drop. */
|
||||
async function attachPickedFile(file: File) {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
await attachNonImageFile(file);
|
||||
return;
|
||||
}
|
||||
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
|
||||
try {
|
||||
const url = await uploadInlineImage(file);
|
||||
editorRef.current?.insertMarkdown(``);
|
||||
} catch (err) {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Upload failed",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const files = evt.target.files;
|
||||
if (files && files.length > 0) {
|
||||
void (async () => {
|
||||
for (const file of Array.from(files)) await attachPickedFile(file);
|
||||
})();
|
||||
}
|
||||
evt.target.value = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pasted image files fall through to the editor's image plugin (inline at
|
||||
* the caret); non-image files are attached to the task here. Only swallow
|
||||
* the paste when it carries no images the plugin should handle.
|
||||
*/
|
||||
function handlePasteCapture(evt: ReactClipboardEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles) return;
|
||||
const files = Array.from(evt.clipboardData?.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
const nonImages = files.filter((file) => !file.type.startsWith("image/"));
|
||||
if (nonImages.length === 0) return;
|
||||
if (nonImages.length === files.length) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
void (async () => {
|
||||
for (const file of nonImages) await attachNonImageFile(file);
|
||||
})();
|
||||
}
|
||||
|
||||
// Uploaded file references ride along as trailing `[name](contentPath)`
|
||||
// lines — the bubble renderer folds those link-only lines back into chips.
|
||||
const attachedRefs = attachments.filter(
|
||||
(item) => item.status === "attached" && item.contentPath,
|
||||
);
|
||||
// Sending mid-upload would silently drop the pending file from the comment;
|
||||
// sending past a failed chip would discard the file the user selected and
|
||||
// clear its error state, so both hold submission until resolved or removed.
|
||||
const uploadPending = attachments.some((item) => item.status === "uploading");
|
||||
const uploadFailed = attachments.some((item) => item.status === "error");
|
||||
const ModeIcon = modeMeta.icon;
|
||||
|
||||
async function submit() {
|
||||
const trimmed = bodyRef.current.trim();
|
||||
if (
|
||||
(!trimmed && attachedRefs.length === 0) ||
|
||||
uploadPending ||
|
||||
uploadFailed ||
|
||||
submitting ||
|
||||
disabled
|
||||
)
|
||||
return;
|
||||
|
||||
const refLines = attachedRefs
|
||||
.map((item) => `[${escapeMarkdownLabel(item.name)}](${item.contentPath})`)
|
||||
.join("\n");
|
||||
const fullBody = [trimmed, refLines].filter(Boolean).join("\n\n");
|
||||
const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined;
|
||||
const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined;
|
||||
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || submitting || disabled) return;
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
if (pendingMode !== workMode && onWorkModeChange) {
|
||||
await onWorkModeChange(pendingMode);
|
||||
}
|
||||
await onAdd(fullBody, reopen, reassignment);
|
||||
setAttachments([]);
|
||||
setPendingAssignee(null);
|
||||
await onAdd(trimmed);
|
||||
} catch {
|
||||
setBody(trimmed); // restore on failure (chips stay for retry)
|
||||
setBody(trimmed); // restore on failure
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -315,207 +58,75 @@ 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 bg-card p-2 shadow-(--shadow-extract-7)",
|
||||
modeMeta.classes.container || "border-border",
|
||||
)}
|
||||
onKeyDownCapture={(e) => {
|
||||
// Shift+Tab cycles the pending mode; captured on the wrapper so it
|
||||
// wins over Lexical's list-outdent binding inside the editor.
|
||||
if (disabled) return;
|
||||
if (e.key === "Tab" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPendingMode((mode) => nextWorkMode(mode));
|
||||
}
|
||||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
<div data-testid="task-chat-composer-input">
|
||||
<MarkdownEditor
|
||||
ref={editorRef}
|
||||
value={body}
|
||||
onChange={setBody}
|
||||
placeholder={disabled ? (disabledReason ?? "Composer disabled") : effectivePlaceholder}
|
||||
readOnly={disabled}
|
||||
mentions={mentions}
|
||||
onSubmit={() => void submit()}
|
||||
imageUploadHandler={canAcceptFiles ? uploadInlineImage : undefined}
|
||||
onDropFile={canAcceptFiles ? attachNonImageFile : undefined}
|
||||
bordered={false}
|
||||
className={cn(disabled && "opacity-60")}
|
||||
contentClassName={
|
||||
mobile
|
||||
? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide"
|
||||
: "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide"
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<AttachmentGroup className="mb-1 px-1" data-testid="task-chat-composer-attachments">
|
||||
{attachments.map((attachment) => {
|
||||
const kind = fileKindForName(attachment.name);
|
||||
const KindIcon = kind.icon;
|
||||
const sizeLabel = formatFileSize(attachment.size);
|
||||
return (
|
||||
<Attachment
|
||||
key={attachment.id}
|
||||
size="sm"
|
||||
state={
|
||||
attachment.status === "uploading"
|
||||
? "uploading"
|
||||
: attachment.status === "error"
|
||||
? "error"
|
||||
: "done"
|
||||
}
|
||||
>
|
||||
<AttachmentMedia>
|
||||
{attachment.status === "uploading" ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<KindIcon aria-hidden />
|
||||
)}
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle className="max-w-48">{attachment.name}</AttachmentTitle>
|
||||
<AttachmentDescription className="max-w-48">
|
||||
{attachment.status === "uploading"
|
||||
? "Uploading…"
|
||||
: attachment.status === "error"
|
||||
? (attachment.error ?? "Upload failed")
|
||||
: [kind.label, sizeLabel].filter(Boolean).join(" · ")}
|
||||
</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
onClick={() =>
|
||||
setAttachments((prev) => prev.filter((item) => item.id !== attachment.id))
|
||||
}
|
||||
>
|
||||
<X aria-hidden />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
);
|
||||
})}
|
||||
</AttachmentGroup>
|
||||
) : null}
|
||||
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? (disabledReason ?? "Composer disabled") : placeholder}
|
||||
rows={2}
|
||||
className="w-full resize-none bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-60"
|
||||
data-testid="task-chat-composer-input"
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{canAcceptFiles ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
title="Attach file"
|
||||
aria-label="Attach file"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
data-testid="task-chat-composer-attach"
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
|
||||
modeMeta.classes.chip,
|
||||
)}
|
||||
disabled={!onWorkModeChange}
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
<ModeIcon className="h-3.5 w-3.5" />
|
||||
{modeMeta.shortLabel}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || !onWorkModeChange}
|
||||
className="status-chip flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{ "--sc": modeHue(pendingMode) } as CSSProperties}
|
||||
data-testid="task-chat-composer-mode"
|
||||
data-pending-work-mode={pendingMode}
|
||||
>
|
||||
{modeMeta.label}
|
||||
<ChevronDown className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-44 p-1">
|
||||
{workModeMetaList().map((m) => {
|
||||
const Icon = m.icon;
|
||||
const selected = m.value === pendingMode;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<button
|
||||
key={m.value}
|
||||
onSelect={() => setPendingMode(m.value)}
|
||||
style={
|
||||
selected
|
||||
? { backgroundColor: `color-mix(in srgb, ${modeHue(m.value)} 12%, transparent)` }
|
||||
: undefined
|
||||
}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPendingMode(m.value);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent",
|
||||
m.classes.menuItem,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" style={{ color: modeHue(m.value) }} aria-hidden />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="font-medium">{m.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{MODE_DESCRIPTION[m.value] ?? ""}</span>
|
||||
</span>
|
||||
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
|
||||
</DropdownMenuItem>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="flex-1">{m.label}</span>
|
||||
{m.value === pendingMode ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={
|
||||
disabled ||
|
||||
submitting ||
|
||||
uploadPending ||
|
||||
uploadFailed ||
|
||||
(body.trim().length === 0 && attachedRefs.length === 0)
|
||||
}
|
||||
title={
|
||||
uploadPending
|
||||
? "Waiting for upload to finish"
|
||||
: uploadFailed
|
||||
? "Remove the failed attachment to send"
|
||||
: "Send (⌘+Enter)"
|
||||
}
|
||||
aria-label="Send"
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-transform hover:scale-105 disabled:scale-100 disabled:bg-muted disabled:text-muted-foreground"
|
||||
disabled={disabled || submitting || body.trim().length === 0}
|
||||
className="ml-auto flex items-center gap-1 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
data-testid="task-chat-composer-send"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<ArrowUp className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
{submitting ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight, Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
|
||||
import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
|
||||
import type { TaskChatStatusItem } from "./task-chat-model";
|
||||
import { statusLabelIcon, toolTaxonomy } from "./tool-taxonomy";
|
||||
import { isGenericStatusLabel, whimsyWord } from "./status-whimsy";
|
||||
import { parseCssTimeMs } from "./motion-tokens";
|
||||
|
||||
function elapsedLabel(ms?: number): string | null {
|
||||
if (ms == null) return null;
|
||||
@@ -13,242 +9,6 @@ function elapsedLabel(ms?: number): string | null {
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
/** Tenths-precision elapsed ("24.3s", "1m 24.3s") so the readout visibly moves. */
|
||||
function liveElapsedLabel(ms?: number): string | null {
|
||||
if (ms == null) return null;
|
||||
const s = Math.max(0, ms) / 1000;
|
||||
if (s < 60) return `${s.toFixed(1)}s`;
|
||||
return `${Math.floor(s / 60)}m ${(s % 60).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/** Elapsed ms since `startedAtMs`, ticking ten times a second while `live`. */
|
||||
function useLiveElapsedMs(startedAtMs: number | undefined, live: boolean): number | undefined {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (startedAtMs == null || !live) return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 100);
|
||||
return () => window.clearInterval(id);
|
||||
}, [startedAtMs, live]);
|
||||
return startedAtMs == null ? undefined : Math.max(0, now - startedAtMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many line-heights the streaming interstitial block is shifted up so only
|
||||
* its LAST line shows in the 1lh viewport: lines − 1 (never negative).
|
||||
*/
|
||||
export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): number {
|
||||
if (!Number.isFinite(scrollHeight) || !Number.isFinite(lineHeightPx) || lineHeightPx <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.round(scrollHeight / lineHeightPx) - 1);
|
||||
}
|
||||
|
||||
/** Read a `--motion-*` time token off :root in ms (0 when unset, e.g. jsdom). */
|
||||
function motionTokenMs(name: string): number {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
const ms = parseCssTimeMs(raw);
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The interstitial update inside a one-line-height clipped viewport.
|
||||
*
|
||||
* The `.tc-line-scroll` slide plays ONLY at swap moments (PAP-368): on mount —
|
||||
* i.e. when a new update replaces the previous one, the parent keys this
|
||||
* component by swap — the text slides up from below the 1lh clip, and once
|
||||
* that enter window (--motion-line-scroll) elapses the transition is disabled
|
||||
* inline. While `streaming`, the block is shifted up by whole line-heights so
|
||||
* only the tail line shows, but with the transition off the shift SNAPS — no
|
||||
* per-wrap glide keeping the row in constant motion. Once the update finishes
|
||||
* (`streaming` false) the held text renders single-line ellipsized, static.
|
||||
* When `leaving` (held past the dwell with no successor, PAP-376), the
|
||||
* transition re-enables and the line continues up out of the clip. Line count
|
||||
* is measured from scrollHeight / line-height, re-checked on resize.
|
||||
*/
|
||||
function LiveSelfTalkLine({
|
||||
text,
|
||||
streaming,
|
||||
leaving,
|
||||
}: {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
leaving?: boolean;
|
||||
}) {
|
||||
const innerRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [entered, setEntered] = useState(false);
|
||||
const [settled, setSettled] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
const el = innerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight);
|
||||
setOffset(lineScrollOffset(el.scrollHeight, lineHeight));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [text, streaming]);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setEntered(true));
|
||||
const timer = window.setTimeout(() => setSettled(true), motionTokenMs("--motion-line-scroll"));
|
||||
return () => {
|
||||
cancelAnimationFrame(id);
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
const shift = !entered ? 1 : leaving ? -1 : streaming ? -offset : 0;
|
||||
return (
|
||||
<span
|
||||
className="tc-line-scroll min-w-0 flex-1"
|
||||
data-testid="task-chat-live-self-talk"
|
||||
data-streaming={streaming || undefined}
|
||||
data-leaving={leaving || undefined}
|
||||
>
|
||||
<span
|
||||
ref={innerRef}
|
||||
className={cn("tc-line-scroll-inner block", !streaming && "truncate")}
|
||||
style={{
|
||||
transform: shift !== 0 ? `translateY(calc(${shift} * 1lh))` : undefined,
|
||||
transition: settled && !leaving ? "none" : undefined,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface HeldSelfTalk {
|
||||
text: string;
|
||||
/** The displayed update is the currently-streaming tail message. */
|
||||
streaming: boolean;
|
||||
/** Increments at each swap — keys LiveSelfTalkLine so the slide replays. */
|
||||
swapKey: number;
|
||||
/** Sliding out after sitting held for the full dwell (PAP-376). */
|
||||
leaving?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Held interstitial state (PAP-368, amended by PAP-376): a finished update
|
||||
* stays visible, static, for --motion-interstitial-dwell (~4s) — then, if
|
||||
* nothing superseded it, slides away rather than sitting until the next
|
||||
* update happens to push it out. The same dwell paces swaps (~4s minimum
|
||||
* between them) so visibility tracks reading time, not streaming speed.
|
||||
* Updates that arrive inside the dwell window collapse latest-wins (a queue
|
||||
* of one: only the newest pending update swaps in when the dwell expires).
|
||||
*
|
||||
* Message boundaries are the `selfTalk` gaps: the transcript tail clears it
|
||||
* between assistant messages (tool calls / thinking in between), so a
|
||||
* defined→undefined→defined sequence marks a new update.
|
||||
*/
|
||||
function useHeldSelfTalk(selfTalk: string | undefined, live: boolean): HeldSelfTalk | null {
|
||||
const [line, setLine] = useState<HeldSelfTalk | null>(null);
|
||||
const lineRef = useRef<HeldSelfTalk | null>(line);
|
||||
const pendingRef = useRef<string | null>(null);
|
||||
const tailRef = useRef<string | undefined>(selfTalk);
|
||||
const lastSwapAtRef = useRef(0);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const leaveTimerRef = useRef<number | null>(null);
|
||||
const exitTimerRef = useRef<number | null>(null);
|
||||
tailRef.current = selfTalk;
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (ref: { current: number | null }) => {
|
||||
if (ref.current != null) {
|
||||
window.clearTimeout(ref.current);
|
||||
ref.current = null;
|
||||
}
|
||||
};
|
||||
const show = (next: HeldSelfTalk | null) => {
|
||||
lineRef.current = next;
|
||||
setLine(next);
|
||||
};
|
||||
// Held past the dwell with nothing queued: slide the line out of the 1lh
|
||||
// viewport (--motion-line-scroll) and unmount it back to the empty slot.
|
||||
const scheduleLeave = () => {
|
||||
clear(leaveTimerRef);
|
||||
clear(exitTimerRef);
|
||||
leaveTimerRef.current = window.setTimeout(() => {
|
||||
leaveTimerRef.current = null;
|
||||
// A queued update's swap timer fires no later than this — let the
|
||||
// swap replace the line instead of playing the exit first.
|
||||
if (pendingRef.current != null) return;
|
||||
const cur = lineRef.current;
|
||||
if (cur == null || cur.streaming) return;
|
||||
show({ ...cur, leaving: true });
|
||||
exitTimerRef.current = window.setTimeout(() => {
|
||||
exitTimerRef.current = null;
|
||||
if (lineRef.current?.leaving) show(null);
|
||||
}, motionTokenMs("--motion-line-scroll"));
|
||||
}, motionTokenMs("--motion-interstitial-dwell"));
|
||||
};
|
||||
const swapIn = (text: string, streaming: boolean) => {
|
||||
clear(leaveTimerRef);
|
||||
clear(exitTimerRef);
|
||||
lastSwapAtRef.current = Date.now();
|
||||
show({ text, streaming, swapKey: (lineRef.current?.swapKey ?? 0) + 1 });
|
||||
// Swapped in with its stream already over: its idle clock starts now.
|
||||
if (!streaming) scheduleLeave();
|
||||
};
|
||||
if (!live) {
|
||||
// Turn end clears the row (the reserved slot itself unmounts with it).
|
||||
clear(timerRef);
|
||||
clear(leaveTimerRef);
|
||||
clear(exitTimerRef);
|
||||
pendingRef.current = null;
|
||||
if (lineRef.current) show(null);
|
||||
return;
|
||||
}
|
||||
const cur = lineRef.current;
|
||||
if (selfTalk == null) {
|
||||
// Stream ended: the update becomes held and its idle clock starts.
|
||||
if (cur?.streaming) {
|
||||
show({ ...cur, streaming: false });
|
||||
scheduleLeave();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (cur == null) {
|
||||
swapIn(selfTalk, true);
|
||||
return;
|
||||
}
|
||||
if (cur.streaming) {
|
||||
// Same message growing — update in place, no swap.
|
||||
if (cur.text !== selfTalk) show({ ...cur, text: selfTalk });
|
||||
return;
|
||||
}
|
||||
// A new update while one is held (or leaving): queue it latest-wins and
|
||||
// swap when the dwell since the last swap has elapsed.
|
||||
pendingRef.current = selfTalk;
|
||||
if (timerRef.current == null) {
|
||||
const dwellMs = motionTokenMs("--motion-interstitial-dwell");
|
||||
const delay = Math.max(0, lastSwapAtRef.current + dwellMs - Date.now());
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
const text = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
// Still streaming iff the tail message is live at swap time; if so the
|
||||
// next effect pass keeps the text tracking the tail.
|
||||
if (text != null) swapIn(text, tailRef.current != null);
|
||||
}, delay);
|
||||
}
|
||||
}, [selfTalk, live]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current != null) window.clearTimeout(timerRef.current);
|
||||
if (leaveTimerRef.current != null) window.clearTimeout(leaveTimerRef.current);
|
||||
if (exitTimerRef.current != null) window.clearTimeout(exitTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
@@ -261,145 +21,18 @@ const CONFIG = {
|
||||
interface TaskChatStatusPillProps {
|
||||
item: TaskChatStatusItem;
|
||||
onApprovalDecision?: (optionId: string) => void;
|
||||
/**
|
||||
* When set, the live line is the header of an expandable parent row
|
||||
* (TaskChatTurn): render a trailing chevron reflecting the open state —
|
||||
* the same expand grammar as tool rows and the settled "Worked ·" line.
|
||||
*/
|
||||
chevronOpen?: boolean;
|
||||
/**
|
||||
* Expand toggle for the parent row. The button (hover + click target) wraps
|
||||
* ONLY the gerund status line — the interstitial row above it stays outside
|
||||
* (PAP-376): its text vanishes on expand, so hovering it as part of the
|
||||
* header read as covering two unrelated lines.
|
||||
*/
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle state as a status affordance (never a bubble), with a defined
|
||||
* enter transition. Live running/working states render the v7 flat statusline
|
||||
* (pulse dot + shimmering label + mono meta); Tier-B attention states keep
|
||||
* card chrome — awaiting-approval elevates with an attention pulse and
|
||||
* enter transition. Awaiting-approval elevates with an attention pulse and
|
||||
* exposes the ACP permission options as actions.
|
||||
*/
|
||||
export function TaskChatStatusPill({
|
||||
item,
|
||||
onApprovalDecision,
|
||||
chevronOpen,
|
||||
onToggle,
|
||||
}: TaskChatStatusPillProps) {
|
||||
export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusPillProps) {
|
||||
const { Icon, spin, tone } = CONFIG[item.status];
|
||||
const awaiting = item.status === "awaiting_approval";
|
||||
const live = item.status === "running" || item.status === "working";
|
||||
const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live);
|
||||
const heldSelfTalk = useHeldSelfTalk(live ? item.selfTalk : undefined, live);
|
||||
const elapsed = elapsedLabel(item.elapsedMs);
|
||||
|
||||
if (live) {
|
||||
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
|
||||
// While an interstitial streams, its text lives on its OWN row above; the
|
||||
// status line below keeps the uninterrupted gerund rotation ("Responding"
|
||||
// never takes the line, and no Responding icon appears here).
|
||||
const streamingInterstitial = item.selfTalk != null;
|
||||
const ToolIcon = item.toolName
|
||||
? toolTaxonomy(item.toolName).icon
|
||||
: streamingInterstitial
|
||||
? null
|
||||
: statusLabelIcon(item.label);
|
||||
// Whimsy fills gaps, never replaces signal: only the generic
|
||||
// "Running"/"Working" labels — and the interstitial-streaming state, whose
|
||||
// text renders on the row above — swap for a deterministic whimsical
|
||||
// gerund (seeded by the run-scoped item id, rotating ~10s via the live
|
||||
// tick).
|
||||
const label = streamingInterstitial || isGenericStatusLabel(item.label)
|
||||
? whimsyWord(item.id, liveElapsedMs ?? item.elapsedMs ?? 0)
|
||||
: item.label;
|
||||
const statusLine = (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
{/* Fixed-size lead slot keeps the label from moving as tool icons
|
||||
come and go; the pulse dot renders unconditionally. */}
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
</span>
|
||||
{ToolIcon ? <ToolIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
{/* Text cells share a baseline row so the smaller mono readouts don't
|
||||
float above the label's baseline. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="shimmer-text shimmer-text-muted shrink-0 font-medium">
|
||||
{label}…
|
||||
</span>
|
||||
{liveElapsed ? (
|
||||
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
|
||||
{liveElapsed}
|
||||
</span>
|
||||
) : null}
|
||||
{item.detail ? (
|
||||
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
|
||||
) : null}
|
||||
{item.tokens ? (
|
||||
<span className="ml-auto shrink-0 font-mono text-(length:--text-micro)">
|
||||
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{chevronOpen !== undefined ? (
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", chevronOpen ? "rotate-90" : null)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
// The interstitial row is PERMANENTLY RESERVED while the turn is live
|
||||
// (round 9): the layout above never jumps to make room. A streaming
|
||||
// interstitial gets this bubble-less, icon-less single-line row DIRECTLY
|
||||
// ABOVE the status line — the two never share a line. The latest update is
|
||||
// held after its stream ends (PAP-368) and slides away once it has sat
|
||||
// for the full --motion-interstitial-dwell without a successor (PAP-376);
|
||||
// the .tc-line-scroll slide otherwise plays only when a new update swaps
|
||||
// in (keyed remount). Before the first update the row is an empty one-line
|
||||
// slot of identical height; the empty lead slot keeps the text
|
||||
// column-aligned with the status label. The expand button wraps ONLY the
|
||||
// status line, so the interstitial row never picks up its hover.
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="flex items-center gap-2 py-0.5 text-xs text-muted-foreground"
|
||||
data-testid="task-chat-interstitial-row"
|
||||
>
|
||||
<span aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
{heldSelfTalk ? (
|
||||
<LiveSelfTalkLine
|
||||
key={heldSelfTalk.swapKey}
|
||||
text={heldSelfTalk.text}
|
||||
streaming={heldSelfTalk.streaming}
|
||||
leaving={heldSelfTalk.leaving}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="tc-line-scroll min-w-0 flex-1" />
|
||||
)}
|
||||
</div>
|
||||
{onToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={chevronOpen}
|
||||
className="-mx-1.5 block w-full rounded-sm px-1.5 text-left transition-colors hover:bg-muted/60"
|
||||
data-testid="task-chat-live-turn"
|
||||
>
|
||||
{statusLine}
|
||||
</button>
|
||||
) : (
|
||||
statusLine
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Brain, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import type { TaskChatThinkingItem } from "./task-chat-model";
|
||||
|
||||
/**
|
||||
* Chain-of-thought (ACP agent_thought_chunk). While streaming it renders the
|
||||
* live lines with a staggered enter; when the item reports `collapsed` it folds
|
||||
* to a "Worked for N" header that can be re-expanded. Recessed styling keeps
|
||||
* thinking visually subordinate to agent output.
|
||||
*/
|
||||
export function TaskChatThinkingBlock({ item }: { item: TaskChatThinkingItem }) {
|
||||
const [open, setOpen] = useState(!item.collapsed);
|
||||
const collapsedByDefault = item.collapsed === true;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground"
|
||||
>
|
||||
<Brain className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">
|
||||
{collapsedByDefault ? (item.summaryLabel ?? "Thought process") : item.streaming ? "Thinking…" : "Thought process"}
|
||||
</span>
|
||||
{open ? <ChevronDown className="ml-auto h-3.5 w-3.5" /> : <ChevronRight className="ml-auto h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="space-y-1 px-3 pb-2">
|
||||
{item.lines.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className={cn("tc-enter-cot-line text-xs italic text-muted-foreground")}
|
||||
style={{ animationDelay: `calc(var(--motion-cot-line-stagger) * ${i})` }}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{item.streaming ? (
|
||||
<span className="tc-cursor inline-block h-3 w-1.5 bg-muted-foreground align-baseline" />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
TaskChatInteractionItem,
|
||||
TaskChatItem,
|
||||
TaskChatMessageItem,
|
||||
} from "./task-chat-model";
|
||||
import { TaskChatTurn } from "./TaskChatTurn";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
import { TaskChatBubble } from "./TaskChatBubble";
|
||||
import { TaskChatMarker } from "./TaskChatMarker";
|
||||
import { TaskChatStatusPill } from "./TaskChatStatusPill";
|
||||
import { TaskChatToolCard } from "./TaskChatToolCard";
|
||||
import { TaskChatThinkingBlock } from "./TaskChatThinkingBlock";
|
||||
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
|
||||
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
|
||||
* TaskChatInteractionCard bound to its accept/reject handlers). Interaction
|
||||
* items render nothing without it — the harness has no control plane.
|
||||
*/
|
||||
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode;
|
||||
/**
|
||||
* Renders the description-as-first-bubble placeholder (PAP-375). The live
|
||||
* thread binds TaskChatDescriptionBubble to the issue; brief items render
|
||||
* nothing without it.
|
||||
*/
|
||||
renderBrief?: () => ReactNode;
|
||||
/**
|
||||
* Renders the copy/👍/👎 action cluster prepended to an agent bubble's footer
|
||||
* line (PAP-413). The live thread binds it to the feedback-vote API; harness
|
||||
* fixtures omit it and the bubbles render actionless.
|
||||
*/
|
||||
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
|
||||
className?: string;
|
||||
/** When false, render the list without the scroll container (e.g. previews). */
|
||||
scroll?: boolean;
|
||||
@@ -48,43 +19,14 @@ interface TaskChatThreadViewProps {
|
||||
function renderItem(
|
||||
item: TaskChatItem,
|
||||
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
|
||||
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
|
||||
renderBrief?: () => ReactNode,
|
||||
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode,
|
||||
) {
|
||||
switch (item.kind) {
|
||||
case "message": {
|
||||
// Compute the actions once: the bubble renders them for a runless reply
|
||||
// (footer = actions + timestamp), while an attached turn hands them to
|
||||
// TaskChatTurn's `leading` slot so they ride the summary line and stay
|
||||
// put when the tool history expands (PAP-413). The two paths are mutually
|
||||
// exclusive at runtime, so only one host ever mounts the node.
|
||||
const actions = renderMessageActions?.(item);
|
||||
return (
|
||||
<TaskChatBubble
|
||||
item={item}
|
||||
actions={actions}
|
||||
attachedTurn={
|
||||
item.attachedTurn ? (
|
||||
<TaskChatTurn
|
||||
item={item.attachedTurn}
|
||||
timestampPrefix={item.timestamp}
|
||||
leading={actions}
|
||||
renderChild={(child) => renderItem(child, onApprovalDecision)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "message":
|
||||
return <TaskChatBubble item={item} />;
|
||||
case "marker":
|
||||
return <TaskChatMarker item={item} />;
|
||||
case "thinking":
|
||||
// Thinking never renders as a row (PAP-361): its live signal is the
|
||||
// pill's "Thinking…" state, and the text stays in the run log / classic
|
||||
// transcript. The kind survives in the model because the transcript
|
||||
// parser still emits it (both nesting rules filter it out).
|
||||
return null;
|
||||
return <TaskChatThinkingBlock item={item} />;
|
||||
case "tool":
|
||||
return <TaskChatToolCard item={item} />;
|
||||
case "status":
|
||||
@@ -96,17 +38,6 @@ function renderItem(
|
||||
);
|
||||
case "usage":
|
||||
return <TaskChatUsageReadout item={item} />;
|
||||
case "interaction":
|
||||
return renderInteraction ? renderInteraction(item) : null;
|
||||
case "brief":
|
||||
return renderBrief ? renderBrief() : null;
|
||||
case "turn":
|
||||
return (
|
||||
<TaskChatTurn
|
||||
item={item}
|
||||
renderChild={(child) => renderItem(child, onApprovalDecision)}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
// Exhaustiveness guard: a new item kind must add a branch above.
|
||||
const _never: never = item;
|
||||
@@ -123,56 +54,28 @@ function renderItem(
|
||||
*/
|
||||
export function TaskChatThreadView({
|
||||
items,
|
||||
header,
|
||||
onApprovalDecision,
|
||||
renderInteraction,
|
||||
renderBrief,
|
||||
renderMessageActions,
|
||||
className,
|
||||
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)}>
|
||||
{header ? (
|
||||
<div className="flex flex-col gap-6 pb-2" data-testid="task-chat-thread-header">
|
||||
{header}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn("flex flex-col gap-3 px-3 py-4", className)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)}
|
||||
</div>
|
||||
<div key={item.id}>{renderItem(item, onApprovalDecision)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!scroll) return body;
|
||||
|
||||
return <TaskMessageScroller contentKey={taskChatContentKey(items)}>{body}</TaskMessageScroller>;
|
||||
}
|
||||
// Include a cheap content signature so streaming growth (text lengthening
|
||||
// without the item count changing) still advances the auto-follow key.
|
||||
const contentKey = items.reduce((acc, it) => {
|
||||
if (it.kind === "message") return acc + it.text.length;
|
||||
if (it.kind === "thinking") return acc + it.lines.reduce((n, l) => n + l.length, 0);
|
||||
if (it.kind === "tool") return acc + (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
|
||||
return acc + 1;
|
||||
}, items.length);
|
||||
|
||||
// Cheap content signature so streaming growth (text lengthening without the
|
||||
// item count changing) still advances the auto-follow key. Shared by the
|
||||
// desktop scroller above and the mobile window-scroll follow (TaskChatThread).
|
||||
function signatureOf(it: TaskChatItem): number {
|
||||
if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0);
|
||||
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
|
||||
if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
|
||||
if (it.kind === "turn") {
|
||||
if (it.settled) return 1;
|
||||
// The live parent row's header changes (gerund ↔ tool-state flashes,
|
||||
// streaming interstitial text growing) count too, so the collapsed
|
||||
// single-line turn still advances the key.
|
||||
const headerSig = it.liveStatus
|
||||
? it.liveStatus.label.length +
|
||||
(it.liveStatus.detail?.length ?? 0) +
|
||||
(it.liveStatus.selfTalk?.length ?? 0)
|
||||
: 0;
|
||||
return it.items.reduce((n, child) => n + signatureOf(child), it.items.length + headerSig);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function taskChatContentKey(items: TaskChatItem[]): number {
|
||||
return items.reduce((acc, it) => acc + signatureOf(it), items.length);
|
||||
return <TaskMessageScroller contentKey={contentKey}>{body}</TaskMessageScroller>;
|
||||
}
|
||||
@@ -1,90 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Check, Loader2, X, Wrench, ShieldCheck, ShieldX } from "lucide-react";
|
||||
import type { TaskChatToolItem } from "./task-chat-model";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
const STATUS_ICON = {
|
||||
pending: { Icon: Loader2, spin: false, tone: "text-muted-foreground" },
|
||||
in_progress: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
completed: { Icon: Check, spin: false, tone: "text-muted-foreground" },
|
||||
completed: { Icon: Check, spin: false, tone: "text-(--status-task-icon-done)" },
|
||||
failed: { Icon: X, spin: false, tone: "text-destructive" },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Tool invocation as a flat activity row (v7): [glyph] [name] [mono target] …
|
||||
* [hover chevron] [status]. No card chrome — activity is metadata, not
|
||||
* content. Clicking toggles the mono result/detail block (left-rail inset);
|
||||
* diffs render as an inset panel below the row.
|
||||
* Tool invocation card. Renders the tool name + live status glyph, an optional
|
||||
* resolved permission-decision badge, and an inline diff when present.
|
||||
*/
|
||||
export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
|
||||
const { Icon, spin, tone } = STATUS_ICON[item.status];
|
||||
const RowIcon = toolTaxonomy(item.rawName ?? item.name).icon;
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const expandable = Boolean(item.detail);
|
||||
|
||||
return (
|
||||
<div className="tc-enter-tool flex flex-col text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={expandable ? () => setShowDetail((v) => !v) : undefined}
|
||||
aria-expanded={expandable ? showDetail : undefined}
|
||||
className={cn(
|
||||
"group/tool -mx-1.5 flex min-w-0 items-center gap-2 rounded-sm px-1.5 py-0.5 text-left text-muted-foreground",
|
||||
expandable ? "cursor-pointer transition-colors hover:bg-muted/60 hover:text-foreground" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<RowIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="shrink-0 font-medium">{item.name}</span>
|
||||
{item.target ? (
|
||||
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.target}</span>
|
||||
<div className="tc-enter-tool rounded-lg border border-border bg-card/60 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2 px-3 py-2">
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate font-mono text-xs">{item.name}</span>
|
||||
{item.decision ? (
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 text-(length:--text-micro)",
|
||||
item.decision === "allowed" ? "text-(--status-task-icon-done)" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
{item.decision === "allowed" ? <ShieldCheck className="h-3 w-3" /> : <ShieldX className="h-3 w-3" />}
|
||||
{item.decision}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{item.decision ? (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-(length:--text-micro)",
|
||||
item.decision === "allowed" ? "text-(--status-task-icon-done)" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
{item.decision === "allowed" ? <ShieldCheck className="h-3 w-3" /> : <ShieldX className="h-3 w-3" />}
|
||||
{item.decision}
|
||||
</span>
|
||||
) : null}
|
||||
{expandable ? (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3 w-3 opacity-0 transition-[opacity,transform] group-hover/tool:opacity-80",
|
||||
showDetail ? "rotate-90" : null,
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<Icon className={cn("h-3.5 w-3.5", tone, spin && "animate-spin")} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
{expandable && showDetail ? (
|
||||
<pre className="ml-2.5 mt-0.5 overflow-x-auto whitespace-pre-wrap border-l-2 border-border py-1 pl-2.5 font-mono text-(length:--text-micro) leading-relaxed text-muted-foreground">
|
||||
{item.detail}
|
||||
</pre>
|
||||
) : null}
|
||||
<Icon className={cn("ml-auto h-4 w-4 shrink-0", tone, spin && "animate-spin")} />
|
||||
</div>
|
||||
{item.detail ? <p className="px-3 pb-2 text-xs text-muted-foreground">{item.detail}</p> : null}
|
||||
{item.diff ? (
|
||||
<div className="tc-reveal-diff ml-2.5 mt-1 border-l-2 border-border pl-2.5">
|
||||
<div className="tc-reveal-diff border-t border-border px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
|
||||
{item.diff.path ? <span className="truncate font-mono">{item.diff.path}</span> : null}
|
||||
<span className="ml-auto shrink-0 font-mono font-semibold">
|
||||
+{item.diff.added} −{item.diff.removed}
|
||||
<span className="ml-auto shrink-0">
|
||||
<span className="text-(--status-task-icon-done)">+{item.diff.added}</span>{" "}
|
||||
<span className="text-destructive">−{item.diff.removed}</span>
|
||||
</span>
|
||||
</div>
|
||||
{item.diff.lines?.length ? (
|
||||
<pre className="overflow-x-auto rounded-sm bg-muted/50 p-2 text-(length:--text-micro) leading-relaxed">
|
||||
<pre className="overflow-x-auto rounded bg-muted/50 p-2 text-(length:--text-micro) leading-relaxed">
|
||||
{item.diff.lines.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
|
||||
@@ -4,19 +4,6 @@ import { ArrowDown } from "lucide-react";
|
||||
|
||||
const PIN_THRESHOLD_PX = 48;
|
||||
|
||||
/** Visibility lifecycle of the scroll-to-latest pill. */
|
||||
type PillPhase = "hidden" | "in" | "out";
|
||||
|
||||
/**
|
||||
* True when animations are disabled (prefers-reduced-motion, or environments
|
||||
* without matchMedia such as jsdom). In that case the pill's exit animation
|
||||
* never fires animationend, so we must unmount immediately.
|
||||
*/
|
||||
function motionDisabled(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
interface TaskMessageScrollerProps {
|
||||
children: ReactNode;
|
||||
/** Value that changes whenever content that could grow the thread updates. */
|
||||
@@ -28,33 +15,16 @@ interface TaskMessageScrollerProps {
|
||||
* Scroll container that owns the redesign's auto-follow vs. hold-position rule.
|
||||
*
|
||||
* Explicit rule: while the viewport is pinned to the bottom (within a small
|
||||
* threshold) new content auto-follows with INSTANT scroll; the moment the user
|
||||
* scrolls up we hold their position and surface a scroll-to-latest pill
|
||||
* instead of yanking them down.
|
||||
*
|
||||
* Re-follow is easing-aware: clicking the pill glides down smoothly, and while
|
||||
* that glide is in flight (`easingRef`) the scroll handler must not unpin —
|
||||
* the smooth scroll fires intermediate scroll events that would otherwise
|
||||
* re-show the pill mid-glide (the known stick-to-bottom failure mode). Arrival
|
||||
* within the pin threshold re-pins and hides the pill; a wheel/touch during
|
||||
* the glide cancels it and treats the user as unpinned. Content-driven follow
|
||||
* while pinned stays instant, so no reflow/jump happens during streaming.
|
||||
* threshold) new content auto-follows; the moment the user scrolls up we hold
|
||||
* their position and surface a "Jump to latest" affordance instead of yanking
|
||||
* them down. Scrolling is INSTANT (not smooth) so a scroll listener cannot
|
||||
* unpin mid-animation — a known stick-to-bottom failure mode. No reflow/jump is
|
||||
* introduced during streaming because we only ever scroll when already pinned.
|
||||
*/
|
||||
export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const pinnedRef = useRef(true);
|
||||
const easingRef = useRef(false);
|
||||
const [pillPhase, setPillPhase] = useState<PillPhase>("hidden");
|
||||
|
||||
const showPill = useCallback(() => {
|
||||
setPillPhase("in");
|
||||
}, []);
|
||||
|
||||
const hidePill = useCallback(() => {
|
||||
// Keep rendering through the exit animation; unmount on animationend.
|
||||
// Without animations (reduced motion / no matchMedia) unmount immediately.
|
||||
setPillPhase((phase) => (phase === "hidden" ? phase : motionDisabled() ? "hidden" : "out"));
|
||||
}, []);
|
||||
const [showJump, setShowJump] = useState(false);
|
||||
|
||||
const isPinned = useCallback(() => {
|
||||
const el = ref.current;
|
||||
@@ -70,60 +40,9 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const pinned = isPinned();
|
||||
if (easingRef.current) {
|
||||
// Smooth re-follow in flight: intermediate scroll events must not
|
||||
// unpin/re-show the pill. Only act once we arrive within the threshold.
|
||||
if (pinned) {
|
||||
easingRef.current = false;
|
||||
pinnedRef.current = true;
|
||||
hidePill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
pinnedRef.current = pinned;
|
||||
if (pinned) hidePill();
|
||||
else showPill();
|
||||
}, [isPinned, hidePill, showPill]);
|
||||
|
||||
const handleJumpToLatest = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (isPinned()) {
|
||||
// Already at (or within threshold of) the bottom: no glide needed.
|
||||
pinnedRef.current = true;
|
||||
hidePill();
|
||||
return;
|
||||
}
|
||||
easingRef.current = true;
|
||||
if (typeof el.scrollTo === "function") {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
} else {
|
||||
// Environments without scrollTo (older jsdom): fall back to instant.
|
||||
el.scrollTop = el.scrollHeight;
|
||||
easingRef.current = false;
|
||||
pinnedRef.current = true;
|
||||
hidePill();
|
||||
}
|
||||
}, [isPinned, hidePill]);
|
||||
|
||||
// A user gesture during the smooth glide cancels the re-follow: stop
|
||||
// treating scroll events as easing and consider the user unpinned.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const cancelEasing = () => {
|
||||
if (!easingRef.current) return;
|
||||
easingRef.current = false;
|
||||
pinnedRef.current = false;
|
||||
showPill();
|
||||
};
|
||||
el.addEventListener("wheel", cancelEasing, { passive: true });
|
||||
el.addEventListener("touchstart", cancelEasing, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("wheel", cancelEasing);
|
||||
el.removeEventListener("touchstart", cancelEasing);
|
||||
};
|
||||
}, [showPill]);
|
||||
setShowJump(!pinned);
|
||||
}, [isPinned]);
|
||||
|
||||
// Follow new content only when already pinned; otherwise hold position.
|
||||
useLayoutEffect(() => {
|
||||
@@ -140,30 +59,23 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
|
||||
<div
|
||||
ref={ref}
|
||||
onScroll={handleScroll}
|
||||
// absolute inset-0 (not h-full): the viewport must equal the flex-sized
|
||||
// wrapper exactly — percentage heights don't reliably resolve against
|
||||
// flex-determined block heights, which let the thread overflow the page.
|
||||
className={cn("absolute inset-0 overflow-y-auto", className)}
|
||||
className={cn("h-full overflow-y-auto", className)}
|
||||
data-testid="task-chat-scroller"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{pillPhase !== "hidden" ? (
|
||||
{showJump ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll to latest"
|
||||
onClick={handleJumpToLatest}
|
||||
onAnimationEnd={() => {
|
||||
if (pillPhase === "out") setPillPhase("hidden");
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
pinnedRef.current = true;
|
||||
setShowJump(false);
|
||||
}}
|
||||
// The tc-scroll-pill-* keyframes carry the translate(-50%) X-centering
|
||||
// (fill: both keeps it after the animation) — no -translate-x-1/2 here.
|
||||
className={cn(
|
||||
"absolute bottom-3 left-1/2 flex size-8 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-muted",
|
||||
pillPhase === "out" ? "tc-scroll-pill-out" : "tc-scroll-pill-in",
|
||||
)}
|
||||
className="tc-enter-status absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card px-3 py-1 text-xs shadow-sm hover:bg-accent/50"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
Jump to latest
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { Copy, GripHorizontal, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
EASING_PRESETS,
|
||||
@@ -221,7 +220,7 @@ export function TweakPanel() {
|
||||
value={exportText}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.select();
|
||||
void copyTextToClipboard(exportText).catch(() => {});
|
||||
void navigator.clipboard?.writeText(exportText).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -27,9 +27,6 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
|
||||
{ name: "--motion-ease-out-expo", group: "Easing", kind: "easing" },
|
||||
{ name: "--motion-ease-standard", group: "Easing", kind: "easing" },
|
||||
{ name: "--motion-ease-out", group: "Easing", kind: "easing" },
|
||||
{ name: "--motion-ease-scroll-pill-in", group: "Easing", kind: "easing" },
|
||||
{ name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" },
|
||||
{ name: "--motion-ease-in", group: "Easing", kind: "easing" },
|
||||
|
||||
// Duration primitives
|
||||
{ name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
|
||||
@@ -37,11 +34,6 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
|
||||
{ name: "--motion-duration-base", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
|
||||
{ name: "--motion-duration-slow", group: "Durations", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-duration-deliberate", group: "Durations", kind: "time", min: 0, max: 2000, step: 10 },
|
||||
// Shared enter/exit/swap primitives owned by the decision/quicklook motion
|
||||
// block in index.css.
|
||||
{ name: "--motion-duration-enter", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
|
||||
{ name: "--motion-duration-exit", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
|
||||
{ name: "--motion-duration-swap", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
|
||||
|
||||
// State/component-scoped
|
||||
{ name: "--motion-marker-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
@@ -57,12 +49,6 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
|
||||
{ name: "--motion-plan-check", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
|
||||
{ name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-line-scroll", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-interstitial-dwell", group: "States", kind: "time", min: 0, max: 10000, step: 100 },
|
||||
{ name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
];
|
||||
|
||||
/** Common easing presets offered by the tweak panel's easing picker. */
|
||||
|
||||
@@ -9,24 +9,12 @@
|
||||
*/
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { IssueChatComment } from "@/lib/issue-chat-messages";
|
||||
import { resolveCommentAttribution } from "@/lib/comment-attribution";
|
||||
import type { TaskChatAuthorKind, TaskChatItem } from "./task-chat-model";
|
||||
|
||||
export interface TaskChatAdapterContext {
|
||||
agentMap?: Map<string, Agent>;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
currentUserId?: string | null;
|
||||
/**
|
||||
* Task's current assignee. Agent comments from anyone else are cross-issue
|
||||
* 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 {
|
||||
@@ -40,8 +28,7 @@ function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
|
||||
return "system";
|
||||
}
|
||||
|
||||
/** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */
|
||||
export function formatTaskChatTimestamp(value: unknown): string | undefined {
|
||||
function formatTimestamp(value: unknown): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const d = value instanceof Date ? value : new Date(value as string);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
@@ -57,18 +44,9 @@ export function commentsToTaskChatItems(
|
||||
if (comment.deletedAt) continue;
|
||||
const kind = authorKind(comment);
|
||||
let authorName: string | undefined;
|
||||
let agentIcon: string | null | undefined;
|
||||
let onBehalfOfUserName: string | undefined;
|
||||
if (kind === "agent") {
|
||||
const agentId = effectiveAgentId(comment);
|
||||
authorName = (agentId && ctx.agentMap?.get(agentId)?.name) || "Agent";
|
||||
agentIcon = agentId ? ctx.agentMap?.get(agentId)?.icon : undefined;
|
||||
onBehalfOfUserName = resolveCommentAttribution({
|
||||
authorAgentId: agentId,
|
||||
onBehalfOfUserId: comment.onBehalfOfUserId ?? null,
|
||||
issueAssigneeAgentId: ctx.issueAssigneeAgentId,
|
||||
resolveUserLabel: (userId) => ctx.userLabelMap?.get(userId),
|
||||
})?.userName;
|
||||
} else if (kind === "human") {
|
||||
authorName =
|
||||
(comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined;
|
||||
@@ -85,11 +63,8 @@ export function commentsToTaskChatItems(
|
||||
author: kind,
|
||||
authorName,
|
||||
text: comment.body,
|
||||
timestamp: formatTaskChatTimestamp(comment.createdAt),
|
||||
timestamp: formatTimestamp(comment.createdAt),
|
||||
optimistic,
|
||||
agentIcon,
|
||||
onBehalfOfUserName,
|
||||
modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
@@ -50,90 +50,30 @@ 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, text: "On it — I'll add a token-bucket limiter and wire it into the login route.", timestamp: "2:31 PM" },
|
||||
],
|
||||
};
|
||||
case "thinking":
|
||||
// The surviving thinking signal (PAP-361): the live line's "Thinking…"
|
||||
// state — Brain icon + shimmer on the pill. Thinking rows no longer
|
||||
// render in the thread or nest under turns.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{
|
||||
id: "turn-thinking",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 1, added: 0, removed: 0 },
|
||||
liveStatus: { id: "st-thinking", kind: "status", status: "running", label: "Thinking", startedAtMs: Date.now() - 6100, tokens: { used: 18240, size: 200000 } },
|
||||
items: [
|
||||
{ id: "th-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" },
|
||||
],
|
||||
},
|
||||
{ id: "th-1", kind: "thinking", streaming: true, lines: ["The login route is in server/src/routes/auth.ts.", "There's already an ipRateLimit helper I can reuse.", "I'll add a per-account bucket keyed on the email."] },
|
||||
],
|
||||
};
|
||||
case "responding":
|
||||
// A streaming interstitial update gets its own row directly above the
|
||||
// status line (PAP-361, amended): it wraps into the 1lh viewport
|
||||
// line-scroll while the gerund rotation below runs uninterrupted.
|
||||
// Ephemeral — when it finishes, the row slides out and the text renders
|
||||
// nowhere.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{
|
||||
id: "turn-responding",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 1, added: 0, removed: 0 },
|
||||
liveStatus: {
|
||||
id: "st-responding", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 9300, tokens: { used: 18240, size: 200000 },
|
||||
selfTalk:
|
||||
"I found an existing ipRateLimit helper, so I'll extend it with a per-account token bucket keyed on the email address instead of adding a second limiter. The bucket refills at six requests a minute, matching the lockout policy the auth spec documents, and failed attempts drain it twice as fast so brute-force runs hit the ceiling quickly while a fat-fingered password barely registers.",
|
||||
},
|
||||
items: [
|
||||
{ id: "resp-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
case "responding-burst":
|
||||
// A run emitting several interstitial updates in quick succession
|
||||
// (PAP-368): the lab replay streams each blank-line-separated segment as
|
||||
// its own update with a short gap between. The pill HOLDS each finished
|
||||
// update until the next swaps in, paced by --motion-interstitial-dwell
|
||||
// (latest-wins when updates outpace the dwell).
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{
|
||||
id: "turn-responding-burst",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 2, added: 0, removed: 0 },
|
||||
liveStatus: {
|
||||
id: "st-responding-burst", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 21400, tokens: { used: 18240, size: 200000 },
|
||||
selfTalk:
|
||||
"Found the existing ipRateLimit helper — extending it beats adding a second limiter.\n\n" +
|
||||
"Wiring a per-account token bucket keyed on the email address, refilling at six requests a minute per the auth spec.\n\n" +
|
||||
"Failed attempts drain the bucket twice as fast, so brute-force runs hit the ceiling while a fat-fingered password barely registers.\n\n" +
|
||||
"Now updating the login route to consume from the bucket before the password check and adding tests for the lockout path.",
|
||||
},
|
||||
items: [
|
||||
{ id: "burst-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
{ id: "burst-grep", kind: "tool", name: "Grep", target: "ipRateLimit", toolKind: "search", status: "completed" },
|
||||
],
|
||||
},
|
||||
{ id: "m-agent-stream", kind: "message", author: "agent", authorName: AGENT, streaming: true, text: "I found an existing ipRateLimit helper, so I'll extend it with a per-account bucket and" },
|
||||
],
|
||||
};
|
||||
case "tool-call":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "tool-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "in_progress" },
|
||||
{ id: "tool-1", kind: "tool", name: "read server/src/routes/auth.ts", toolKind: "read", status: "in_progress" },
|
||||
],
|
||||
};
|
||||
case "diff":
|
||||
@@ -141,7 +81,7 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{
|
||||
id: "tool-diff", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", decision: "allowed",
|
||||
id: "tool-diff", kind: "tool", name: "edit server/src/routes/auth.ts", toolKind: "edit", status: "completed", decision: "allowed",
|
||||
diff: {
|
||||
path: "server/src/routes/auth.ts", added: 3, removed: 1,
|
||||
lines: [
|
||||
@@ -156,73 +96,25 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
],
|
||||
};
|
||||
case "working":
|
||||
// Parent-row live turn (PAP-354): the tool-state line owns the activity;
|
||||
// expanding nests the chronological history underneath.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{
|
||||
id: "turn-working",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 2, added: 0, removed: 0 },
|
||||
liveStatus: { id: "st-working", kind: "status", status: "working", label: "Editing files", detail: "Edit · server/src/routes/auth.ts", toolName: "Edit", startedAtMs: Date.now() - 4200 },
|
||||
items: [
|
||||
{ id: "w-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
{ id: "w-edit", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "in_progress" },
|
||||
],
|
||||
},
|
||||
{ id: "st-working", kind: "status", status: "working", label: "Using edit", detail: "server/src/routes/auth.ts", elapsedMs: 4200 },
|
||||
],
|
||||
};
|
||||
case "running":
|
||||
// Generic label → the parent row header rotates whimsical gerunds.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{
|
||||
id: "turn-running",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 1, added: 0, removed: 0 },
|
||||
liveStatus: { id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", startedAtMs: Date.now() - 12000, tokens: { used: 18240, size: 200000 } },
|
||||
items: [
|
||||
{ id: "r-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" },
|
||||
],
|
||||
},
|
||||
{ id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", elapsedMs: 12000, tokens: { used: 18240, size: 200000 } },
|
||||
],
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
// Round 9: the settled turn attaches to the final reply bubble — the
|
||||
// "✓ Worked · …" summary renders on the bubble's always-visible
|
||||
// timestamp line ("2:34 PM · ✓ Worked · 38s · 2 tools").
|
||||
{
|
||||
id: "m-done",
|
||||
kind: "message",
|
||||
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: {
|
||||
id: "turn-done",
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
// "Worked · N tools" expands to exactly the tool rows (PAP-361):
|
||||
// toolCount matches the nested rows, no thinking row.
|
||||
summary: { durationLabel: "38s", toolCount: 2, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
|
||||
items: [
|
||||
{ id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
{ id: "tool-done-2", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", diff: { path: "server/src/routes/auth.ts", added: 34, removed: 3 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: "th-done", kind: "thinking", collapsed: true, summaryLabel: "Worked for 38s · 3 tools · +34 −3", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] },
|
||||
{ id: "m-done", kind: "message", author: "agent", authorName: AGENT, text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.", timestamp: "2:34 PM" },
|
||||
],
|
||||
};
|
||||
case "awaiting-approval":
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* 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";
|
||||
@@ -69,37 +68,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;
|
||||
/**
|
||||
* Responsible user's display name, set only when this agent comment is a
|
||||
* cross-issue write (the author is not the assignee). Renders as a
|
||||
* "for {user}" chip beside the author name (the open cross-task write design (attribution)).
|
||||
*/
|
||||
onBehalfOfUserName?: string;
|
||||
/**
|
||||
* Agent text streamed inside a run turn (interstitial updates between tool
|
||||
* calls). Ephemeral in the redesigned view (PAP-361): while streaming it
|
||||
* takes the live parent row's line (TaskChatStatusItem.selfTalk); once
|
||||
* finished it renders nowhere — the run log / classic transcript remain the
|
||||
* archive. Tagged for live and settled transcripts alike.
|
||||
*/
|
||||
interstitial?: boolean;
|
||||
/** Epoch ms of the message's first streamed chunk. */
|
||||
atMs?: number;
|
||||
/**
|
||||
* The settled run turn "attached" to this bubble (round 9): when the run's
|
||||
* final reply lands, the live parent row transforms into the "Worked · …"
|
||||
* summary rendered on this bubble's always-visible timestamp line —
|
||||
* "2:34 PM · ✓ Worked · 38s · 3 tools" — instead of a standalone row.
|
||||
* Expanding still nests the tool history beneath the bubble.
|
||||
*/
|
||||
attachedTurn?: TaskChatTurnItem;
|
||||
}
|
||||
|
||||
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
|
||||
@@ -114,21 +82,14 @@ export interface TaskChatThinkingItem {
|
||||
summaryLabel?: string;
|
||||
}
|
||||
|
||||
/** A tool invocation row (ACP tool_call / tool_call_update). */
|
||||
/** A tool invocation card (ACP tool_call / tool_call_update). */
|
||||
export interface TaskChatToolItem {
|
||||
id: string;
|
||||
kind: "tool";
|
||||
name: string;
|
||||
/** Raw ACP tool name pre-collapse ("mcp__server__tool") for taxonomy lookup. */
|
||||
rawName?: string;
|
||||
/** ACP ToolKind. */
|
||||
toolKind?: string;
|
||||
status: TaskChatToolStatus;
|
||||
/**
|
||||
* Summarized primary argument (file path, command, pattern…) rendered mono
|
||||
* next to the tool name — the v7 toolrow "target".
|
||||
*/
|
||||
target?: string;
|
||||
detail?: string;
|
||||
diff?: TaskChatDiff;
|
||||
/** Resolved permission decision badge, when one applied. */
|
||||
@@ -152,22 +113,7 @@ export interface TaskChatStatusItem {
|
||||
| "truncated";
|
||||
label: string;
|
||||
detail?: string;
|
||||
/** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */
|
||||
toolName?: string;
|
||||
/**
|
||||
* Flattened plain text of the interstitial update currently streaming
|
||||
* (PAP-361, round 9): it renders in a dedicated single-line row directly
|
||||
* above the status line — a slot PERMANENTLY RESERVED while the turn is
|
||||
* live, so the layout above never jumps — inside a one-line viewport
|
||||
* showing the streaming tail line; the gerund rotation below runs
|
||||
* uninterrupted. When the message finishes the pill HOLDS the text as a
|
||||
* static ellipsized line until the next update supersedes it or the turn
|
||||
* ends (PAP-368) — nothing persists in the settled thread.
|
||||
*/
|
||||
selfTalk?: string;
|
||||
elapsedMs?: number;
|
||||
/** Run start epoch ms; live states tick their own elapsed from this. */
|
||||
startedAtMs?: number;
|
||||
tokens?: TaskChatTokenUsage;
|
||||
/** Present for awaiting_approval (ACP RequestPermissionRequest). */
|
||||
approval?: { toolName: string; options: TaskChatApprovalOption[] };
|
||||
@@ -189,84 +135,13 @@ export interface TaskChatUsageItem {
|
||||
usage: TaskChatTokenUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* The task description rendered as the requester's first chat bubble
|
||||
* (PAP-375). A placeholder kind only — the host supplies the render
|
||||
* (TaskChatThread binds TaskChatDescriptionBubble to the live issue), mirroring
|
||||
* how interaction items defer to renderInteraction. Always prepended AFTER
|
||||
* thread assembly, so no backbone entry or settled turn can sort above it.
|
||||
*/
|
||||
export interface TaskChatBriefItem {
|
||||
id: string;
|
||||
kind: "brief";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
| TaskChatThinkingItem
|
||||
| TaskChatToolItem
|
||||
| TaskChatStatusItem
|
||||
| TaskChatMarkerItem
|
||||
| TaskChatUsageItem;
|
||||
|
||||
/**
|
||||
* One agent turn's activity (thinking/tools/diffs) grouped so a finished turn
|
||||
* can fold into a one-line expandable summary ("✓ Worked · 38s · 3 tools ·
|
||||
* +34 −3"). A live turn carrying `liveStatus` renders as a single expandable
|
||||
* parent row headed by that status line (gerund/tool-state + elapsed +
|
||||
* tokens); expanding nests the chronological activity underneath, and on
|
||||
* settle the header morphs in place into the summary. The live → settled
|
||||
* fold animates (~ --motion-turn-fold), while turns that load already-settled
|
||||
* collapse instantly.
|
||||
*/
|
||||
export interface TaskChatTurnItem {
|
||||
id: string;
|
||||
kind: "turn";
|
||||
items: TaskChatTurnChildItem[];
|
||||
settled: boolean;
|
||||
/**
|
||||
* The in-flight run's status line, hoisted to be THE turn's single visible
|
||||
* row while collapsed (PAP-354 parent-row model). Absent once settled.
|
||||
*/
|
||||
liveStatus?: TaskChatStatusItem;
|
||||
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
|
||||
animateFold?: boolean;
|
||||
summary: {
|
||||
/** e.g. "38s" — omitted when unknown. */
|
||||
durationLabel?: string;
|
||||
toolCount: number;
|
||||
added: number;
|
||||
removed: number;
|
||||
/** e.g. "12.3k tokens" — omitted when unknown. */
|
||||
tokensLabel?: string;
|
||||
/** Failed/interrupted turns get a ✗ affordance instead of ✓. */
|
||||
failed?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type TaskChatItem =
|
||||
| TaskChatMessageItem
|
||||
| TaskChatThinkingItem
|
||||
| TaskChatToolItem
|
||||
| TaskChatStatusItem
|
||||
| TaskChatMarkerItem
|
||||
| TaskChatUsageItem
|
||||
| TaskChatInteractionItem
|
||||
| TaskChatTurnItem
|
||||
| TaskChatBriefItem;
|
||||
| TaskChatUsageItem;
|
||||
|
||||
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
|
||||
export interface TaskChatPlanEntry {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { TASK_CHAT_STATES } from "./task-chat-states";
|
||||
import { buildScenario } from "./task-chat-fixtures";
|
||||
import { TaskChatThreadView } from "./TaskChatThreadView";
|
||||
import { TaskChatPlanView } from "./TaskChatPlanView";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
|
||||
/**
|
||||
* Finish-line clause C: every state id in the inventory renders without error.
|
||||
@@ -33,15 +32,11 @@ describe("Task chat state inventory renders", () => {
|
||||
expect(() => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
// ThemeProvider: bubbles render markdown via MarkdownBody, which
|
||||
// reads the theme — mirror the real app's provider tree.
|
||||
<ThemeProvider>
|
||||
{scenario.surface === "plan" && scenario.plan ? (
|
||||
<TaskChatPlanView plan={scenario.plan} />
|
||||
) : (
|
||||
<TaskChatThreadView items={scenario.items} scroll={false} />
|
||||
)}
|
||||
</ThemeProvider>,
|
||||
scenario.surface === "plan" && scenario.plan ? (
|
||||
<TaskChatPlanView plan={scenario.plan} />
|
||||
) : (
|
||||
<TaskChatThreadView items={scenario.items} scroll={false} />
|
||||
),
|
||||
);
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
@@ -18,7 +18,6 @@ export const TASK_CHAT_STATES = [
|
||||
"agent-message",
|
||||
"thinking",
|
||||
"responding",
|
||||
"responding-burst",
|
||||
"tool-call",
|
||||
"diff",
|
||||
"working",
|
||||
@@ -82,13 +81,6 @@ export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> =
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output, streaming",
|
||||
},
|
||||
"responding-burst": {
|
||||
id: "responding-burst",
|
||||
label: "Responding (update burst)",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output ×N, tool calls between (PAP-368 dwell)",
|
||||
},
|
||||
"tool-call": {
|
||||
id: "tool-call",
|
||||
label: "Tool call",
|
||||
|
||||
@@ -10,10 +10,7 @@ import type {
|
||||
TaskChatDiff,
|
||||
TaskChatItem,
|
||||
TaskChatToolItem,
|
||||
TaskChatTurnChildItem,
|
||||
TaskChatTurnItem,
|
||||
} from "./task-chat-model";
|
||||
import { isGenericToolName, mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
"failed",
|
||||
@@ -27,127 +24,12 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean
|
||||
return status != null && TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live parent row's nesting rule (PAP-354, narrowed by PAP-361): only tool
|
||||
* calls and usage readouts nest inside the expandable live turn. Messages
|
||||
* never nest — an interstitial update streams on the parent row's own line
|
||||
* (TaskChatStatusItem.selfTalk) and vanishes when it completes, and the run's
|
||||
* final reply lands as its posted comment bubble. Thinking never nests either:
|
||||
* its live signal is the pill's "Thinking…" state, and the text stays in the
|
||||
* run log / classic transcript. Markers, statuses and interaction cards stay
|
||||
* in the thread outside.
|
||||
*/
|
||||
export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem {
|
||||
return item.kind === "tool" || item.kind === "usage";
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten markdown-ish interstitial text to one plain line for the live parent
|
||||
* row. Stream-safe: markers are stripped without requiring pairs.
|
||||
*/
|
||||
export function flattenSelfTalk(text: string): string {
|
||||
return text
|
||||
.replace(/```[a-z]*\n?/gi, " ")
|
||||
.replace(/`/g, "")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*>\s?/gm, "")
|
||||
.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1")
|
||||
.replace(/\*+/g, "")
|
||||
.replace(/__/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function diffKind(changeType: string): "add" | "remove" | "context" {
|
||||
if (changeType === "add") return "add";
|
||||
if (changeType === "remove") return "remove";
|
||||
return "context";
|
||||
}
|
||||
|
||||
/** Param keys probed (in order) for a tool call's one-line mono "target". */
|
||||
const TARGET_KEYS = [
|
||||
"file_path",
|
||||
"path",
|
||||
"notebook_path",
|
||||
"command",
|
||||
"pattern",
|
||||
"query",
|
||||
"url",
|
||||
"prompt",
|
||||
"description",
|
||||
"skill",
|
||||
"subject",
|
||||
] as const;
|
||||
|
||||
const TARGET_MAX = 96;
|
||||
|
||||
function clip(text: string, max: number): string {
|
||||
const oneLine = text.replace(/\s+/g, " ").trim();
|
||||
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a tool call's input into the v7 toolrow target: the most
|
||||
* identifying parameter when the input is a keyed object, else a clipped
|
||||
* rendering of the raw input. Returns undefined when there is nothing useful.
|
||||
*/
|
||||
export function summarizeToolInput(input: unknown): string | undefined {
|
||||
if (input == null) return undefined;
|
||||
if (typeof input === "string") return input.trim() ? clip(input, TARGET_MAX) : undefined;
|
||||
if (typeof input !== "object") return clip(String(input), TARGET_MAX);
|
||||
const record = input as Record<string, unknown>;
|
||||
for (const key of TARGET_KEYS) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return clip(value, TARGET_MAX);
|
||||
}
|
||||
// The acpx log parser synthesizes { text, status } onto tool inputs from the
|
||||
// event's summary line — presentation noise, not call parameters.
|
||||
const entries = Object.entries(record).filter(
|
||||
([k, v]) =>
|
||||
(typeof v === "string" || typeof v === "number" || typeof v === "boolean") &&
|
||||
k !== "text" &&
|
||||
k !== "status",
|
||||
);
|
||||
if (entries.length === 0) return undefined;
|
||||
return clip(entries.map(([k, v]) => `${k}: ${String(v)}`).join(", "), TARGET_MAX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for a tool call. Live acpx `tool_call` events carry real names
|
||||
* ("Read", "Bash", mcp__server__tool); legacy stored logs may not — those fall
|
||||
* back to a generic "Tool" row. MCP names collapse to their tool segment.
|
||||
*/
|
||||
export function toolDisplayName(name: string | undefined | null): string {
|
||||
const raw = (name ?? "").trim();
|
||||
if (isGenericToolName(raw)) return "Tool";
|
||||
const mcp = mcpToolSegment(raw);
|
||||
if (mcp) return mcp;
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||
}
|
||||
|
||||
/** "Thought for Ns" once a coalesced thinking group spans ≥1s. */
|
||||
function thoughtDurationLabel(startTs: string | undefined, endTs: string): string | undefined {
|
||||
if (!startTs) return undefined;
|
||||
const start = Date.parse(startTs);
|
||||
const end = Date.parse(endTs);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
|
||||
const secs = Math.round((end - start) / 1000);
|
||||
if (secs < 1) return undefined;
|
||||
return secs < 60 ? `Thought for ${secs}s` : `Thought for ${Math.floor(secs / 60)}m ${secs % 60}s`;
|
||||
}
|
||||
|
||||
const DETAIL_MAX = 600;
|
||||
|
||||
/** Result content → the expandable mono detail block (clipped, trimmed). */
|
||||
function formatToolResultDetail(content: unknown): string | undefined {
|
||||
if (content == null) return undefined;
|
||||
const text = typeof content === "string" ? content : String(content);
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.length > DETAIL_MAX ? `${trimmed.slice(0, DETAIL_MAX)}\n…` : trimmed;
|
||||
}
|
||||
|
||||
interface TranscriptAdapterOptions {
|
||||
runId: string;
|
||||
agentName?: string;
|
||||
@@ -166,7 +48,6 @@ export function transcriptToTaskChatItems(
|
||||
): TaskChatItem[] {
|
||||
const items: TaskChatItem[] = [];
|
||||
const toolIndexById = new Map<string, number>();
|
||||
const thinkingStartTs = new Map<number, string>();
|
||||
let lastToolIndex = -1;
|
||||
let thinkingIndex = -1;
|
||||
let messageIndex = -1;
|
||||
@@ -182,24 +63,16 @@ export function transcriptToTaskChatItems(
|
||||
if (!entry.text) break;
|
||||
if (thinkingIndex >= 0) {
|
||||
const it = items[thinkingIndex];
|
||||
if (it.kind === "thinking") {
|
||||
it.lines.push(...entry.text.split("\n"));
|
||||
const startTs = thinkingStartTs.get(thinkingIndex);
|
||||
const label = thoughtDurationLabel(startTs, entry.ts);
|
||||
if (label) it.summaryLabel = label;
|
||||
}
|
||||
if (it.kind === "thinking") it.lines.push(...entry.text.split("\n"));
|
||||
} else {
|
||||
items.push({
|
||||
id: `${runId}:think:${i}`,
|
||||
kind: "thinking",
|
||||
lines: entry.text.split("\n"),
|
||||
streaming: running,
|
||||
// Settled history folds its thinking behind the header (v7);
|
||||
// the in-flight run streams it expanded.
|
||||
collapsed: !running,
|
||||
collapsed: false,
|
||||
});
|
||||
thinkingIndex = items.length - 1;
|
||||
thinkingStartTs.set(thinkingIndex, entry.ts);
|
||||
messageIndex = -1;
|
||||
}
|
||||
break;
|
||||
@@ -210,7 +83,6 @@ export function transcriptToTaskChatItems(
|
||||
const it = items[messageIndex];
|
||||
if (it.kind === "message") it.text += entry.text;
|
||||
} else {
|
||||
const atMs = Date.parse(entry.ts);
|
||||
items.push({
|
||||
id: `${runId}:msg:${i}`,
|
||||
kind: "message",
|
||||
@@ -218,10 +90,6 @@ export function transcriptToTaskChatItems(
|
||||
authorName: agentName,
|
||||
text: entry.text,
|
||||
streaming: running,
|
||||
// Everything the agent says inside a run turn is self-talk until it
|
||||
// lands as the posted comment — live and history tag it alike.
|
||||
interstitial: true,
|
||||
atMs: Number.isFinite(atMs) ? atMs : undefined,
|
||||
});
|
||||
messageIndex = items.length - 1;
|
||||
thinkingIndex = -1;
|
||||
@@ -230,32 +98,17 @@ export function transcriptToTaskChatItems(
|
||||
}
|
||||
case "tool_call": {
|
||||
const toolCallId = entry.toolUseId || `tool-${i}`;
|
||||
const existingIndex = toolIndexById.get(toolCallId);
|
||||
const existing = existingIndex != null ? items[existingIndex] : undefined;
|
||||
if (existing?.kind === "tool") {
|
||||
// tool_call_update for a call already in the list: merge. Updates
|
||||
// often omit the title (acpx fills in a literal "tool call"), so a
|
||||
// generic name must never displace the initial call's real one. ACP
|
||||
// also retitles a call to its invocation once known ("Terminal" →
|
||||
// "ls -la"); that is detail for the target slot, not a new identity.
|
||||
if (!isGenericToolName(entry.name)) {
|
||||
if (isGenericToolName(existing.rawName)) {
|
||||
existing.name = toolDisplayName(entry.name);
|
||||
existing.rawName = entry.name ?? undefined;
|
||||
} else if (!existing.target && entry.name !== existing.rawName) {
|
||||
existing.target = clip(entry.name!, TARGET_MAX);
|
||||
}
|
||||
}
|
||||
lastToolIndex = existingIndex!;
|
||||
const item: TaskChatToolItem = {
|
||||
id: `${runId}:tool:${toolCallId}`,
|
||||
kind: "tool",
|
||||
name: entry.name || "tool",
|
||||
status: "in_progress",
|
||||
};
|
||||
if (toolIndexById.has(toolCallId)) {
|
||||
items[toolIndexById.get(toolCallId)!] = item;
|
||||
lastToolIndex = toolIndexById.get(toolCallId)!;
|
||||
} else {
|
||||
items.push({
|
||||
id: `${runId}:tool:${toolCallId}`,
|
||||
kind: "tool",
|
||||
name: toolDisplayName(entry.name),
|
||||
rawName: entry.name ?? undefined,
|
||||
target: summarizeToolInput(entry.input),
|
||||
status: "in_progress",
|
||||
});
|
||||
items.push(item);
|
||||
toolIndexById.set(toolCallId, items.length - 1);
|
||||
lastToolIndex = items.length - 1;
|
||||
}
|
||||
@@ -269,13 +122,8 @@ export function transcriptToTaskChatItems(
|
||||
const existing = items[idx];
|
||||
if (existing.kind === "tool") {
|
||||
existing.status = entry.isError ? "failed" : "completed";
|
||||
if (isGenericToolName(existing.rawName) && !isGenericToolName(entry.toolName)) {
|
||||
existing.name = toolDisplayName(entry.toolName);
|
||||
existing.rawName = entry.toolName;
|
||||
}
|
||||
if (!existing.detail) {
|
||||
const detail = formatToolResultDetail(entry.content);
|
||||
if (detail) existing.detail = detail;
|
||||
if (entry.content && !existing.detail) {
|
||||
existing.detail = String(entry.content).slice(0, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,7 +144,7 @@ export function transcriptToTaskChatItems(
|
||||
items.push({
|
||||
id: `${runId}:diff:${i}`,
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
name: "edit",
|
||||
status: "completed",
|
||||
diff: {
|
||||
added: line.kind === "add" ? 1 : 0,
|
||||
@@ -316,352 +164,21 @@ export function transcriptToTaskChatItems(
|
||||
}
|
||||
}
|
||||
|
||||
// Only the message still open at the transcript tail is streaming; earlier
|
||||
// self-talk is finished and nests as a settled row even mid-run.
|
||||
if (running) {
|
||||
for (const [idx, it] of items.entries()) {
|
||||
if (it.kind === "message" && idx !== messageIndex) it.streaming = false;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function settledRunChildren(parsed: readonly TaskChatItem[]): TaskChatTurnChildItem[] {
|
||||
return parsed.filter(
|
||||
(it): it is TaskChatTurnChildItem =>
|
||||
it.kind !== "turn" && it.kind !== "message" && it.kind !== "thinking",
|
||||
);
|
||||
}
|
||||
|
||||
function formatDurationLabel(ms: number): string | undefined {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return undefined;
|
||||
const totalSec = Math.round(ms / 1000);
|
||||
if (totalSec < 1) return "1s";
|
||||
if (totalSec < 60) return `${totalSec}s`;
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = totalSec % 60;
|
||||
return sec === 0 ? `${min}m` : `${min}m ${sec}s`;
|
||||
}
|
||||
|
||||
function formatTokensLabel(tokens: number): string | undefined {
|
||||
if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
|
||||
const label = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;
|
||||
return `${label} tokens`;
|
||||
}
|
||||
|
||||
/** First→last ts span of a transcript, or undefined when unknowable. */
|
||||
function transcriptSpanMs(entries: readonly TranscriptEntry[]): number | undefined {
|
||||
if (entries.length < 2) return undefined;
|
||||
const first = Date.parse(entries[0].ts);
|
||||
const last = Date.parse(entries[entries.length - 1].ts);
|
||||
if (!Number.isFinite(first) || !Number.isFinite(last)) return undefined;
|
||||
return Math.max(0, last - first);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate a turn's transcript into the folded one-line summary
|
||||
* ("✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens"). Duration prefers the
|
||||
* caller-supplied run duration and falls back to the transcript's ts span.
|
||||
*/
|
||||
export function buildTurnSummary(
|
||||
entries: readonly TranscriptEntry[],
|
||||
opts: { durationMs?: number; failed?: boolean } = {},
|
||||
): TaskChatTurnItem["summary"] {
|
||||
const toolIds = new Set<string>();
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let tokens = 0;
|
||||
for (const [i, entry] of entries.entries()) {
|
||||
// Each status change of a call logs its own tool_call entry sharing the
|
||||
// toolUseId; count unique calls so the folded summary matches the rows the
|
||||
// expanded list renders (same `tool-${i}` fallback as the parser above).
|
||||
if (entry.kind === "tool_call") toolIds.add(entry.toolUseId || `tool-${i}`);
|
||||
else if (entry.kind === "diff") {
|
||||
if (entry.changeType === "add") added += 1;
|
||||
else if (entry.changeType === "remove") removed += 1;
|
||||
} else if (entry.kind === "result") {
|
||||
tokens += (entry.inputTokens || 0) + (entry.outputTokens || 0);
|
||||
}
|
||||
}
|
||||
const durationMs = opts.durationMs ?? transcriptSpanMs(entries);
|
||||
return {
|
||||
durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined,
|
||||
toolCount: toolIds.size,
|
||||
added,
|
||||
removed,
|
||||
tokensLabel: formatTokensLabel(tokens),
|
||||
failed: opts.failed || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** One settled run's raw summary inputs, kept so back-to-back runs can coalesce (PAP-362). */
|
||||
export interface TurnSummaryPart {
|
||||
entries: readonly TranscriptEntry[];
|
||||
durationMs?: number;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary for a turn coalesced from several back-to-back runs (PAP-362).
|
||||
* Tool/diff/token counts re-derive from the concatenated transcripts; duration
|
||||
* is the SUM of the per-run durations (each with its own ts-span fallback) —
|
||||
* never the wall span across the idle gap between runs.
|
||||
*/
|
||||
export function buildMergedTurnSummary(
|
||||
parts: readonly TurnSummaryPart[],
|
||||
): TaskChatTurnItem["summary"] {
|
||||
const all: TranscriptEntry[] = [];
|
||||
let durationMs: number | undefined;
|
||||
let failed = false;
|
||||
for (const part of parts) {
|
||||
all.push(...part.entries);
|
||||
if (part.failed) failed = true;
|
||||
const d = part.durationMs ?? transcriptSpanMs(part.entries);
|
||||
if (d != null && Number.isFinite(d)) durationMs = (durationMs ?? 0) + Math.max(0, d);
|
||||
}
|
||||
// durationMs: 0 suppresses the concatenated-span fallback (which would count
|
||||
// the gap between runs); the summed label is applied over it below.
|
||||
const counts = buildTurnSummary(all, { durationMs: 0, failed });
|
||||
return {
|
||||
...counts,
|
||||
durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** One chronological backbone entry (comment / interaction / marker). */
|
||||
export interface ThreadBackboneEntry {
|
||||
/** Chronological sort key in ms (backbone is already sorted by it). */
|
||||
ms: number;
|
||||
/** Stable entry id — the anchor key settled turns attach after. */
|
||||
id: string;
|
||||
item: TaskChatItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the thread body (PAP-367): backbone entries in order, each followed
|
||||
* by the settled turns anchored to it (run → reply-comment linkage), with
|
||||
* comment-less settled turns — stopped runs, or a reply not yet fetched —
|
||||
* interleaved chronologically at their run's start time instead of
|
||||
* bottom-appended under the newest message. Ties go after the backbone entry
|
||||
* (trigger comment first); turns with no known start (startMs = Infinity) keep
|
||||
* the tail slot. `unanchored` must be sorted ascending by startMs.
|
||||
*/
|
||||
export function assembleThreadItems(
|
||||
entries: readonly ThreadBackboneEntry[],
|
||||
turnsByAnchor: ReadonlyMap<string, readonly TaskChatTurnItem[]>,
|
||||
unanchored: readonly { turn: TaskChatTurnItem; startMs: number }[],
|
||||
): TaskChatItem[] {
|
||||
const out: TaskChatItem[] = [];
|
||||
let next = 0;
|
||||
for (const entry of entries) {
|
||||
while (next < unanchored.length && unanchored[next].startMs < entry.ms) {
|
||||
out.push(unanchored[next++].turn);
|
||||
}
|
||||
out.push(entry.item);
|
||||
const following = turnsByAnchor.get(entry.id);
|
||||
if (following) out.push(...following);
|
||||
}
|
||||
while (next < unanchored.length) out.push(unanchored[next++].turn);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Stable id of the description-as-first-bubble item (PAP-375). */
|
||||
export const ISSUE_BRIEF_ITEM_ID = "issue-brief";
|
||||
|
||||
/**
|
||||
* Prepend the issue-brief placeholder (PAP-375) to the fully assembled thread.
|
||||
* Running AFTER assembleThreadItems/coalesce/attach makes the ordering
|
||||
* guarantee structural rather than data-dependent: even an unanchored settled
|
||||
* turn whose startMs predates every backbone entry (F15) lands below the
|
||||
* description bubble.
|
||||
*/
|
||||
export function prependIssueBrief(items: TaskChatItem[], hasBrief: boolean): TaskChatItem[] {
|
||||
if (!hasBrief) return items;
|
||||
return [{ id: ISSUE_BRIEF_ITEM_ID, kind: "brief" }, ...items];
|
||||
}
|
||||
|
||||
/** Per-turn identity + raw summary inputs for coalescing (keyed by turn item id). */
|
||||
export interface SettledTurnMergeMeta {
|
||||
/** Stable agent identity (agentId); empty/unknown turns never merge. */
|
||||
agentKey: string;
|
||||
/** Display name, used to keep another agent's bubble from bridging a merge. */
|
||||
agentName?: string;
|
||||
parts: TurnSummaryPart[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Final assembly pass (PAP-362): merge runs of settled turns from the SAME
|
||||
* agent that arrive back-to-back — two runs replying consecutively — into one
|
||||
* "Worked" row. The agent's own reply bubbles do not break the run (the merged
|
||||
* row lands below the last bubble, in the last run's slot); a human/system
|
||||
* message, an interaction, a marker, or the live in-flight turn does. Child
|
||||
* items concatenate in order, the summary re-derives via buildMergedTurnSummary,
|
||||
* the merged turn keeps the FIRST run's id (stable across re-renders), and
|
||||
* animateFold survives if any merged run was seen live.
|
||||
*/
|
||||
export function coalesceSettledTurns(
|
||||
items: readonly TaskChatItem[],
|
||||
metaById: ReadonlyMap<string, SettledTurnMergeMeta>,
|
||||
): TaskChatItem[] {
|
||||
const out: TaskChatItem[] = [];
|
||||
// Overlay for merged turns' accumulated parts (metaById stays untouched).
|
||||
const mergedMeta = new Map<string, SettledTurnMergeMeta>();
|
||||
const metaFor = (id: string) => mergedMeta.get(id) ?? metaById.get(id);
|
||||
let heldIdx = -1; // index in `out` of the last mergeable settled turn
|
||||
for (const item of items) {
|
||||
if (item.kind === "turn" && item.settled) {
|
||||
const meta = metaFor(item.id);
|
||||
const held = heldIdx >= 0 ? (out[heldIdx] as TaskChatTurnItem) : null;
|
||||
const heldMeta = held ? metaFor(held.id) : undefined;
|
||||
if (held && meta && heldMeta && meta.agentKey && meta.agentKey === heldMeta.agentKey) {
|
||||
out.splice(heldIdx, 1);
|
||||
const parts = [...heldMeta.parts, ...meta.parts];
|
||||
out.push({
|
||||
...held,
|
||||
items: [...held.items, ...item.items],
|
||||
animateFold: held.animateFold || item.animateFold || undefined,
|
||||
summary: buildMergedTurnSummary(parts),
|
||||
});
|
||||
mergedMeta.set(held.id, { ...heldMeta, parts });
|
||||
} else {
|
||||
out.push(item);
|
||||
}
|
||||
heldIdx = meta?.agentKey ? out.length - 1 : -1;
|
||||
continue;
|
||||
}
|
||||
out.push(item);
|
||||
if (item.kind === "message" && item.author === "agent") {
|
||||
// The same agent's reply bubble sits between its runs — keep merging
|
||||
// across it. A different agent's bubble ends the run of turns.
|
||||
const heldMeta = heldIdx >= 0 ? metaFor((out[heldIdx] as TaskChatTurnItem).id) : undefined;
|
||||
if (heldMeta?.agentName && item.authorName && item.authorName !== heldMeta.agentName) {
|
||||
heldIdx = -1;
|
||||
}
|
||||
} else {
|
||||
heldIdx = -1;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Final assembly pass (round 9, after coalesceSettledTurns): a settled turn
|
||||
* that directly follows its own agent's reply bubble folds INTO that bubble as
|
||||
* `attachedTurn` — the "Worked · …" summary renders appended to the bubble's
|
||||
* always-visible timestamp line instead of as a standalone row. The turn must
|
||||
* belong to the same agent as the bubble (metaById identity); turns without
|
||||
* meta, or preceded by anything other than that agent's non-interstitial
|
||||
* bubble, keep the standalone-row fallback.
|
||||
*/
|
||||
export function attachSettledTurns(
|
||||
items: readonly TaskChatItem[],
|
||||
metaById: ReadonlyMap<string, SettledTurnMergeMeta>,
|
||||
): TaskChatItem[] {
|
||||
const out: TaskChatItem[] = [];
|
||||
for (const item of items) {
|
||||
const prev = out[out.length - 1];
|
||||
if (
|
||||
item.kind === "turn" &&
|
||||
item.settled &&
|
||||
prev?.kind === "message" &&
|
||||
prev.author === "agent" &&
|
||||
!prev.interstitial &&
|
||||
!prev.streaming &&
|
||||
prev.attachedTurn == null
|
||||
) {
|
||||
const meta = metaById.get(item.id);
|
||||
const sameAgent =
|
||||
meta != null && (meta.agentName == null || prev.authorName == null || meta.agentName === prev.authorName);
|
||||
if (sameAgent) {
|
||||
out[out.length - 1] = { ...prev, attachedTurn: item };
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for the live status pill from the tail of a transcript.
|
||||
* A tail tool_call yields the taxonomy verb ("Searching", "Running a command")
|
||||
* with tool + target as detail; `toolName` lets the pill show the family icon.
|
||||
* A tail assistant message yields "Responding" plus `selfTalk` — the flattened
|
||||
* text of the interstitial update streamed so far, which takes over the parent
|
||||
* row's line while it streams (PAP-361).
|
||||
*/
|
||||
/** Human-readable label for the live status pill from the tail of a transcript. */
|
||||
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
|
||||
label: string;
|
||||
detail?: string;
|
||||
toolName?: string;
|
||||
selfTalk?: string;
|
||||
} {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.kind === "tool_call") {
|
||||
// A tail update may carry the generic placeholder name or a retitle to
|
||||
// the invocation ("Terminal" → "ls -la"): the call's FIRST real name is
|
||||
// its identity, and a differing later title is the invocation detail.
|
||||
let name = entry.name;
|
||||
let invocation: string | undefined;
|
||||
if (entry.toolUseId) {
|
||||
for (const prev of entries) {
|
||||
if (prev === entry) break;
|
||||
if (
|
||||
prev.kind === "tool_call" &&
|
||||
prev.toolUseId === entry.toolUseId &&
|
||||
!isGenericToolName(prev.name)
|
||||
) {
|
||||
if (!isGenericToolName(entry.name) && entry.name !== prev.name) {
|
||||
invocation = entry.name;
|
||||
}
|
||||
name = prev.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const target =
|
||||
summarizeToolInput(entry.input) ?? (invocation ? clip(invocation, TARGET_MAX) : undefined);
|
||||
const display = toolDisplayName(name);
|
||||
return {
|
||||
label: toolTaxonomy(name).verbLabel,
|
||||
detail: target ? `${display} · ${target}` : display,
|
||||
toolName: name ?? undefined,
|
||||
};
|
||||
return { label: "Working", detail: `Using ${entry.name || "tool"}` };
|
||||
}
|
||||
if (entry.kind === "tool_result") break;
|
||||
if (entry.kind === "assistant") {
|
||||
// Accumulate the trailing message's deltas (the same coalescing run the
|
||||
// parser groups into one item: broken by tool/thinking/diff, not by
|
||||
// status-only entries like stdout).
|
||||
const parts: string[] = [];
|
||||
for (let j = i; j >= 0; j--) {
|
||||
const prev = entries[j];
|
||||
if (prev.kind === "assistant") {
|
||||
if (prev.text) parts.unshift(prev.text);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
prev.kind === "tool_call" ||
|
||||
prev.kind === "tool_result" ||
|
||||
prev.kind === "thinking" ||
|
||||
prev.kind === "diff"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const selfTalk = flattenSelfTalk(parts.join(""));
|
||||
return { label: "Responding", selfTalk: selfTalk || undefined };
|
||||
}
|
||||
if (entry.kind === "assistant") return { label: "Responding" };
|
||||
if (entry.kind === "thinking") return { label: "Thinking" };
|
||||
}
|
||||
return { label: "Running" };
|
||||
|
||||
+7
-297
@@ -209,10 +209,10 @@
|
||||
hardcoded ms / cubic-bezier in components (enforced by a check script).
|
||||
──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Easing primitives (tunable). --motion-ease-out itself is declared by the
|
||||
decision/quicklook motion block further down; the thread shares it. */
|
||||
/* Easing primitives (tunable) */
|
||||
--motion-ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* house signature curve */
|
||||
--motion-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); /* house material curve */
|
||||
--motion-ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
|
||||
/* Duration primitives (tunable) */
|
||||
--motion-duration-instant: 80ms;
|
||||
@@ -235,35 +235,6 @@
|
||||
--motion-plan-check: var(--motion-duration-fast);
|
||||
--motion-count-tween: var(--motion-duration-slow);
|
||||
--motion-streaming-cursor-blink: 1.1s;
|
||||
--motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */
|
||||
--motion-line-scroll: 240ms; /* completed interstitial line sliding out of the 1lh live-line viewport */
|
||||
--motion-interstitial-dwell: 4000ms; /* interstitial update hold: minimum before the next swaps in, and how long a finished update sits before sliding away */
|
||||
--motion-scroll-pill-enter: 200ms; /* scroll-to-latest pill entry */
|
||||
--motion-scroll-pill-exit: 400ms; /* scroll-to-latest pill exit */
|
||||
--motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1);
|
||||
--motion-ease-scroll-pill-out: cubic-bezier(0.7, 0, 0.84, 0);
|
||||
--motion-pane-glide: 320ms; /* properties-pane maximize/restore glide */
|
||||
|
||||
/* Composer mode-chip hues (v5–v7 decisions): agent + plan reuse the task
|
||||
status hues; ask is the v5 blue. Consumed as `--sc` seeds through the
|
||||
.status-chip color-mix recipe. */
|
||||
--tc-mode-agent: var(--status-task-backlog);
|
||||
--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);
|
||||
|
||||
/* Redesigned thread viewport bound: lets the thread own its scroll (auto-
|
||||
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. */
|
||||
--tc-shell-max-w: 60rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -275,7 +246,6 @@
|
||||
--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);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
@@ -536,14 +506,6 @@
|
||||
--motion-approval-pulse: 0ms;
|
||||
--motion-plan-entry-stagger: 0ms;
|
||||
--motion-streaming-cursor-blink: 0ms;
|
||||
--motion-turn-fold: 0ms;
|
||||
--motion-line-scroll: 0ms;
|
||||
/* --motion-interstitial-dwell is deliberately NOT zeroed: it is a pacing
|
||||
hold, not a movement — zeroing it would make the interstitial line churn
|
||||
faster under reduced motion. Swaps themselves snap (line-scroll: 0). */
|
||||
--motion-scroll-pill-enter: 0ms;
|
||||
--motion-scroll-pill-exit: 0ms;
|
||||
--motion-pane-glide: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +538,7 @@
|
||||
0%, 45% { opacity: 1; }
|
||||
55%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.tc-enter-bubble { animation: tc-fade-rise var(--motion-bubble-enter) var(--motion-ease-out-expo) both; }
|
||||
.tc-enter-marker { animation: tc-fade-in var(--motion-marker-enter) var(--motion-ease-out) both; }
|
||||
.tc-enter-tool { animation: tc-fade-rise var(--motion-tool-enter) var(--motion-ease-out-expo) both; }
|
||||
@@ -585,42 +548,6 @@
|
||||
.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; }
|
||||
/* 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
|
||||
slides out the top — transform-only, same motion grammar as tool rows
|
||||
animating in. */
|
||||
.tc-line-scroll { display: block; height: 1lh; overflow: hidden; }
|
||||
.tc-line-scroll-inner { transition: transform var(--motion-line-scroll) var(--motion-ease-standard); will-change: transform; }
|
||||
|
||||
/* Scroll-to-latest pill (stock shadcn MessageScrollerButton motion, tokenized).
|
||||
The pill is centered with translateX(-50%), so the keyframes carry the X
|
||||
offset through the whole transform. */
|
||||
@keyframes tc-pill-in {
|
||||
from { opacity: 0; transform: translate(-50%, 8px) scale(0.9); }
|
||||
to { opacity: 1; transform: translate(-50%, 0) scale(1); }
|
||||
}
|
||||
@keyframes tc-pill-out {
|
||||
from { opacity: 1; transform: translate(-50%, 0) scale(1); }
|
||||
to { opacity: 0; transform: translate(-50%, 8px) scale(0.9); }
|
||||
}
|
||||
.tc-scroll-pill-in { animation: tc-pill-in var(--motion-scroll-pill-enter) var(--motion-ease-scroll-pill-in) both; }
|
||||
.tc-scroll-pill-out { animation: tc-pill-out var(--motion-scroll-pill-exit) var(--motion-ease-scroll-pill-out) both; }
|
||||
|
||||
/* Finished-turn fold: grid-rows 1fr→0fr animates height without measuring;
|
||||
the single grid child must be overflow-hidden for 0fr to clip. */
|
||||
.tc-turn-fold {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
transition: grid-template-rows var(--motion-turn-fold) var(--motion-ease-standard),
|
||||
opacity var(--motion-turn-fold) var(--motion-ease-standard);
|
||||
}
|
||||
.tc-turn-fold[data-folded="true"] { grid-template-rows: 0fr; opacity: 0; }
|
||||
.tc-turn-fold > * { overflow: hidden; min-height: 0; }
|
||||
|
||||
/* Properties-pane maximize/restore glide (left animates between docked and
|
||||
sidebar-flush while the pane is position:fixed). */
|
||||
.tc-pane-glide { transition: left var(--motion-pane-glide) var(--motion-ease-standard); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tc-enter-bubble,
|
||||
@@ -629,15 +556,7 @@
|
||||
.tc-enter-status,
|
||||
.tc-enter-cot-line,
|
||||
.tc-reveal-diff,
|
||||
.tc-scroll-pill-in,
|
||||
.tc-scroll-pill-out,
|
||||
.tc-enter-plan-entry { animation: none; }
|
||||
.tc-turn-fold,
|
||||
.tc-line-scroll-inner,
|
||||
.tc-pane-glide { transition: none; }
|
||||
/* The pill keyframes carry the X-centering; with animation:none restore it. */
|
||||
.tc-scroll-pill-in,
|
||||
.tc-scroll-pill-out { transform: translate(-50%, 0); }
|
||||
.tc-approval { animation: none; box-shadow: 0 0 0 2px hsl(var(--primary) / 0.18); }
|
||||
.tc-cursor { animation: none; opacity: 1; }
|
||||
}
|
||||
@@ -647,129 +566,6 @@
|
||||
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Motion tokens
|
||||
---------------------------------------------------------------------------
|
||||
The first named motion values in the system. Durations and easings were
|
||||
previously written inline at each call site; these give the "snappy, eases
|
||||
in and out" behaviour one definition so surfaces stay in step.
|
||||
|
||||
Exit is deliberately shorter than enter: a thing appearing wants to be
|
||||
followed, a thing leaving just needs to get out of the way.
|
||||
|
||||
The enter curve is the same cubic-bezier the dialog max-width transition
|
||||
already used — decelerating hard at the end, which is what reads as "snappy"
|
||||
rather than "slow start". Consumers: the decision-card disclosure and the
|
||||
task quicklook, both below.
|
||||
--------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--motion-duration-enter: 160ms;
|
||||
--motion-duration-exit: 110ms;
|
||||
--motion-ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--motion-ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
|
||||
/* A crossfading pair must share one duration and one curve. Two elements
|
||||
swapping places both change height, and the container shows the SUM. Give
|
||||
them different timing — the outgoing on a 110ms ease-in against the
|
||||
incoming on a 160ms ease-out — and that sum stops being monotonic: the
|
||||
container bulges or dips mid-swap, which reads as a lurch even though each
|
||||
half is individually smooth. Run both off the same clock and the same
|
||||
curve and the sum collapses to a clean interpolation between the two
|
||||
states, because at every moment progress is identical on both sides. */
|
||||
--motion-duration-swap: 150ms;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Decision-card disclosure (See more / See less)
|
||||
---------------------------------------------------------------------------
|
||||
Radix Collapsible measures the panel and publishes its height, so the card
|
||||
can grow and shrink to a real number instead of snapping. The content
|
||||
unmounts once the exit finishes, so an expanded row's resolver is not left
|
||||
running behind a collapsed card.
|
||||
--------------------------------------------------------------------------- */
|
||||
@keyframes decision-disclosure-open {
|
||||
from {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes decision-disclosure-close {
|
||||
from {
|
||||
height: var(--radix-collapsible-content-height);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
[data-slot="collapsible-content"][data-decision-disclosure] {
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-slot="collapsible-content"][data-decision-disclosure][data-state="open"] {
|
||||
animation: decision-disclosure-open var(--motion-duration-swap) var(--motion-ease-out);
|
||||
}
|
||||
[data-slot="collapsible-content"][data-decision-disclosure][data-state="closed"] {
|
||||
/* Same duration and same curve as the open side — see --motion-duration-swap.
|
||||
The two run against each other, so matched timing is what keeps the card's
|
||||
height a straight interpolation instead of a bulge. */
|
||||
animation: decision-disclosure-close var(--motion-duration-swap) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Task quicklook: a small scale-up out of the trigger, not a fade in place.
|
||||
---------------------------------------------------------------------------
|
||||
The shadcn PopoverContent class string carries `animate-in`, `zoom-in-95`
|
||||
and friends, but those utilities come from the tailwindcss-animate plugin,
|
||||
which this project does not install — there is no `enter`/`exit` keyframe in
|
||||
the build, so they are inert. Rather than take on that dependency for one
|
||||
surface (it would newly animate every dialog, dropdown and tooltip in the
|
||||
app), the quicklook defines its own motion here.
|
||||
|
||||
The scale is deliberately shallow. `--radix-popover-content-transform-origin`
|
||||
pins the origin to the corner nearest the trigger, so the card reads as
|
||||
growing out of the task key that opened it rather than swelling in place.
|
||||
--------------------------------------------------------------------------- */
|
||||
@keyframes quicklook-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes quicklook-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
[data-slot="popover-content"][data-quicklook][data-state="open"] {
|
||||
animation: quicklook-open var(--motion-duration-enter) var(--motion-ease-out);
|
||||
}
|
||||
[data-slot="popover-content"][data-quicklook][data-state="closed"] {
|
||||
animation: quicklook-close var(--motion-duration-exit) var(--motion-ease-in);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="collapsible-content"][data-decision-disclosure][data-state="open"],
|
||||
[data-slot="collapsible-content"][data-decision-disclosure][data-state="closed"],
|
||||
[data-slot="popover-content"][data-quicklook][data-state="open"],
|
||||
[data-slot="popover-content"][data-quicklook][data-state="closed"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dashboard activity row entry motion */
|
||||
@keyframes dashboard-activity-enter {
|
||||
0% {
|
||||
@@ -865,101 +661,12 @@
|
||||
animation: shimmer-text-slide 2.5s linear infinite;
|
||||
}
|
||||
|
||||
.paperclip-thinking-icon {
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
@keyframes paperclip-thinking-draw {
|
||||
0% {
|
||||
stroke-dasharray: 0 85.717;
|
||||
stroke-dashoffset: -85.717;
|
||||
opacity: 1;
|
||||
animation-timing-function: cubic-bezier(0.455, 0.03, 0.515, 0.955);
|
||||
}
|
||||
39.0625% {
|
||||
stroke-dasharray: 85.717 85.717;
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19);
|
||||
}
|
||||
78.125% {
|
||||
stroke-dasharray: 0 85.717;
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
78.225% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 0 85.717;
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.paperclip-thinking-icon-path {
|
||||
animation: paperclip-thinking-draw 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Muted variant (task-chat v7 activity rows): the sweep runs over the
|
||||
muted-foreground tier so live "Thinking…"/"Working…" labels stay subordinate
|
||||
to real content. */
|
||||
.shimmer-text-muted {
|
||||
--shimmer-base: var(--muted-foreground);
|
||||
--shimmer-highlight: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Utilities expected by shadcn base components (ui/attachment.tsx). Declared
|
||||
with @utility so variant forms (e.g. group-data-[state=uploading]:shimmer)
|
||||
compile under Tailwind v4. */
|
||||
@utility shimmer {
|
||||
--shimmer-base: var(--foreground);
|
||||
--shimmer-highlight: color-mix(in oklch, var(--foreground) 35%, transparent);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--shimmer-base) 0%,
|
||||
var(--shimmer-base) 40%,
|
||||
var(--shimmer-highlight) 50%,
|
||||
var(--shimmer-base) 60%,
|
||||
var(--shimmer-base) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer-text-slide 2.5s linear infinite;
|
||||
}
|
||||
|
||||
@utility scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scroll-fade-x {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 12px,
|
||||
black calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer-text {
|
||||
animation: none;
|
||||
-webkit-text-fill-color: unset;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.paperclip-thinking-icon-path {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
stroke-dasharray: 85.717 85.717;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Agent heartbeat capsule motion (PAP-75).
|
||||
@@ -2195,7 +1902,10 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
--gtc-17: 1fr auto; /* Extracted from ui/src/components/ui/card.tsx (grid-cols-[1fr_auto]). */
|
||||
--gtc-18: auto minmax(0,1fr) minmax(12rem,0.65fr); /* Extracted from ui/src/pages/AgentDetail.tsx (grid-cols-[auto_minmax(0,1fr)_minmax(12rem,0.65fr)]). */
|
||||
--gtc-19: 260px minmax(0,1fr); /* Extracted from ui/src/pages/AgentDetail.tsx (grid-cols-[260px_minmax(0,1fr)]). */
|
||||
/* --gtc-20 through --gtc-23 retired with the removed Cloud Upstream page. */
|
||||
--gtc-20: 7rem 8rem 1fr; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[7rem_8rem_1fr]). */
|
||||
--gtc-21: 1.25rem 12rem 1fr; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[1.25rem_12rem_1fr]). */
|
||||
--gtc-22: 8rem 1fr 1fr 8rem; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[8rem_1fr_1fr_8rem]). */
|
||||
--gtc-23: 8rem 1fr auto; /* Extracted from ui/src/pages/CloudUpstream.tsx (grid-cols-[8rem_1fr_auto]). */
|
||||
--gtc-24: minmax(0,1.5fr) 120px 120px 180px; /* Extracted from ui/src/pages/CompanyAccess.tsx (grid-cols-[minmax(0,1.5fr)_120px_120px_180px]). */
|
||||
--gtc-25: 19rem minmax(0,1fr); /* Extracted from ui/src/pages/CompanyExport.tsx (grid-cols-[19rem_minmax(0,1fr)]). */
|
||||
--gtc-26: 7rem minmax(0,1fr); /* Extracted from ui/src/pages/CompanySkills.tsx (grid-cols-[7rem_minmax(0,1fr)]). */
|
||||
|
||||
+155
-361
@@ -26,7 +26,7 @@ import { useToastActions } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, formatAssigneeUserLabel, formatUserLabel, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
|
||||
import { extractIssueTimelineEvents, extractIssueWorkModeChanges } from "../lib/issue-timeline-events";
|
||||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
@@ -66,7 +66,6 @@ import {
|
||||
createOptimisticIssueComment,
|
||||
flattenIssueCommentPages,
|
||||
getNextIssueCommentPageParam,
|
||||
ISSUE_COMMENT_PAGE_SIZE,
|
||||
isQueuedIssueComment,
|
||||
loadRemainingIssueCommentPages,
|
||||
matchesIssueRef,
|
||||
@@ -83,7 +82,6 @@ import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { liveBlueBadge } from "../lib/status-colors";
|
||||
import { ApprovalCard } from "../components/ApprovalCard";
|
||||
import { ProjectTile } from "../components/ProjectTile";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import {
|
||||
IssueChatThread,
|
||||
@@ -91,7 +89,6 @@ import {
|
||||
type IssueChatRunFinalizationAction,
|
||||
} from "../components/IssueChatThread";
|
||||
import { TaskChatThread } from "../components/TaskChatThread";
|
||||
import type { TaskChatIssueBrief } from "../components/task-chat/TaskChatDescriptionBubble";
|
||||
import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
|
||||
import { workModeMetaFor } from "../lib/work-mode-meta";
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
@@ -111,9 +108,6 @@ import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
|
||||
import { IssueFieldChangeReceipt } from "../components/IssueFieldChangeReceipt";
|
||||
import { IssueWriteDenialNotice } from "../components/IssueWriteDenialNotice";
|
||||
import { issueWriteDenialForActivity } from "../lib/issue-write-denial-activity";
|
||||
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
|
||||
import {
|
||||
IssueMonitorBanner,
|
||||
@@ -135,7 +129,6 @@ import { ArtifactFileChip } from "../components/ArtifactFileChip";
|
||||
import { ScrollToBottom } from "../components/ScrollToBottom";
|
||||
import { StatusIcon } from "../components/StatusIcon";
|
||||
import { PriorityIcon } from "../components/PriorityIcon";
|
||||
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
|
||||
import { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
@@ -183,6 +176,7 @@ import {
|
||||
ScanEye,
|
||||
Flag,
|
||||
FileCode2,
|
||||
Hexagon,
|
||||
ListTree,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
@@ -257,6 +251,7 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
|
||||
};
|
||||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
const ISSUE_COMMENT_PAGE_SIZE = 50;
|
||||
const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3;
|
||||
const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10;
|
||||
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
||||
@@ -690,16 +685,9 @@ function IssueDetailLoadingState({
|
||||
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
|
||||
}) {
|
||||
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
|
||||
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? "mx-auto w-full max-w-(--tc-shell-max-w) space-y-6"
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
|
||||
@@ -707,8 +695,7 @@ function IssueDetailLoadingState({
|
||||
{headerSeed ? (
|
||||
<>
|
||||
<StatusIcon status={headerSeed.status} blockerAttention={headerSeed.blockerAttention} />
|
||||
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */}
|
||||
{SHOW_TASK_PRIORITY_UI && <PriorityIcon priority={headerSeed.priority} />}
|
||||
<PriorityIcon priority={headerSeed.priority} />
|
||||
{identifier ? (
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
|
||||
) : null}
|
||||
@@ -721,18 +708,16 @@ function IssueDetailLoadingState({
|
||||
Routine
|
||||
</Badge>
|
||||
) : null}
|
||||
{/* Seeded header — same anatomy as the resolved one below, so the
|
||||
eyebrow does not change shape when the real issue arrives. */}
|
||||
{headerSeed.projectId ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground rounded px-1 -mx-1 py-0.5 min-w-0">
|
||||
<ProjectTile size="xs" />
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{headerSeed.projectName ?? headerSeed.projectId.slice(0, 8)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
||||
<ProjectTile size="xs" />
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
No project
|
||||
</span>
|
||||
)}
|
||||
@@ -877,8 +862,6 @@ type IssueDetailChatTabProps = {
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
issueStatus: Issue["status"];
|
||||
/** Marks cross-issue agent comments in the thread (the open cross-task write design (attribution)). */
|
||||
issueAssigneeAgentId: Issue["assigneeAgentId"];
|
||||
issueWorkMode: IssueWorkMode;
|
||||
executionRunId: string | null;
|
||||
blockedBy: Issue["blockedBy"];
|
||||
@@ -903,7 +886,6 @@ type IssueDetailChatTabProps = {
|
||||
title?: string | null;
|
||||
} | null;
|
||||
comments: IssueDetailComment[];
|
||||
commentsInitialLoading?: boolean;
|
||||
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
|
||||
interactions: IssueThreadInteraction[];
|
||||
hasOlderComments: boolean;
|
||||
@@ -914,17 +896,6 @@ 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;
|
||||
/**
|
||||
* The task description rendered as the requester's first chat bubble in the
|
||||
* redesigned thread (PAP-375). Ignored by the legacy thread.
|
||||
*/
|
||||
issueBrief?: TaskChatIssueBrief;
|
||||
footer?: ReactNode;
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
||||
@@ -985,7 +956,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
projectId,
|
||||
issueWorkMode,
|
||||
issueStatus,
|
||||
issueAssigneeAgentId,
|
||||
executionRunId,
|
||||
blockedBy,
|
||||
liveIssueIds,
|
||||
@@ -1005,7 +975,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
canFalsePositiveRecoveryAction,
|
||||
legacyRecoverySourceIssue,
|
||||
comments,
|
||||
commentsInitialLoading = false,
|
||||
locallyQueuedCommentRunIds,
|
||||
interactions,
|
||||
hasOlderComments,
|
||||
@@ -1015,8 +984,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
onWorkModeChange,
|
||||
composerRef,
|
||||
composerAccessory,
|
||||
threadHeader,
|
||||
issueBrief,
|
||||
footer,
|
||||
feedbackVotes,
|
||||
feedbackDataSharingPreference,
|
||||
@@ -1195,45 +1162,25 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
() => extractIssueTimelineEvents(resolvedActivity),
|
||||
[resolvedActivity],
|
||||
);
|
||||
const workModeChanges = useMemo(
|
||||
() => extractIssueWorkModeChanges(resolvedActivity),
|
||||
[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={taskChatRedesignEnabled ? "flex min-h-0 flex-1 flex-col" : "space-y-3"}>
|
||||
{/* 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}
|
||||
{commentsInitialLoading && commentsWithRunMeta.length === 0 && interactions.length === 0 ? (
|
||||
<IssueChatSkeleton />
|
||||
) : (
|
||||
<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}
|
||||
<ThreadComponent
|
||||
composerRef={composerRef}
|
||||
composerAccessory={composerAccessory}
|
||||
threadHeader={
|
||||
taskChatRedesignEnabled && (threadHeader || loadOlderButton) ? (
|
||||
<>
|
||||
{threadHeader}
|
||||
{loadOlderButton}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
issueBrief={issueBrief}
|
||||
comments={commentsWithRunMeta}
|
||||
interactions={interactions}
|
||||
feedbackVotes={feedbackVotes}
|
||||
@@ -1241,7 +1188,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
linkedRuns={timelineRuns}
|
||||
timelineEvents={timelineEvents}
|
||||
workModeChanges={workModeChanges}
|
||||
liveRuns={resolvedLiveRuns}
|
||||
activeRun={resolvedActiveRun}
|
||||
issueId={issueId}
|
||||
@@ -1265,7 +1211,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
issueStatus={issueStatus}
|
||||
issueAssigneeAgentId={issueAssigneeAgentId}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
@@ -1315,7 +1260,6 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
externalReferences={externalReferences}
|
||||
linkCaseReferences={linkCaseReferences}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1547,24 +1491,6 @@ function IssueDetailActivityTab({
|
||||
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
|
||||
</div>
|
||||
<IssueReferenceActivitySummary event={evt} />
|
||||
{/* Field-level who/what/why receipt for agent and board edits alike. */}
|
||||
<IssueFieldChangeReceipt
|
||||
event={evt}
|
||||
resolveAgentLabel={(agentId) => agentMap.get(agentId)?.name ?? null}
|
||||
resolveUserLabel={(userId) => userProfileMap.get(userId)?.label ?? null}
|
||||
/>
|
||||
{/* A refused write explains itself here, not just in the API error. */}
|
||||
{(() => {
|
||||
const denial = issueWriteDenialForActivity(evt.action, evt.details, {
|
||||
actorLabel: evt.agentId ? agentMap.get(evt.agentId)?.name ?? null : null,
|
||||
responsibleUserName: evt.responsibleUserId
|
||||
? userProfileMap.get(evt.responsibleUserId)?.label ?? null
|
||||
: null,
|
||||
});
|
||||
return denial ? (
|
||||
<IssueWriteDenialNotice code={denial.code} context={denial.context} />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -1604,18 +1530,6 @@ function IssueDetailActivityTab({
|
||||
export function IssueDetail() {
|
||||
const { issueId } = useParams<{ issueId: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
// Task Chat Redesign (flag: enableTaskChatRedesign): with the flag ON the
|
||||
// thread owns the center column — the legacy title/description block,
|
||||
// sub-tasks table, plan decompositions and Documents section are gated off
|
||||
// (plan lives in the properties-pane Plan tab). Flag OFF renders the legacy
|
||||
// page byte-identically.
|
||||
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
|
||||
// Flag ON the page wrapper spans the full center pane so the thread's scroll
|
||||
// viewport (and its scrollbar) reaches the properties-pane border; every
|
||||
// non-thread section re-centers itself at the 60rem shell cap instead.
|
||||
const shellSectionClass = taskChatShellEnabled
|
||||
? "mx-auto w-full max-w-(--tc-shell-max-w)"
|
||||
: undefined;
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
|
||||
@@ -1630,9 +1544,6 @@ 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;
|
||||
@@ -1980,10 +1891,7 @@ export function IssueDetail() {
|
||||
},
|
||||
[issue?.id, rawChildIssues],
|
||||
);
|
||||
const liveIssueIds = useMemo(
|
||||
() => collectLiveIssueIds(companyLiveRuns, issue ? [issue, ...childIssues] : childIssues),
|
||||
[childIssues, companyLiveRuns, issue],
|
||||
);
|
||||
const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]);
|
||||
const issuePanelKey = useMemo(
|
||||
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
|
||||
[childIssues, issue],
|
||||
@@ -2047,7 +1955,6 @@ export function IssueDetail() {
|
||||
[comments, optimisticComments],
|
||||
);
|
||||
const breadcrumbTitle = issue?.title ?? issueId ?? "Task";
|
||||
const breadcrumbIdentifier = issue?.identifier ?? issueHeaderSeed?.identifier ?? undefined;
|
||||
const breadcrumbStatus = issue?.status;
|
||||
const breadcrumbBlockerAttention = issue?.blockerAttention;
|
||||
// Stable identity for the breadcrumb status glyph. The glyph's shape/colour
|
||||
@@ -2225,7 +2132,7 @@ export function IssueDetail() {
|
||||
|
||||
return { previousDetailQueries, previousList, selectedCompanyId };
|
||||
},
|
||||
onSuccess: ({ comment: _comment, changes: _changes, blockedByIssueIds: _blockedByIssueIds, ...nextIssue }) => {
|
||||
onSuccess: ({ comment: _comment, ...nextIssue }) => {
|
||||
const issueRefs = new Set<string>([issueId!, nextIssue.id]);
|
||||
if (nextIssue.identifier) issueRefs.add(nextIssue.identifier);
|
||||
mergeIssueResponseIntoCaches(issueRefs, nextIssue);
|
||||
@@ -3215,14 +3122,12 @@ export function IssueDetail() {
|
||||
// The status glyph (leading) already conveys in-progress/live state;
|
||||
// no redundant 🔵 emoji prefix on the title.
|
||||
label: breadcrumbTitle,
|
||||
identifier: breadcrumbIdentifier,
|
||||
leading: breadcrumbStatusLeading,
|
||||
leadingKey: breadcrumbStatusKey,
|
||||
},
|
||||
]);
|
||||
}, [
|
||||
breadcrumbTitle,
|
||||
breadcrumbIdentifier,
|
||||
hasLiveRuns,
|
||||
setBreadcrumbs,
|
||||
sourceBreadcrumb.href,
|
||||
@@ -4160,13 +4065,12 @@ export function IssueDetail() {
|
||||
</>
|
||||
);
|
||||
|
||||
// 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)}>
|
||||
return (
|
||||
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
|
||||
<div className="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">
|
||||
{[...ancestors].reverse().map((ancestor, i) => (
|
||||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
@@ -4189,10 +4093,91 @@ 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>
|
||||
);
|
||||
)}
|
||||
|
||||
const issueHeaderBlock = (
|
||||
<div className={cn("space-y-3", shellSectionClass)}>
|
||||
{issue.hiddenAt && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<EyeOff className="h-4 w-4 shrink-0" />
|
||||
This task is hidden
|
||||
</div>
|
||||
)}
|
||||
{activePauseHold && (
|
||||
<div className="rounded-md border border-amber-500/35 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200">
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
@@ -4200,13 +4185,10 @@ export function IssueDetail() {
|
||||
blockerAttention={issue.blockerAttention}
|
||||
onChange={(status) => updateIssue.mutate({ status })}
|
||||
/>
|
||||
{/* PAP-411: priority UI hidden behind SHOW_TASK_PRIORITY_UI. */}
|
||||
{SHOW_TASK_PRIORITY_UI && (
|
||||
<PriorityIcon
|
||||
priority={issue.priority}
|
||||
onChange={(priority) => updateIssue.mutate({ priority })}
|
||||
/>
|
||||
)}
|
||||
<PriorityIcon
|
||||
priority={issue.priority}
|
||||
onChange={(priority) => updateIssue.mutate({ priority })}
|
||||
/>
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{issue.identifier ?? issue.id.slice(0, 8)}</span>
|
||||
|
||||
{hasLiveRuns && (
|
||||
@@ -4254,11 +4236,7 @@ export function IssueDetail() {
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{/* Task Chat Redesign: no mode chip in the header — mode is a
|
||||
per-request choice made in the composer, and each agent reply
|
||||
carries its own mode chip; a header chip would misread as a
|
||||
task-global setting. Flag OFF keeps the legacy badge. */}
|
||||
{!taskChatShellEnabled && (issue.workMode === "ask" || issue.workMode === "planning") ? (() => {
|
||||
{issue.workMode === "ask" || issue.workMode === "planning" ? (() => {
|
||||
const workModeMeta = workModeMetaFor(issue.workMode);
|
||||
const WorkModeIcon = workModeMeta.icon;
|
||||
return (
|
||||
@@ -4283,24 +4261,17 @@ export function IssueDetail() {
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
{/* Project reads as a tile plus a name, matching the project rows in
|
||||
the sidebar and the Projects list rather than a bare outline
|
||||
glyph. The tile stays neutral here on purpose: the eyebrow already
|
||||
carries the status glyph's colour, and a second tinted swatch
|
||||
beside it competes with the one signal that means something.
|
||||
Project colour still identifies the project on project-native
|
||||
surfaces. */}
|
||||
{issue.projectId ? (
|
||||
<Link
|
||||
to={`/projects/${issue.projectId}`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded px-1 -mx-1 py-0.5 min-w-0"
|
||||
>
|
||||
<ProjectTile size="xs" icon={resolvedProject?.icon ?? issue.project?.icon} />
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{resolvedProject?.name ?? issue.project?.name ?? issue.projectId.slice(0, 8)}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground opacity-50 px-1 -mx-1 py-0.5">
|
||||
<ProjectTile size="xs" />
|
||||
<Hexagon className="h-3 w-3 shrink-0" />
|
||||
No project
|
||||
</span>
|
||||
)}
|
||||
@@ -4527,7 +4498,7 @@ export function IssueDetail() {
|
||||
value={issue.title}
|
||||
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||
as="h2"
|
||||
className={taskChatShellEnabled ? "text-base font-semibold" : "text-xl font-bold"}
|
||||
className="text-xl font-bold"
|
||||
/>
|
||||
|
||||
<IssueMonitorBanner
|
||||
@@ -4536,31 +4507,26 @@ export function IssueDetail() {
|
||||
checkingNow={checkIssueMonitorNow.isPending}
|
||||
/>
|
||||
|
||||
{taskChatShellEnabled ? null : (
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||
as="p"
|
||||
className="text-sm leading-7 text-foreground"
|
||||
placeholder="Add a description..."
|
||||
multiline
|
||||
foldable
|
||||
mentions={mentionOptions}
|
||||
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
onDropFile={async (file) => {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<InlineEditor
|
||||
value={issue.description ?? ""}
|
||||
onSave={(description) => updateIssue.mutateAsync({ description })}
|
||||
as="p"
|
||||
className="text-sm leading-7 text-foreground"
|
||||
placeholder="Add a description..."
|
||||
multiline
|
||||
foldable
|
||||
mentions={mentionOptions}
|
||||
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
|
||||
imageUploadHandler={async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
}}
|
||||
onDropFile={async (file) => {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const pluginOutletsBlock = (
|
||||
<>
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["toolbarButton", "contextMenuItem"]}
|
||||
entityType="issue"
|
||||
@@ -4570,7 +4536,7 @@ export function IssueDetail() {
|
||||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className={cn("flex flex-wrap gap-2", shellSectionClass)}
|
||||
className="flex flex-wrap gap-2"
|
||||
itemClassName="inline-flex"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
@@ -4584,7 +4550,7 @@ export function IssueDetail() {
|
||||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className={cn("flex flex-wrap gap-2", shellSectionClass)}
|
||||
className="flex flex-wrap gap-2"
|
||||
itemClassName="inline-flex"
|
||||
/>
|
||||
|
||||
@@ -4597,126 +4563,12 @@ export function IssueDetail() {
|
||||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className={cn("space-y-3", shellSectionClass)}
|
||||
className="space-y-3"
|
||||
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
|
||||
? isMobile
|
||||
? // Mobile shell scrolls the DOCUMENT (main is overflow-visible,
|
||||
// auto height) — the thread renders in normal flow (PAP-360).
|
||||
"flex w-full flex-col gap-6"
|
||||
: // Fill main exactly so the outer page never scrolls — the
|
||||
// thread's own viewport is the only scroll surface.
|
||||
"flex h-full min-h-0 w-full flex-col gap-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 ? (
|
||||
{showRichSubIssuesSection ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Sub-tasks</h3>
|
||||
@@ -4751,7 +4603,7 @@ export function IssueDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!taskChatShellEnabled && showPlanDecompositionsSection ? (
|
||||
{showPlanDecompositionsSection ? (
|
||||
<IssuePlanDecompositionsSection
|
||||
issueId={issue.id}
|
||||
issueIdentifier={issue.identifier}
|
||||
@@ -4759,9 +4611,6 @@ export function IssueDetail() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Flag ON: attachments/work products/workspace live in the properties
|
||||
pane (Artifacts tab) — the center column belongs to the thread. */}
|
||||
{taskChatShellEnabled ? null : (
|
||||
<IssueDocumentsSection
|
||||
issue={issue}
|
||||
canDeleteDocuments={Boolean(session?.user?.id)}
|
||||
@@ -4789,9 +4638,7 @@ export function IssueDetail() {
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
{taskChatShellEnabled ? null : (
|
||||
<IssueOutputSection
|
||||
workProducts={workProducts}
|
||||
onMediaClick={(item) => {
|
||||
@@ -4806,9 +4653,8 @@ export function IssueDetail() {
|
||||
setGalleryOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{taskChatShellEnabled ? null : attachmentsInitialLoading ? (
|
||||
{attachmentsInitialLoading ? (
|
||||
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
|
||||
) : hasAttachments ? (
|
||||
<IssueAttachmentsSection
|
||||
@@ -4846,7 +4692,6 @@ export function IssueDetail() {
|
||||
onOpenChange={setGalleryOpen}
|
||||
/>
|
||||
|
||||
{taskChatShellEnabled ? null : (
|
||||
<IssueWorkspaceCard
|
||||
issue={issue}
|
||||
project={resolvedProject}
|
||||
@@ -4854,9 +4699,8 @@ export function IssueDetail() {
|
||||
onBrowseFiles={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
|
||||
onOpenFileByPath={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!taskChatShellEnabled && fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
|
||||
{fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
|
||||
const workProductsWithFileRefs = issue.workProducts
|
||||
.map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) }))
|
||||
.filter(({ fileRef }) => fileRef !== null);
|
||||
@@ -4881,17 +4725,10 @@ export function IssueDetail() {
|
||||
);
|
||||
})()}
|
||||
|
||||
{taskChatShellEnabled ? null : <Separator className={shellSectionClass} />}
|
||||
<Separator />
|
||||
|
||||
<Tabs
|
||||
value={resolvedDetailTab}
|
||||
onValueChange={setDetailTab}
|
||||
className={taskChatShellEnabled ? (isMobile ? undefined : "min-h-0 flex-1") : "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)}>
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
|
||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||
<TabsTrigger value="chat" className="gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Chat
|
||||
@@ -4910,56 +4747,14 @@ 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
|
||||
? isMobile
|
||||
? "-mx-4"
|
||||
: "-mx-4 md:-mx-6 flex min-h-0 flex-col"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{resolvedDetailTab === "chat" ? (
|
||||
<TabsContent value="chat">
|
||||
{detailTab === "chat" ? (
|
||||
<IssueDetailChatTab
|
||||
threadHeader={taskChatThreadHeader}
|
||||
issueBrief={
|
||||
taskChatShellEnabled
|
||||
? {
|
||||
description: issue.description ?? "",
|
||||
author: issue.createdByAgentId ? "agent" : "human",
|
||||
authorName: issue.createdByAgentId
|
||||
? agentMap.get(issue.createdByAgentId)?.name ?? "Agent"
|
||||
: undefined,
|
||||
agentIcon: issue.createdByAgentId
|
||||
? agentMap.get(issue.createdByAgentId)?.icon
|
||||
: undefined,
|
||||
createdAt: issue.createdAt,
|
||||
onSave: (description) => updateIssue.mutateAsync({ description }),
|
||||
mentions: mentionOptions,
|
||||
externalReferences: externalObjectsState.isEnabled
|
||||
? externalObjectsState.markdownReferences
|
||||
: undefined,
|
||||
imageUploadHandler: async (file) => {
|
||||
const attachment = await uploadAttachment.mutateAsync(file);
|
||||
return attachment.contentPath;
|
||||
},
|
||||
onDropFile: async (file) => {
|
||||
await uploadAttachment.mutateAsync(file);
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
issueId={issue.id}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId ?? null}
|
||||
issueStatus={issue.status}
|
||||
issueAssigneeAgentId={issue.assigneeAgentId}
|
||||
issueWorkMode={issue.workMode ?? "standard"}
|
||||
executionRunId={issue.executionRunId ?? null}
|
||||
blockedBy={issue.blockedBy ?? []}
|
||||
@@ -4980,7 +4775,6 @@ export function IssueDetail() {
|
||||
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
|
||||
legacyRecoverySourceIssue={legacyRecoverySourceIssue}
|
||||
comments={threadComments}
|
||||
commentsInitialLoading={commentsLoading}
|
||||
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
|
||||
interactions={interactions}
|
||||
hasOlderComments={hasOlderComments}
|
||||
@@ -4998,7 +4792,7 @@ export function IssueDetail() {
|
||||
) : null
|
||||
}
|
||||
footer={
|
||||
!taskChatShellEnabled && siblingNavigation ? (
|
||||
siblingNavigation ? (
|
||||
<IssueSiblingNavigation
|
||||
navigation={siblingNavigation}
|
||||
linkState={resolvedIssueDetailState ?? location.state}
|
||||
@@ -5055,7 +4849,7 @@ export function IssueDetail() {
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity" className={shellSectionClass}>
|
||||
<TabsContent value="activity">
|
||||
{detailTab === "activity" ? (
|
||||
<IssueDetailActivityTab
|
||||
issue={issue}
|
||||
@@ -5077,7 +4871,7 @@ export function IssueDetail() {
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="related-work" className={shellSectionClass}>
|
||||
<TabsContent value="related-work">
|
||||
<IssueRelatedWorkPanel
|
||||
relatedWork={issue.relatedWork}
|
||||
externalObjectsEnabled={externalObjectsState.isEnabled}
|
||||
@@ -5089,7 +4883,7 @@ export function IssueDetail() {
|
||||
</TabsContent>
|
||||
|
||||
{activePluginTab && (
|
||||
<TabsContent value={activePluginTab.value} className={shellSectionClass}>
|
||||
<TabsContent value={activePluginTab.value}>
|
||||
<PluginSlotMount
|
||||
slot={activePluginTab.slot}
|
||||
context={{
|
||||
|
||||
@@ -8,65 +8,10 @@ import {
|
||||
import { buildScenario } from "@/components/task-chat/task-chat-fixtures";
|
||||
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
|
||||
import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView";
|
||||
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
|
||||
import { TweakPanel } from "@/components/task-chat/TweakPanel";
|
||||
import type {
|
||||
TaskChatItem,
|
||||
TaskChatMessageItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
|
||||
|
||||
/**
|
||||
* Demo binding for the agent-bubble copy · 👍 · 👎 cluster (PAP-413). The live
|
||||
* thread wires these to the feedback-vote API; here the votes no-op so the
|
||||
* harness can show the footer without a control plane.
|
||||
*/
|
||||
function labMessageActions(item: TaskChatMessageItem) {
|
||||
if (item.author !== "agent" || item.optimistic) return null;
|
||||
return (
|
||||
<TaskChatBubbleActions
|
||||
copyText={item.text}
|
||||
feedback={{
|
||||
activeVote: null,
|
||||
sharingPreference: "allowed",
|
||||
termsUrl: null,
|
||||
onVote: async () => {},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Replay ticks of selfTalk gap between scripted interstitial updates (~0.7s at 1×). */
|
||||
const SELF_TALK_GAP_TICKS = 30;
|
||||
|
||||
/** A fixture's scripted interstitial updates: blank-line-separated segments. */
|
||||
function selfTalkSegments(script: string): string[] {
|
||||
return script
|
||||
.split(/\n{2,}/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selfTalk visible at replay tick `tick`: each segment streams one char
|
||||
* per tick as its own update, with a gap (selfTalk undefined — the real
|
||||
* adapter clears it between assistant messages) before the next; after the
|
||||
* last segment ends, selfTalk stays undefined so the pill's hold-until-
|
||||
* superseded behavior (PAP-368) is visible in the lab.
|
||||
*/
|
||||
function selfTalkAtTick(segments: string[], tick: number): string | undefined {
|
||||
let t = tick;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (t <= seg.length) return seg.slice(0, t) || undefined;
|
||||
t -= seg.length;
|
||||
if (i === segments.length - 1) return undefined;
|
||||
if (t <= SELF_TALK_GAP_TICKS) return undefined;
|
||||
t -= SELF_TALK_GAP_TICKS;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Progressively reveal any streaming message / live-line interstitial text at `speed`. */
|
||||
/** Progressively reveal any streaming message/thinking text at `speed`. */
|
||||
function useStreamingReplay(
|
||||
baseItems: TaskChatItem[],
|
||||
speed: number,
|
||||
@@ -74,23 +19,15 @@ function useStreamingReplay(
|
||||
): TaskChatItem[] {
|
||||
const [chars, setChars] = useState<number>(Number.MAX_SAFE_INTEGER);
|
||||
const streamingIndex = baseItems.findIndex(
|
||||
(i) =>
|
||||
(i.kind === "message" && i.streaming) ||
|
||||
(i.kind === "turn" && !i.settled && i.liveStatus?.selfTalk != null),
|
||||
(i) => (i.kind === "message" && i.streaming) || (i.kind === "thinking" && i.streaming),
|
||||
);
|
||||
const streamingItem = baseItems[streamingIndex];
|
||||
const isInterstitial = streamingItem?.kind === "turn";
|
||||
const fullText = useMemo(() => {
|
||||
const it = baseItems[streamingIndex];
|
||||
if (!it) return "";
|
||||
if (it.kind === "message") return it.text;
|
||||
if (it.kind === "turn") return it.liveStatus?.selfTalk ?? "";
|
||||
if (it.kind === "thinking") return it.lines.join("\n");
|
||||
return "";
|
||||
}, [baseItems, streamingIndex]);
|
||||
const segments = useMemo(
|
||||
() => (isInterstitial ? selfTalkSegments(fullText) : []),
|
||||
[isInterstitial, fullText],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingIndex < 0) {
|
||||
@@ -99,21 +36,14 @@ function useStreamingReplay(
|
||||
}
|
||||
setChars(0);
|
||||
let n = 0;
|
||||
// One extra tick past the last segment lands on the trailing undefined
|
||||
// (message ended) state.
|
||||
const stopAt = isInterstitial
|
||||
? segments.reduce((a, s) => a + s.length, 0) +
|
||||
SELF_TALK_GAP_TICKS * Math.max(0, segments.length - 1) +
|
||||
1
|
||||
: fullText.length;
|
||||
const baseIntervalMs = 24;
|
||||
const interval = window.setInterval(() => {
|
||||
n += 1;
|
||||
setChars(n);
|
||||
if (n >= stopAt) window.clearInterval(interval);
|
||||
if (n >= fullText.length) window.clearInterval(interval);
|
||||
}, Math.max(4, baseIntervalMs / speed));
|
||||
return () => window.clearInterval(interval);
|
||||
}, [streamingIndex, fullText, isInterstitial, segments, speed, playToken]);
|
||||
}, [streamingIndex, fullText, speed, playToken]);
|
||||
|
||||
if (streamingIndex < 0) return baseItems;
|
||||
return baseItems.map((it, idx) => {
|
||||
@@ -121,12 +51,7 @@ function useStreamingReplay(
|
||||
const shown = fullText.slice(0, chars);
|
||||
const done = chars >= fullText.length;
|
||||
if (it.kind === "message") return { ...it, text: shown, streaming: !done };
|
||||
// Parent-row interstitial (PAP-361/368): reveal the scripted updates
|
||||
// segment by segment; the pill holds each finished one until the next
|
||||
// swaps in.
|
||||
if (it.kind === "turn" && it.liveStatus) {
|
||||
return { ...it, liveStatus: { ...it.liveStatus, selfTalk: selfTalkAtTick(segments, chars) } };
|
||||
}
|
||||
if (it.kind === "thinking") return { ...it, lines: shown.split("\n"), streaming: !done };
|
||||
return it;
|
||||
});
|
||||
}
|
||||
@@ -227,14 +152,14 @@ export function TaskChatLab() {
|
||||
<span className="ml-auto font-mono text-(length:--text-micro) text-muted-foreground">{meta.protocol}</span>
|
||||
</div>
|
||||
|
||||
<div ref={targetRef} className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-stage">
|
||||
<div ref={targetRef} className="min-h-0 flex-1" data-testid="task-chat-stage">
|
||||
{scenario.surface === "plan" && scenario.plan ? (
|
||||
<div className="mx-auto max-w-2xl px-4">
|
||||
<TaskChatPlanView plan={scenario.plan} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col">
|
||||
<TaskChatThreadView items={items} renderMessageActions={labMessageActions} />
|
||||
<div className="mx-auto h-full max-w-2xl">
|
||||
<TaskChatThreadView items={items} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user