diff --git a/DESIGN.md b/DESIGN.md index 6eaa1e12cf..128fc6fafb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -56,3 +56,29 @@ No visual redesign, no new colors or typefaces, no layout restructuring, no new See `doc/design/PRIOR-ART.md` — a previous audit pass (PAP-280/283/284, on the `PAP-282-playground` branch, NOT on master) found that of ~220 hardcoded drift sites, only 6 were exact-value-mappable to existing tokens; expect the verbatim extraction to mint many new tokens that the human scale-collapse step later merges. It also drafted usage rules (radius tiers, CTA tiers, named type styles) that are good candidates for the post-audit scale decision. How-to guide for day-to-day UI changes: see `doc/design/CHANGING-THE-UI.md`. + +## Motion tokens (Task Chat Redesign) + +The redesigned task thread (flag `enableTaskChatRedesign`) is the first surface to +tokenize motion. Principles — reasoning only; values live in `ui/src/index.css`: + +- **One home, and it is `:root`, not `@theme inline`.** `@theme inline` bakes literals + at build time, so a value placed there cannot be moved at runtime. The dev tweak panel + tunes motion by writing CSS custom properties live, so every motion token must resolve + at runtime — hence `:root`. +- **Two tiers.** Primitives (`--motion-duration-*`, `--motion-ease-*`) express the app's + baseline motion feel; state/component-scoped tokens (`--motion--*`) reference the + primitives so the whole thread retunes from a few knobs. Scoped tokens exist so the + tweak panel can group controls by the state they affect. +- **Reuse the house curves.** New easing defaults point at the two curves already used + across the app rather than inventing a third feel. +- **No hardcoded timing in components.** Durations, easings, delays, and staggers used by + the redesigned thread must reference these tokens; a check script rejects raw `ms` / + `cubic-bezier` values outside `ui/src/index.css`. This discipline is what makes the + tweak panel structurally possible. +- **Values are placeholders.** The committed numbers are sensible starting points, tuned + live by a human and pasted back from the tweak panel's export — never treated as final + during the baseline build. +- **Reduced motion is honored at the token layer.** A `prefers-reduced-motion: reduce` + block collapses the duration/stagger tokens to zero, cascading to every scoped token, + in addition to each animation's own component-level guard. diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index f62972089a..29b584c391 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -2333,7 +2333,17 @@ async function emitAcpxLog(ctx: AdapterExecutionContext, payload: Record, +) { if (event.type === "text_delta") { await emitAcpxLog(ctx, { type: "acpx.text_delta", @@ -2346,9 +2356,21 @@ async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeE if (event.type === "tool_call") { const eventRecord = event as Record; const toolInput = eventRecord.input; + let name = event.title ?? "acp_tool"; + const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : ""; + if (toolTitles && toolCallId) { + if (event.title && event.title !== GENERIC_ACP_TOOL_TITLE) { + // First real title is the call's identity; later retitles (ACP swaps + // in the invocation, e.g. "Terminal" → "ls -la") keep their own line + // but don't become the remembered name. + if (!toolTitles.has(toolCallId)) toolTitles.set(toolCallId, event.title); + } else { + name = toolTitles.get(toolCallId) ?? name; + } + } await emitAcpxLog(ctx, { type: "acpx.tool_call", - name: event.title ?? "acp_tool", + name, toolCallId: event.toolCallId, status: event.status, text: event.text, @@ -3294,13 +3316,14 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { cancelActiveTurn = async (reason: string) => { await turn.cancel({ reason }); }; + const toolTitles = new Map(); for await (const event of turn.events) { if (event.type === "text_delta") textParts.push(event.text); if (event.type === "status" && event.tag === "usage_update") { eventBreakdown = event.breakdown ?? eventBreakdown; eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd; } - await emitRuntimeEvent(ctx, event); + await emitRuntimeEvent(ctx, event, toolTitles); } const terminal = await turn.result; if (timeout) clearTimeout(timeout); diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 21c93ebe57..5b0837e17e 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -96,6 +96,14 @@ export const INSTANCE_FEATURE_CATALOG: Recordms` duration or a `cubic-bezier(` + * literal. All motion must reference the --motion-* tokens in + * ui/src/index.css (the single motion source), so the dev tweak panel can + * retune them live. + * + * Documented allowlist: ui/src/components/task-chat/motion-tokens.ts is the + * easing-preset catalog the tweak panel offers — those cubic-bezier values + * are data for the tool, analogous to the token layer, not component motion. + * Test files are excluded. + * + * 2. Flag-off isolation seams are single conditionals (grep-provable): asserts + * the IssueDetail chat-tab seam and the IssueProperties tab-shell seam exist. + * + * Exit non-zero on any violation. + */ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, relative } from "node:path"; + +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", + "ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx", +]; +const ALLOWLIST = new Set(["ui/src/components/task-chat/motion-tokens.ts"]); + +const MS_RE = /\d+ms\b/; +const CUBIC_RE = /cubic-bezier\(/; + +function isCheckable(path) { + if (!/\.(ts|tsx)$/.test(path)) return false; + if (/\.test\.(ts|tsx)$/.test(path)) return false; + return true; +} + +function walk(dir, out) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full, out); + else out.push(full); + } +} + +const files = []; +for (const d of SCAN_DIRS) walk(join(repoRoot, d), files); +for (const f of SCAN_FILES) files.push(join(repoRoot, f)); + +const violations = []; +for (const file of files) { + const rel = relative(repoRoot, file).split("\\").join("/"); + if (!isCheckable(rel) || ALLOWLIST.has(rel)) continue; + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, i) => { + if (MS_RE.test(line) || CUBIC_RE.test(line)) { + violations.push(`${rel}:${i + 1}: ${line.trim()}`); + } + }); +} + +// Seam assertions (clause B: single grep-provable conditional in each file). +const seams = [ + { + file: "ui/src/pages/IssueDetail.tsx", + needle: "? TaskChatThread : IssueChatThread", + label: "chat-tab seam", + }, + { + file: "ui/src/components/issue-properties/IssueProperties.tsx", + needle: "if (!taskChatRedesignEnabled) return propertiesBody;", + label: "properties-pane seam", + }, +]; +const missingSeams = []; +for (const seam of seams) { + const text = readFileSync(join(repoRoot, seam.file), "utf8"); + if (!text.includes(seam.needle)) missingSeams.push(`${seam.label} (${seam.file})`); +} + +let failed = false; +if (violations.length > 0) { + failed = true; + console.error("Hardcoded timing found in the task-chat redesign surface:"); + for (const v of violations) console.error(` ${v}`); + console.error("Use a --motion-* token from ui/src/index.css instead."); +} +if (missingSeams.length > 0) { + failed = true; + console.error("Missing flag-off isolation seam(s):"); + for (const s of missingSeams) console.error(` ${s}`); +} + +if (failed) process.exit(1); +console.log( + `check-task-chat-motion: OK (${files.filter((f) => isCheckable(relative(repoRoot, f).split("\\").join("/"))).length} files scanned, seams present).`, +); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index f641eb6a5a..295f58720e 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -29,6 +29,7 @@ describe("instance settings service", () => { enableStreamlinedLeftNavigation: true, enableApps: false, enableConferenceRoomChat: false, + enableTaskChatRedesign: false, enableExternalObjects: false, enableSmokeLab: false, enablePipelines: false, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 2a94177c20..f7a017c5c9 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -214,6 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enablePipelines: parsed.data.enablePipelines ?? false, enableCases: parsed.data.enableCases ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, + enableTaskChatRedesign: parsed.data.enableTaskChatRedesign ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, @@ -248,6 +249,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enablePipelines: false, enableCases: false, enableConferenceRoomChat: false, + enableTaskChatRedesign: false, enableTaskWatchdogs: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index bf61c83db9..9a1163bafd 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,6 +3,8 @@ import { Button } from "@/components/ui/button"; import { useTranslation } from "@/i18n"; import { Layout } from "./components/Layout"; import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate"; +import { TaskChatRedesignGate } from "./components/TaskChatRedesignGate"; +import { TaskChatLab } from "./pages/TaskChatLab"; import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate"; import { CasesExperimentalGate } from "./components/CasesExperimentalGate"; import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate"; @@ -269,6 +271,14 @@ function boardRoutes() { } /> } /> + {/* 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 ? ( + }> + } /> + + ) : null} } /> } /> } /> diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 104abe3402..0bcb0c6136 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -77,6 +77,7 @@ import { type IssueTimelineAssignee, type IssueTimelineEvent, type IssueTimelineWorkspace, + type IssueWorkModeChange, } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -428,6 +429,13 @@ interface IssueChatThreadProps { feedbackTermsUrl?: string | null; linkedRuns?: IssueChatLinkedRun[]; timelineEvents?: IssueTimelineEvent[]; + /** + * Work-mode switch history from the activity feed. Only the redesigned + * TaskChatThread consumes this (flag: enableTaskChatRedesign) to tag each + * agent reply with the mode its request ran under; the legacy thread + * ignores it. + */ + workModeChanges?: IssueWorkModeChange[]; liveRuns?: LiveRunForIssue[]; activeRun?: ActiveRunForIssue | null; issueId?: string | null; @@ -492,6 +500,13 @@ interface IssueChatThreadProps { autoScrollToHashOnInitialLoad?: boolean; emptyMessage?: string; footer?: ReactNode; + /** + * Issue header content (title row, badges, plugin toolbars) rendered INSIDE + * the thread's scroll viewport so it scrolls away with the messages. Only the + * redesigned TaskChatThread consumes this (flag: enableTaskChatRedesign); + * the legacy thread ignores it — its header stays in the page flow. + */ + threadHeader?: ReactNode; variant?: "full" | "embedded"; enableLiveTranscriptPolling?: boolean; transcriptsByRunId?: ReadonlyMap; diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx index 364a68a173..59f3a8e111 100644 --- a/ui/src/components/IssueThreadInteractionCard.tsx +++ b/ui/src/components/IssueThreadInteractionCard.tsx @@ -65,6 +65,8 @@ interface IssueThreadInteractionCardProps { onCancelInteraction?: ( interaction: AskUserQuestionsInteraction, ) => Promise | void; + /** Render confirmation CTAs with the primary action rightmost (task-chat grammar). */ + primaryActionOnRight?: boolean; onSubmitInteractionVerdicts?: ( interaction: RequestItemVerdictsInteraction, verdicts: { id: string; verdict: RequestItemVerdictValue; reason?: string }[], @@ -1842,6 +1844,7 @@ function RequestToolActionCard({ function RequestConfirmationCard({ interaction, isPlan = false, + primaryActionOnRight = false, onAcceptInteraction, onRejectInteraction, onUploadImage, @@ -1849,6 +1852,7 @@ function RequestConfirmationCard({ }: { interaction: RequestConfirmationInteraction; isPlan?: boolean; + primaryActionOnRight?: boolean; onAcceptInteraction?: ( interaction: RequestConfirmationInteraction, ) => Promise | void; @@ -1969,7 +1973,12 @@ function RequestConfirmationCard({ {interaction.status === "pending" ? (
-
+
+ if (!redesignEnabled) { + return ( +
- + + ); + } + + return ( + + ); +} + +/* ------------------------------------------------------------------------- * + * Task Chat Redesign (flag: enableTaskChatRedesign) — resizable/maximizable + * variant. Everything below renders only when the flag is ON. + * ------------------------------------------------------------------------- */ + +/** + * Portal target in the redesigned pane's header bar: hosted content (the + * Properties | Plan | Artifacts tab strip) renders here, left of the window + * controls. See IssueProperties' flag-ON shell. + */ +export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot"; +/** + * Portal target pinned below the pane's scroll area: hosted content (the plan + * confirmation action bar) renders here so it stays visible while the pane + * body scrolls. + */ +export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot"; + +const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth"; +const DEFAULT_PANE_WIDTH = 322; +const MIN_PANE_WIDTH = 260; +/** ~236px sidebar + ~420px minimum center column stay usable while resizing. */ +const RESERVED_LAYOUT_WIDTH = 656; +/** Content cap while maximized so text doesn't span the full viewport. */ +const MAXIMIZED_CONTENT_MAX_WIDTH = 840; +/** + * Defensive fallback (in milliseconds) for the restore glide in case + * `transitionend` never fires; slightly longer than the --motion-pane-glide + * token in index.css. + */ +const RESTORE_FALLBACK_DELAY = 400; + +function clampPaneWidth(width: number): number { + const max = + typeof window === "undefined" + ? Number.POSITIVE_INFINITY + : Math.max(MIN_PANE_WIDTH, window.innerWidth - RESERVED_LAYOUT_WIDTH); + return Math.min(Math.max(Math.round(width), MIN_PANE_WIDTH), max); +} + +function readStoredPaneWidth(): number { + if (typeof window === "undefined") return DEFAULT_PANE_WIDTH; + try { + const raw = window.localStorage.getItem(WIDTH_STORAGE_KEY); + const parsed = raw === null ? Number.NaN : Number(raw); + return Number.isFinite(parsed) ? parsed : DEFAULT_PANE_WIDTH; + } catch { + return DEFAULT_PANE_WIDTH; + } +} + +function persistPaneWidth(width: number) { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(WIDTH_STORAGE_KEY, String(width)); + } catch { + // Ignore storage failures. + } +} + +function clearStoredPaneWidth() { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(WIDTH_STORAGE_KEY); + } catch { + // Ignore storage failures. + } +} + +function prefersReducedMotion(): boolean { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** Fixed-position geometry while the panel is maximized (or gliding). */ +interface FixedPane { + top: number; + /** Animated by the .tc-pane-glide transition. */ + left: number; + /** Distance from the viewport's right edge to the panel's right edge. */ + rightInset: number; + /** Glide target: the layout row's left edge (flush with the sidebar). */ + parentLeft: number; +} + +interface ResizablePropertiesPanelProps { + panelContent: ReactNode; + panelVisible: boolean; + setPanelVisible: (visible: boolean) => void; +} + +function ResizablePropertiesPanel({ + panelContent, + panelVisible, + setPanelVisible, +}: ResizablePropertiesPanelProps) { + const [width, setWidth] = useState(() => clampPaneWidth(readStoredPaneWidth())); + const [dragging, setDragging] = useState(false); + const [maximized, setMaximized] = useState(false); + const [fixedPane, setFixedPane] = useState(null); + + const asideRef = useRef(null); + const widthRef = useRef(width); + widthRef.current = width; + const dragStateRef = useRef<{ pointerId: number; startX: number; startWidth: number } | null>( + null, + ); + const previousBodyUserSelectRef = useRef(""); + const restoreTimerRef = useRef(null); + + const clearRestoreTimer = useCallback(() => { + if (restoreTimerRef.current !== null) { + window.clearTimeout(restoreTimerRef.current); + restoreTimerRef.current = null; + } + }, []); + + const finishRestore = useCallback(() => { + clearRestoreTimer(); + setFixedPane(null); + }, [clearRestoreTimer]); + + // Hiding the panel keeps today's collapse-to-0 behavior; if it was + // maximized (or mid-glide), just unmaximize instantly first. + useEffect(() => { + if (!panelVisible) { + setMaximized(false); + finishRestore(); + } + }, [panelVisible, finishRestore]); + + useEffect( + () => () => { + if (restoreTimerRef.current !== null) window.clearTimeout(restoreTimerRef.current); + if (dragStateRef.current !== null) { + document.body.style.userSelect = previousBodyUserSelectRef.current; + } + }, + [], + ); + + const endDrag = useCallback((persist: boolean) => { + if (dragStateRef.current === null) return; + dragStateRef.current = null; + setDragging(false); + document.body.style.userSelect = previousBodyUserSelectRef.current; + if (persist) persistPaneWidth(widthRef.current); + }, []); + + const handleGripPointerDown = useCallback((event: React.PointerEvent) => { + // Primary button only (touch/pen report button 0 or -1 for down events). + if (event.pointerType === "mouse" && event.button !== 0) return; + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startWidth: widthRef.current, + }; + previousBodyUserSelectRef.current = document.body.style.userSelect; + document.body.style.userSelect = "none"; + setDragging(true); + }, []); + + const handleGripPointerMove = useCallback((event: React.PointerEvent) => { + const drag = dragStateRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + // The grip sits on the panel's LEFT border: moving left widens the panel. + setWidth(clampPaneWidth(drag.startWidth + (drag.startX - event.clientX))); + }, []); + + const handleGripPointerUp = useCallback( + (event: React.PointerEvent) => { + const drag = dragStateRef.current; + if (drag === null || drag.pointerId !== event.pointerId) return; + endDrag(true); + }, + [endDrag], + ); + + const handleGripLostPointerCapture = useCallback(() => { + endDrag(true); + }, [endDrag]); + + const handleGripDoubleClick = useCallback(() => { + setWidth(DEFAULT_PANE_WIDTH); + clearStoredPaneWidth(); + }, []); + + const handleMaximize = useCallback(() => { + const aside = asideRef.current; + const row = aside?.parentElement; + if (!aside || !row) return; + clearRestoreTimer(); + setMaximized(true); + const rowRect = row.getBoundingClientRect(); + setFixedPane((pane) => { + // Re-maximizing mid-restore: keep the current geometry, glide back left. + if (pane) return { ...pane, left: pane.parentLeft }; + const rect = aside.getBoundingClientRect(); + const seeded: FixedPane = { + top: rect.top, + left: rect.left, + rightInset: Math.max(0, window.innerWidth - rect.right), + parentLeft: rowRect.left, + }; + if (prefersReducedMotion()) return { ...seeded, left: seeded.parentLeft }; + // Seed at the current left, then glide to the row's left edge once the + // fixed position has been committed (double rAF). + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + setFixedPane((current) => (current ? { ...current, left: current.parentLeft } : current)); + }); + }); + return seeded; + }); + }, [clearRestoreTimer]); + + const handleRestore = useCallback(() => { + const row = asideRef.current?.parentElement; + setMaximized(false); + if (prefersReducedMotion()) { + finishRestore(); + return; + } + setFixedPane((pane) => { + if (!pane) return pane; + const rowRight = row + ? row.getBoundingClientRect().right + : window.innerWidth - pane.rightInset; + return { ...pane, left: rowRight - widthRef.current }; + }); + clearRestoreTimer(); + restoreTimerRef.current = window.setTimeout(finishRestore, RESTORE_FALLBACK_DELAY); + }, [clearRestoreTimer, finishRestore]); + + const handleTransitionEnd = useCallback( + (event: React.TransitionEvent) => { + if (event.target !== asideRef.current || event.propertyName !== "left") return; + // Only the restore glide needs to unfix on arrival. + if (!maximized) finishRestore(); + }, + [maximized, finishRestore], + ); + + const isFixed = fixedPane !== null; + + return ( + <> + {isFixed ? ( + // Holds the panel's slot in the layout flex row while the panel is + // position:fixed, so the main column never reflows. +
+ ) : null} + + ); } diff --git a/ui/src/components/TaskChatRedesignGate.test.tsx b/ui/src/components/TaskChatRedesignGate.test.tsx new file mode 100644 index 0000000000..478d334361 --- /dev/null +++ b/ui/src/components/TaskChatRedesignGate.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskChatRedesignGate } from "./TaskChatRedesignGate"; +import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled"; + +const mockInstanceSettingsApi = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); + +vi.mock("@/api/instanceSettings", () => ({ + instanceSettingsApi: mockInstanceSettingsApi, +})); + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to, replace }: { to: string; replace?: boolean }) => ( +
+ ), + Outlet: () =>
gated content
, +})); + +async function flushReact() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + flushSync(() => {}); +} + +describe("TaskChatRedesignGate", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function renderGate() { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReact(); + } + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + flushSync(() => { + root?.unmount(); + }); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("redirects to the company home when the flag is off (flag-off isolation)", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: false }); + await renderGate(); + const navigate = container.querySelector('[data-testid="navigate"]'); + expect(navigate?.getAttribute("data-to")).toBe("/dashboard"); + expect(container.querySelector('[data-testid="outlet"]')).toBeNull(); + }); + + it("renders the gated harness when the flag is on", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: true }); + await renderGate(); + expect(container.querySelector('[data-testid="outlet"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); + }); +}); + +describe("useTaskChatRedesignEnabled", () => { + it("resolves to flag-off when rendered without a QueryClientProvider", () => { + // Rendered detached (no provider) it must default OFF and loaded — this is + // what makes flag-off the provable current behavior at every call site. + let captured: { enabled: boolean; loaded: boolean } | null = null; + function Probe() { + captured = useTaskChatRedesignEnabled(); + return null; + } + const container = document.createElement("div"); + const root = createRoot(container); + flushSync(() => root.render()); + flushSync(() => root.unmount()); + expect(captured).toEqual({ enabled: false, loaded: true }); + }); +}); diff --git a/ui/src/components/TaskChatRedesignGate.tsx b/ui/src/components/TaskChatRedesignGate.tsx new file mode 100644 index 0000000000..59f2bb8c97 --- /dev/null +++ b/ui/src/components/TaskChatRedesignGate.tsx @@ -0,0 +1,18 @@ +import { Navigate, Outlet } from "@/lib/router"; +import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled"; + +/** + * Layout route guard for Task Chat Redesign dev surfaces (e.g. the + * /dev/task-chat-lab harness). + * + * The gated routes stay registered (gating is presentation-only, no 404 + * flash); when the experimental flag is off the element redirects to the + * company home instead of rendering. While the flag is still loading nothing + * renders so an enabled user is not bounced away by a premature redirect. + */ +export function TaskChatRedesignGate() { + const { enabled, loaded } = useTaskChatRedesignEnabled(); + if (!loaded) return null; + if (!enabled) return ; + return ; +} diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx new file mode 100644 index 0000000000..8e64458107 --- /dev/null +++ b/ui/src/components/TaskChatThread.tsx @@ -0,0 +1,403 @@ +import { useCallback, useEffect, useMemo, useRef, 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 { + buildTurnSummary, + deriveRunStatusLabel, + isTerminalRunStatus, + transcriptToTaskChatItems, +} from "@/components/task-chat/transcript-adapter"; +import type { + TaskChatInteractionItem, + TaskChatItem, + TaskChatTurnChildItem, + TaskChatTurnItem, +} from "@/components/task-chat/task-chat-model"; +import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; +import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView"; +import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; +import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; +import { latestSameRunHandoffTimestamp, type IssueChatComment } from "@/lib/issue-chat-messages"; +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; + +/** + * Task Chat Redesign thread (experimental flag: `enableTaskChatRedesign`). + * + * Renders the redesigned, Claude-Code-style thread for the live task. It shares + * IssueChatThread's exact prop type — so the IssueDetail seam ternary + * (`redesign ? TaskChatThread : IssueChatThread`) type-checks with no casts. + * + * Two data sources feed the render layer, both reused from the existing thread: + * - the comment stream (incl. optimistic echoes) → author-typed bubbles, and + * - 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 + * an unsettled (expanded) turn; when it terminates the same turn id flips + * settled, so TaskChatTurn plays the ~--motion-turn-fold collapse down to the + * one-line "✓ Worked · …" summary (runs already terminal at mount collapse + * instantly). Settled turns interleave after the run's last comment + * (comment.runId linkage) — the agent's reply bubble above, the folded activity + * summary below, so the live "Running…" pill reads as being replaced by the + * summary in place. flag-OFF remains byte-for-byte IssueChatThread. + */ +export function TaskChatThread(props: TaskChatThreadProps) { + const { + comments, + interactions, + timelineEvents, + issueId = null, + agentMap, + userLabelMap, + currentUserId, + onAdd, + issueWorkMode = "standard", + onWorkModeChange, + composerAccessory, + footer, + showComposer = true, + composerDisabledReason, + emptyMessage = "No messages yet.", + companyId, + linkedRuns, + liveRuns, + activeRun, + onAttachImage, + imageUploadHandler, + enableReassign, + reassignOptions, + currentAssigneeValue, + issueStatus, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, + onCancelInteraction, + onSubmitInteractionVerdicts, + externalReferences, + threadHeader, + workModeChanges, + } = props; + + const linkedRunMetaById = useMemo(() => { + const map = new Map[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, agentModeLabelFor }), + [comments, agentMap, userLabelMap, currentUserId, agentModeLabelFor], + ); + + // Every run we might need a transcript for (history + live), deduped by id. + const runs = useMemo(() => { + const map = new Map(); + for (const r of linkedRuns ?? []) { + map.set(r.runId, { + id: r.runId, + status: r.status, + adapterType: r.adapterType ?? "", + hasStoredOutput: r.hasStoredOutput, + logBytes: r.logBytes, + }); + } + for (const r of liveRuns ?? []) { + map.set(r.id, { + id: r.id, + status: r.status, + adapterType: r.adapterType, + hasStoredOutput: map.get(r.id)?.hasStoredOutput, + logBytes: r.logBytes, + lastOutputBytes: r.lastOutputBytes, + }); + } + if (activeRun) { + map.set(activeRun.id, { + id: activeRun.id, + status: activeRun.status, + adapterType: activeRun.adapterType, + logBytes: activeRun.logBytes, + lastOutputBytes: activeRun.lastOutputBytes, + }); + } + return [...map.values()]; + }, [linkedRuns, liveRuns, activeRun]); + + const { transcriptByRun } = useLiveRunTranscripts({ runs, companyId }); + + // The single in-flight run whose turn we stream live (non-terminal). + const liveRun = useMemo(() => { + 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>(new Set()); + useEffect(() => { + if (liveRun) liveSeenRef.current.add(liveRun.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. + const lastCommentIdByRun = useMemo(() => { + const map = new Map(); + 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]); + + const items = useMemo(() => { + // Settled turns for every terminal run whose transcript we have. The + // transcript's assistant text is excluded — it already landed as the run's + // comment bubble; the turn holds the activity (thinking/tools/diffs). + const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; order: number }[] = []; + 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 children = transcriptToTaskChatItems(entries, { + runId: source.id, + agentName: meta?.agentName, + running: false, + }).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message"); + if (children.length === 0) continue; + settledTurns.push({ + turn: { + id: `${source.id}:turn`, + kind: "turn", + settled: true, + animateFold: liveSeenRef.current.has(source.id), + items: children, + summary: buildTurnSummary(entries, { + durationMs, + failed: source.status !== "succeeded", + }), + }, + anchorCommentId: lastCommentIdByRun.get(source.id) ?? null, + order: meta?.createdAt ? new Date(meta.createdAt).getTime() : 0, + }); + } + settledTurns.sort((a, b) => a.order - b.order); + + const turnsByAnchor = new Map(); + const unanchored: TaskChatTurnItem[] = []; + for (const { turn, anchorCommentId } of settledTurns) { + if (anchorCommentId) { + const list = turnsByAnchor.get(anchorCommentId) ?? []; + list.push(turn); + turnsByAnchor.set(anchorCommentId, list); + } else { + unanchored.push(turn); + } + } + + const out: TaskChatItem[] = []; + for (const entry of orderedEntries) { + out.push(entry.item); + const following = turnsByAnchor.get(entry.id); + if (following) out.push(...following); + } + out.push(...unanchored); + + if (liveRun) { + const entries = transcriptByRun.get(liveRun.id) ?? []; + const children = transcriptToTaskChatItems(entries, { + runId: liveRun.id, + agentName: liveRun.agentName, + running: true, + }).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn"); + if (children.length > 0) { + out.push({ + id: `${liveRun.id}:turn`, + kind: "turn", + settled: false, + items: children, + summary: buildTurnSummary(entries), + }); + } + 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 } + : deriveRunStatusLabel(entries); + out.push({ + id: `${liveRun.id}:status`, + kind: "status", + status: "running", + label: status.label, + detail: status.detail, + toolName: status.toolName, + startedAtMs: startedAt ?? undefined, + }); + } + return out; + }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]); + + const renderInteraction = useCallback( + (item: TaskChatInteractionItem) => ( + + ), + [ + agentMap, + currentUserId, + userLabelMap, + onAcceptInteraction, + onRejectInteraction, + onSubmitInteractionAnswers, + onCancelInteraction, + onSubmitInteractionVerdicts, + imageUploadHandler, + externalReferences, + ], + ); + + return ( +
+
+ {items.length === 0 ? ( +
+ {threadHeader ? ( +
+ {threadHeader} +
+ ) : null} +
{emptyMessage}
+
+ ) : ( + + )} +
+ {showComposer ? ( +
+ {composerAccessory} + + {footer} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx b/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx new file mode 100644 index 0000000000..e679989f08 --- /dev/null +++ b/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx @@ -0,0 +1,204 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import type { Issue, IssueThreadInteraction, RequestConfirmationInteraction } from "@paperclipai/shared"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { PROPERTIES_PANE_FOOTER_SLOT_ID } from "../PropertiesPanel"; + +/** The pending confirmation that targets the issue's `plan` document, if any. */ +function findPendingPlanConfirmation( + interactions: IssueThreadInteraction[] | undefined, +): RequestConfirmationInteraction | null { + for (const interaction of interactions ?? []) { + if (interaction.kind !== "request_confirmation") continue; + if (interaction.status !== "pending") continue; + const target = interaction.payload.target; + if (target?.type === "issue_document" && target.key === "plan") return interaction; + } + return null; +} + +interface IssuePlanConfirmationActionBarProps { + issue: Issue; + /** Inline hosts (mobile sheet) render the bar in place instead of portaling + * it into the pane's pinned footer slot. */ + inline?: boolean; +} + +/** + * Sticky action bar for the Plan pane (flag: enableTaskChatRedesign): while a + * plan confirmation is pending, its CTAs stay pinned below the pane's scroll + * area so the board can approve or send back the plan without hunting for the + * card in the thread. Mirrors the thread card's semantics (accept/reject labels + * and the optional decline reason) against the same interactions API. + */ +export function IssuePlanConfirmationActionBar({ issue, inline }: IssuePlanConfirmationActionBarProps) { + const queryClient = useQueryClient(); + const { data: interactions } = useQuery({ + queryKey: queryKeys.issues.interactions(issue.id), + queryFn: () => issuesApi.listInteractions(issue.id), + }); + const confirmation = findPendingPlanConfirmation(interactions); + + const [footerSlot, setFooterSlot] = useState(null); + useEffect(() => { + if (inline) { + setFooterSlot(null); + return; + } + setFooterSlot(document.getElementById(PROPERTIES_PANE_FOOTER_SLOT_ID)); + }, [inline]); + + const [rejecting, setRejecting] = useState(false); + const [rejectReason, setRejectReason] = useState(""); + const [rejectAttempted, setRejectAttempted] = useState(false); + const [actionError, setActionError] = useState(null); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issue.id) }); + }; + const accept = useMutation({ + mutationFn: (interactionId: string) => issuesApi.acceptInteraction(issue.id, interactionId), + onSuccess: invalidate, + onError: () => setActionError("Couldn't confirm — try again."), + }); + const reject = useMutation({ + mutationFn: ({ interactionId, reason }: { interactionId: string; reason?: string }) => + issuesApi.rejectInteraction(issue.id, interactionId, reason), + onSuccess: () => { + setRejecting(false); + setRejectReason(""); + invalidate(); + }, + onError: () => setActionError("Couldn't send that back — try again."), + }); + + // Interaction changed under us (resolved elsewhere, superseded): reset. + useEffect(() => { + setRejecting(false); + setRejectReason(""); + setRejectAttempted(false); + setActionError(null); + }, [confirmation?.id, confirmation?.status]); + + if (!confirmation) return null; + + const working = accept.isPending ? "accept" : reject.isPending ? "reject" : null; + const rejectRequiresReason = confirmation.payload.rejectRequiresReason === true; + const allowDeclineReason = confirmation.payload.allowDeclineReason !== false; + const trimmedReason = rejectReason.trim(); + const reasonInvalid = rejectRequiresReason && trimmedReason.length === 0; + + const handleReject = () => { + setRejectAttempted(true); + if (reasonInvalid) return; + setActionError(null); + reject.mutate({ interactionId: confirmation.id, reason: trimmedReason || undefined }); + }; + + const bar = ( +
+ {rejecting ? ( +
+