feat(ui): chat-style task view behind an experimental flag (#10606)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators spend most of their time on the issue detail page. They talk to the assigned agent there through comments. > - The current page reads as a ticket form. The thread sits below properties, the composer sits mid-page, and live agent activity renders as dense transcript logs. > - Talking to an agent is a conversation. A chat-first layout matches that mental model better than a ticket form. > - A layout change this large must not disrupt current users. It needs a safe opt-in path and full parity with the existing thread features. > - This pull request adds a chat-style task view behind a new "Chat-Style Tasks" experiment toggle. The flag is off by default and the existing page is unchanged when it is off. > - The benefit is a focused, readable conversation with the agent: live tool activity folds into compact summaries, the composer stays at the bottom, and properties, plan, and artifacts move into header tabs. ## Linked Issues or Issue Description Refs #49 (chat with agents is a much-wanted feature). Related PRs found in the dedup search: - #4489 — an earlier, closed attempt to promote the conversation to the primary surface on issue detail. This PR is a fresh, flag-gated take on the same goal. - #8837 — an open PR that proposes a two-column task layout. It restructures the same page but keeps the ticket paradigm; this PR is orthogonal because it is opt-in and chat-first. **Subsystem affected** UI (issue detail page). **Problem or motivation** The issue detail page presents agent conversations as a ticket: properties first, thread below, composer in the middle of the page, and raw transcript noise during live runs. Users who mainly converse with their agents must scroll past chrome to follow the conversation, and live activity is hard to read. **Proposed solution** An opt-in chat-style view of the issue detail page, gated by a new "Chat-Style Tasks" experiment toggle in Settings → Experimental. With the flag on, the thread fills the center pane, the composer docks to the bottom of the viewport, Properties / Plan / Artifacts become header tabs, live turns show a status pill with the current tool action and elapsed time, and settled turns collapse to a "Worked · N tools" summary that expands into per-tool rows. With the flag off, nothing changes. **Alternatives considered** Restyling the existing layout in place (rejected: too disruptive without an opt-out), and a separate chat page beside the issue page (rejected: splits the task's single source of truth). A per-request lab page (`/task-chat-lab`, dev-only) was kept for design iteration instead. **Roadmap alignment** ROADMAP.md "CEO Chat" wants lighter conversations that still resolve to real work objects. This PR keeps the core task-and-comments model — it only changes presentation, opt-in — so it does not duplicate that planned work. ## What Changed - New `enableTaskChatRedesign` instance setting, exposed as a "Chat-Style Tasks" experiment card in Settings → Experimental (shared feature catalog, validators, server instance-settings service, and UI settings page). - New `ui/src/components/task-chat/` component family: chat thread with turn grouping, agent reply bubbles, live status pill, collapsible turn summaries with per-tool rows, plan tab with a sticky CTA action bar, inline interaction cards, per-request mode chips, and a bottom-docked composer. - A shared tool taxonomy (`tool-taxonomy.ts`) maps tool names to verbs and icons; the status pill, tool rows, and the classic transcript view all use it. - A transcript adapter converts stored run logs into chat turns; it dedupes tool-call updates by `toolUseId` so tool counts match the expanded rows, and it keeps a tool row's first real name when later generic updates arrive. - Composer: posts on Cmd/Ctrl+Enter, supports image paste with object-URL thumbnail previews (revoked on clear/unmount), and uploads through the issue attachments route. - `IssueDetail.tsx`: with the flag on, pane tabs move to the header bar, the header is not sticky, and the chat fills the center; with the flag off, the previous layout renders unchanged. - Motion tokens for the new animations live in `ui/src/index.css` with a `motion-tokens.ts` catalog and a test that keeps the two in sync (the catalog now also covers the shared enter/exit/swap tokens that the decision/quicklook block declares). - A dev-only `/task-chat-lab` page with fixtures and a tweak panel for motion tuning. ## Verification - `pnpm typecheck` — clean across the workspace. - `pnpm check:token-gates` — 3/3 CLEAN. - `cd ui && pnpm vitest run` — 3,344 of 3,345 tests pass locally. The one failure is `IssueProperties.test.tsx` monitor-row time formatting, which is timezone-sensitive: it also fails on unmodified `origin/master` in a non-UTC timezone and passes with `TZ=UTC`. It is not related to this change. - `cd server && pnpm vitest run src/__tests__/instance-settings-service.test.ts` — 21/21 pass (covers the new setting). - Manual: start the dev server, open Settings → Experimental, enable "Chat-Style Tasks", and open any issue. The thread fills the page, the composer docks to the bottom, and Properties / Plan / Artifacts appear as header tabs. Assign an agent and comment to watch a live run: the status pill shows the current tool action with elapsed time, and the finished turn folds into a "Worked · N tools" summary. Disable the toggle and confirm the classic page is unchanged. - Visual snapshot baselines are intentionally not updated: per `doc/design/DECISION-SHEET.md`, "Per-change snapshot verification demoted to dormant (Jul 13 2026)". ## Risks - The flag-off path goes through the same `IssueDetail.tsx` file, so a regression there would affect current users. Mitigation: the classic markup renders through the same components as before behind explicit flag conditionals, and the full UI suite passes. - The transcript adapter interprets stored run-log formats, including legacy entries without `toolUseId`. Malformed logs degrade to generic tool rows rather than crashing. - The new view changes no server behavior other than one additive instance setting; it is additive and default-off. Overall risk with the flag off is low. ## Model Used - Claude (Anthropic), model id `claude-fable-5` (Claude Fable 5), extended thinking enabled, agentic tool use (file editing, shell, test execution) via Claude Code / Claude Agent SDK. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6401f4f78c
commit
c185e64b77
26
DESIGN.md
26
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-<state>-*`) 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.
|
||||
|
|
|
|||
|
|
@ -2333,7 +2333,17 @@ async function emitAcpxLog(ctx: AdapterExecutionContext, payload: Record<string,
|
|||
await ctx.onLog("stdout", `${JSON.stringify(payload)}\n`);
|
||||
}
|
||||
|
||||
async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeEvent) {
|
||||
// acpx substitutes a literal "tool call" title when an ACP tool_call_update
|
||||
// omits one, which would persist a generic name over the real one ("Terminal",
|
||||
// "Read", …) in the stored run log. Remember each call's real title so update
|
||||
// lines keep the name durably.
|
||||
const GENERIC_ACP_TOOL_TITLE = "tool call";
|
||||
|
||||
async function emitRuntimeEvent(
|
||||
ctx: AdapterExecutionContext,
|
||||
event: AcpRuntimeEvent,
|
||||
toolTitles?: Map<string, string>,
|
||||
) {
|
||||
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<string, unknown>;
|
||||
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<string, string>();
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -96,6 +96,14 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableTaskChatRedesign: {
|
||||
title: "Chat-Style Tasks",
|
||||
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.",
|
||||
tier: "preference",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableTaskWatchdogs: {
|
||||
title: "Task Watchdogs",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export interface InstanceExperimentalSettings {
|
|||
enablePipelines: boolean;
|
||||
enableCases: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableTaskChatRedesign: boolean;
|
||||
enableTaskWatchdogs: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableExperimentalFileViewer: boolean;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enablePipelines: z.boolean().default(false),
|
||||
enableCases: z.boolean().default(false),
|
||||
enableConferenceRoomChat: z.boolean().default(false),
|
||||
enableTaskChatRedesign: z.boolean().default(false),
|
||||
enableTaskWatchdogs: z.boolean().default(false),
|
||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
enableExperimentalFileViewer: z.boolean().default(false),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Finish-line gate for the Task Chat Redesign (flag: enableTaskChatRedesign).
|
||||
*
|
||||
* 1. No hardcoded timing in the redesign surface: fails if any redesigned
|
||||
* component/page contains a raw `<digits>ms` 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).`,
|
||||
);
|
||||
|
|
@ -29,6 +29,7 @@ describe("instance settings service", () => {
|
|||
enableStreamlinedLeftNavigation: true,
|
||||
enableApps: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableTaskChatRedesign: false,
|
||||
enableExternalObjects: false,
|
||||
enableSmokeLab: false,
|
||||
enablePipelines: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<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}
|
||||
<Route path="decisions" element={<WhatNeedsMe />} />
|
||||
<Route path="decisions/training" element={<TrainingLibrary />} />
|
||||
<Route path="decisions/training/:id" element={<TrainingInspector />} />
|
||||
|
|
|
|||
|
|
@ -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<string, readonly IssueChatTranscriptEntry[]>;
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ interface IssueThreadInteractionCardProps {
|
|||
onCancelInteraction?: (
|
||||
interaction: AskUserQuestionsInteraction,
|
||||
) => Promise<void> | 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> | void;
|
||||
|
|
@ -1969,7 +1973,12 @@ function RequestConfirmationCard({
|
|||
|
||||
{interaction.status === "pending" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-end gap-2",
|
||||
primaryActionOnRight && "flex-row-reverse justify-start",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={rejecting ? "outline" : isPlan ? "cta" : "default"}
|
||||
|
|
@ -3045,6 +3054,7 @@ export function IssueThreadInteractionCard({
|
|||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
primaryActionOnRight,
|
||||
onSubmitInteractionVerdicts,
|
||||
onUploadImage,
|
||||
externalReferences,
|
||||
|
|
@ -3180,6 +3190,7 @@ export function IssueThreadInteractionCard({
|
|||
<RequestConfirmationCard
|
||||
interaction={interaction}
|
||||
isPlan={isPlan}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onUploadImage={onUploadImage}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
// @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 { PropertiesPanel } from "./PropertiesPanel";
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
const mockPanelState = vi.hoisted(() => ({
|
||||
panelContent: null as unknown,
|
||||
panelVisible: true,
|
||||
}));
|
||||
|
||||
vi.mock("../context/PanelContext", () => ({
|
||||
usePanel: () => ({
|
||||
panelContent: mockPanelState.panelContent,
|
||||
panelVisible: mockPanelState.panelVisible,
|
||||
openPanel: vi.fn(),
|
||||
closePanel: vi.fn(),
|
||||
setPanelVisible: vi.fn(),
|
||||
togglePanelVisible: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
describe("PropertiesPanel", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
async function renderPanel({ panelVisible = true }: { panelVisible?: boolean } = {}) {
|
||||
mockPanelState.panelContent = <div data-testid="panel-content">content</div>;
|
||||
mockPanelState.panelVisible = panelVisible;
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PropertiesPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("flag off (current behavior)", () => {
|
||||
beforeEach(() => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskChatRedesign: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the fixed-width panel with no grip and no maximize button", async () => {
|
||||
await renderPanel();
|
||||
const aside = container.querySelector("aside");
|
||||
expect(aside).not.toBeNull();
|
||||
expect(aside!.style.width).toBe("320px");
|
||||
expect(aside!.querySelector('[role="separator"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Maximize panel"]')).toBeNull();
|
||||
// Inner wrapper keeps the hardcoded width classes exactly as today.
|
||||
expect(aside!.querySelector(".w-80")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("collapses to width 0 when the panel is hidden", async () => {
|
||||
await renderPanel({ panelVisible: false });
|
||||
const aside = container.querySelector("aside");
|
||||
expect(aside!.style.width).toBe("0px");
|
||||
expect(aside!.style.opacity).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("flag on (task chat redesign)", () => {
|
||||
beforeEach(() => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskChatRedesign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the default 322px width with a drag grip and a maximize button", async () => {
|
||||
await renderPanel();
|
||||
const aside = container.querySelector("aside");
|
||||
expect(aside).not.toBeNull();
|
||||
expect(aside!.style.width).toBe("322px");
|
||||
expect(aside!.querySelector('[role="separator"][aria-label="Resize panel"]')).not.toBeNull();
|
||||
expect(container.querySelector('[aria-label="Maximize panel"]')).not.toBeNull();
|
||||
const inner = aside!.querySelector<HTMLDivElement>(":scope > div:not([role])");
|
||||
expect(inner!.style.width).toBe("322px");
|
||||
expect(inner!.style.minWidth).toBe("322px");
|
||||
});
|
||||
|
||||
it("restores a remembered width from localStorage (clamped to the minimum)", async () => {
|
||||
window.localStorage.setItem("taskChatRedesign.propertiesPaneWidth", "300");
|
||||
await renderPanel();
|
||||
expect(container.querySelector("aside")!.style.width).toBe("300px");
|
||||
});
|
||||
|
||||
it("clamps a stored width below the 260px minimum", async () => {
|
||||
window.localStorage.setItem("taskChatRedesign.propertiesPaneWidth", "100");
|
||||
await renderPanel();
|
||||
expect(container.querySelector("aside")!.style.width).toBe("260px");
|
||||
});
|
||||
|
||||
it("falls back to the default width when the stored value is garbage", async () => {
|
||||
window.localStorage.setItem("taskChatRedesign.propertiesPaneWidth", "not-a-number");
|
||||
await renderPanel();
|
||||
expect(container.querySelector("aside")!.style.width).toBe("322px");
|
||||
});
|
||||
|
||||
it("keeps the collapse-to-0 behavior when the panel is hidden", async () => {
|
||||
await renderPanel({ panelVisible: false });
|
||||
const aside = container.querySelector("aside");
|
||||
expect(aside!.style.width).toBe("0px");
|
||||
expect(aside!.style.opacity).toBe("0");
|
||||
// No grip while hidden.
|
||||
expect(aside!.querySelector('[role="separator"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +1,399 @@
|
|||
import { X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { panelContent, panelVisible, setPanelVisible } = usePanel();
|
||||
const { enabled: redesignEnabled } = useTaskChatRedesignEnabled();
|
||||
|
||||
if (!panelContent) return null;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-(--tp-width-opacity) duration-200 ease-in-out h-full"
|
||||
style={{ width: panelVisible ? 320 : 0, opacity: panelVisible ? 1 : 0 }}
|
||||
>
|
||||
<div className="w-80 flex-1 flex flex-col min-w-(--sz-320px) min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
||||
<span className="text-sm font-medium">Properties</span>
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => setPanelVisible(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
if (!redesignEnabled) {
|
||||
return (
|
||||
<aside
|
||||
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-(--tp-width-opacity) duration-200 ease-in-out h-full"
|
||||
style={{ width: panelVisible ? 320 : 0, opacity: panelVisible ? 1 : 0 }}
|
||||
>
|
||||
<div className="w-80 flex-1 flex flex-col min-w-(--sz-320px) min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
||||
<span className="text-sm font-medium">Properties</span>
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => setPanelVisible(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">{panelContent}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">{panelContent}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePropertiesPanel
|
||||
panelContent={panelContent}
|
||||
panelVisible={panelVisible}
|
||||
setPanelVisible={setPanelVisible}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 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<FixedPane | null>(null);
|
||||
|
||||
const asideRef = useRef<HTMLElement | null>(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<number | null>(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<HTMLDivElement>) => {
|
||||
// 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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLElement>) => {
|
||||
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.
|
||||
<div
|
||||
aria-hidden
|
||||
className="hidden md:block shrink-0"
|
||||
style={{ width: panelVisible ? width : 0 }}
|
||||
/>
|
||||
) : null}
|
||||
<aside
|
||||
ref={asideRef}
|
||||
className={cn(
|
||||
"hidden md:flex border-l border-border bg-card flex-col",
|
||||
isFixed
|
||||
? "tc-pane-glide fixed z-40 overflow-hidden"
|
||||
: cn(
|
||||
"relative h-full shrink-0",
|
||||
panelVisible ? "overflow-visible" : "overflow-hidden",
|
||||
// The width/opacity transition would fight pointer-driven
|
||||
// resizing, so it is suspended while dragging.
|
||||
!dragging && "transition-(--tp-width-opacity) duration-200 ease-in-out",
|
||||
),
|
||||
)}
|
||||
style={
|
||||
isFixed
|
||||
? {
|
||||
top: fixedPane.top,
|
||||
bottom: 0,
|
||||
left: fixedPane.left,
|
||||
right: fixedPane.rightInset,
|
||||
}
|
||||
: { width: panelVisible ? width : 0, opacity: panelVisible ? 1 : 0 }
|
||||
}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
>
|
||||
{!isFixed && panelVisible ? (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize panel"
|
||||
data-dragging={dragging ? "" : undefined}
|
||||
className="group absolute inset-y-0 z-10 cursor-col-resize touch-none"
|
||||
style={{ left: -4, width: 8 }}
|
||||
onPointerDown={handleGripPointerDown}
|
||||
onPointerMove={handleGripPointerMove}
|
||||
onPointerUp={handleGripPointerUp}
|
||||
onPointerCancel={handleGripPointerUp}
|
||||
onLostPointerCapture={handleGripLostPointerCapture}
|
||||
onDoubleClick={handleGripDoubleClick}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-full w-0.5 transition-colors",
|
||||
dragging ? "bg-ring" : "bg-transparent group-hover:bg-ring",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={cn("flex-1 flex flex-col min-h-0", isFixed && "w-full")}
|
||||
style={isFixed ? undefined : { width, minWidth: width }}
|
||||
>
|
||||
{/* The slot hosts whatever IssueProperties portals in — the tab strip
|
||||
when Plan/Artifacts have content, else a plain "Properties" title;
|
||||
window controls sit right. Vertical padding lives on the controls
|
||||
cluster, not the bar, so the tab strip can stretch to the border
|
||||
and its active underline hugs the header's bottom line. */}
|
||||
<div className="flex items-center justify-between gap-2 px-4 border-b border-border">
|
||||
<div
|
||||
id={PROPERTIES_PANE_HEADER_SLOT_ID}
|
||||
className="flex min-w-0 flex-1 items-center self-stretch"
|
||||
/>
|
||||
<div className="flex items-center gap-1 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="size-7"
|
||||
title={maximized ? "Restore panel" : "Maximize panel"}
|
||||
aria-label={maximized ? "Restore panel" : "Maximize panel"}
|
||||
onClick={maximized ? handleRestore : handleMaximize}
|
||||
>
|
||||
{maximized ? (
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={() => setPanelVisible(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div
|
||||
className={cn("p-4", maximized && "mx-auto w-full px-9")}
|
||||
style={maximized ? { maxWidth: MAXIMIZED_CONTENT_MAX_WIDTH } : undefined}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div id={PROPERTIES_PANE_FOOTER_SLOT_ID} className="shrink-0" />
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<div data-testid="navigate" data-to={to} data-replace={String(replace ?? false)} />
|
||||
),
|
||||
Outlet: () => <div data-testid="outlet">gated content</div>,
|
||||
}));
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TaskChatRedesignGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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(<Probe />));
|
||||
flushSync(() => root.unmount());
|
||||
expect(captured).toEqual({ enabled: false, loaded: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 <Navigate to="/dashboard" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -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<typeof IssueChatThread>;
|
||||
|
||||
/**
|
||||
* 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<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, agentModeLabelFor }),
|
||||
[comments, agentMap, userLabelMap, currentUserId, agentModeLabelFor],
|
||||
);
|
||||
|
||||
// Every run we might need a transcript for (history + live), deduped by id.
|
||||
const runs = useMemo<RunTranscriptSource[]>(() => {
|
||||
const map = new Map<string, RunTranscriptSource>();
|
||||
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<Set<string>>(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<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]);
|
||||
|
||||
const items = useMemo<TaskChatItem[]>(() => {
|
||||
// Settled turns for every terminal run whose transcript we have. The
|
||||
// transcript's assistant text is excluded — it already landed as the run's
|
||||
// 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<string, TaskChatTurnItem[]>();
|
||||
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) => (
|
||||
<TaskChatInteractionCard
|
||||
item={item}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
|
||||
onCancelInteraction={onCancelInteraction}
|
||||
onSubmitInteractionVerdicts={onSubmitInteractionVerdicts}
|
||||
onUploadImage={imageUploadHandler}
|
||||
externalReferences={externalReferences}
|
||||
/>
|
||||
),
|
||||
[
|
||||
agentMap,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
onSubmitInteractionAnswers,
|
||||
onCancelInteraction,
|
||||
onSubmitInteractionVerdicts,
|
||||
imageUploadHandler,
|
||||
externalReferences,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-(--tc-thread-max-h) min-h-0 flex-1 flex-col"
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{items.length === 0 ? (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{threadHeader ? (
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4 pt-4"
|
||||
data-testid="task-chat-thread-header"
|
||||
>
|
||||
{threadHeader}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatThreadView items={items} header={threadHeader} renderInteraction={renderInteraction} />
|
||||
)}
|
||||
</div>
|
||||
{showComposer ? (
|
||||
<div className="sticky bottom-0 z-10 mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
{composerAccessory}
|
||||
<TaskChatComposer
|
||||
onAdd={onAdd}
|
||||
workMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
disabled={Boolean(composerDisabledReason)}
|
||||
disabledReason={composerDisabledReason}
|
||||
onAttachImage={onAttachImage}
|
||||
onImageUpload={imageUploadHandler}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
issueStatus={issueStatus}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLElement | null>(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<string | null>(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 = (
|
||||
<div
|
||||
data-testid="plan-pane-action-bar"
|
||||
className="space-y-2 border-t border-border bg-card p-3"
|
||||
>
|
||||
{rejecting ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
placeholder={
|
||||
confirmation.payload.declineReasonPlaceholder
|
||||
?? "Optional: what would you like revised?"
|
||||
}
|
||||
aria-invalid={rejectAttempted && reasonInvalid}
|
||||
className={cn(
|
||||
"min-h-20 bg-background text-sm",
|
||||
rejectAttempted && reasonInvalid && "border-rose-500 focus-visible:ring-rose-500/25",
|
||||
)}
|
||||
/>
|
||||
{rejectAttempted && reasonInvalid ? (
|
||||
<p className="text-xs text-destructive">A decline reason is required.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<p className="text-xs text-destructive" role="alert">{actionError}</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{rejecting ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
setRejecting(false);
|
||||
setRejectAttempted(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={working !== null} onClick={handleReject}>
|
||||
{working === "reject" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sending back...
|
||||
</>
|
||||
) : (
|
||||
confirmation.payload.rejectLabel ?? "Decline"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
if (!allowDeclineReason) {
|
||||
handleReject();
|
||||
return;
|
||||
}
|
||||
setRejectAttempted(false);
|
||||
setRejecting(true);
|
||||
}}
|
||||
>
|
||||
{working === "reject" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sending back...
|
||||
</>
|
||||
) : (
|
||||
confirmation.payload.rejectLabel ?? "Decline"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="cta"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
setActionError(null);
|
||||
accept.mutate(confirmation.id);
|
||||
}}
|
||||
>
|
||||
{working === "accept" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Confirming...
|
||||
</>
|
||||
) : (
|
||||
confirmation.payload.acceptLabel ?? "Confirm"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return footerSlot ? createPortal(bar, footerSlot) : bar;
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel";
|
||||
import { pickTextColorForPillBg } from "@/lib/color-contrast";
|
||||
import { issueStatusText } from "@/lib/status-colors";
|
||||
import { Link } from "@/lib/router";
|
||||
|
|
@ -10,6 +12,7 @@ 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";
|
||||
|
|
@ -52,6 +55,9 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab";
|
||||
import { IssuePropertiesArtifactsTab } from "./IssuePropertiesArtifactsTab";
|
||||
import { User, ArrowUpRight, Plus, GitBranch, FolderOpen, HardDrive, Check, Clock, RotateCcw, Loader2, CheckCircle2, ArchiveRestore } from "lucide-react";
|
||||
import { AgentIcon } from "../AgentIconPicker";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "../InlineEntitySelector";
|
||||
|
|
@ -163,6 +169,41 @@ export function IssueProperties({
|
|||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
|
||||
// Task Chat Redesign: gate the Properties | Plans | Artifacts tab shell. Flag
|
||||
// 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/Artifacts only earn a tab when they have content; with neither, the
|
||||
// header bar shows a plain "Properties" title instead of a one-tab strip.
|
||||
// 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;
|
||||
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
|
||||
|
|
@ -1967,7 +2008,7 @@ export function IssueProperties({
|
|||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
const propertiesBody = (
|
||||
<div>
|
||||
<PropertySection title="Triage" first>
|
||||
<PropertyRow label="Status">
|
||||
|
|
@ -2445,4 +2486,87 @@ export function IssueProperties({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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>
|
||||
);
|
||||
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}
|
||||
<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}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
interface IssuePropertiesArtifactsTabProps {
|
||||
issue: Issue;
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifacts tab of the redesigned properties pane (flag: enableTaskChatRedesign).
|
||||
*
|
||||
* A read-only gallery of the task's attachments / work products. Uploads,
|
||||
* previews, and deletes stay on the existing attachment surfaces for the
|
||||
* baseline; this tab consolidates the "what did this task produce" view.
|
||||
*/
|
||||
export function IssuePropertiesArtifactsTab({ issue }: IssuePropertiesArtifactsTabProps) {
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.issues.attachments(issue.id),
|
||||
queryFn: () => issuesApi.listAttachments(issue.id),
|
||||
});
|
||||
const attachments = data ?? [];
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return (
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
No artifacts yet. Attachments and work products will appear here.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1 py-2">
|
||||
{attachments.map((a) => (
|
||||
<li key={a.id}>
|
||||
<a
|
||||
href={a.contentPath}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm hover:bg-accent/50"
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{a.originalFilename ?? a.objectKey}</span>
|
||||
<span className="shrink-0 text-(length:--text-micro) text-muted-foreground">{formatBytes(a.byteSize)}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) {
|
||||
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
|
||||
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
|
||||
});
|
||||
const hasPlans = (data?.length ?? 0) > 0;
|
||||
|
||||
if (!planDocument && !hasPlans) {
|
||||
return (
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
{planDocumentLoading
|
||||
? "Loading plan…"
|
||||
: "No plan yet. The plan document, 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. */}
|
||||
{planDocument ? <IssuePlanConfirmationActionBar issue={issue} inline={inline} /> : null}
|
||||
{planDocument ? (
|
||||
<section data-testid="issue-plan-document" className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{`Revision ${planDocument.latestRevisionNumber ?? 1} · updated ${new Date(planDocument.updatedAt).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}`}
|
||||
</div>
|
||||
<MarkdownBody>{planDocument.body}</MarkdownBody>
|
||||
</section>
|
||||
) : null}
|
||||
{hasPlans ? (
|
||||
<IssuePlanDecompositionsSection issueId={issue.id} issueIdentifier={issue.identifier} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import type { TaskChatMessageItem } from "./task-chat-model";
|
||||
|
||||
interface TaskChatBubbleProps {
|
||||
item: TaskChatMessageItem;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
|
||||
if (item.author === "system") {
|
||||
return (
|
||||
<div className="tc-enter-bubble flex justify-center py-1">
|
||||
<p className="max-w-(--pct-85) text-center text-xs text-muted-foreground">{item.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHuman = item.author === "human";
|
||||
return (
|
||||
<div className={cn("tc-enter-bubble group 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.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}
|
||||
<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 softBreaks linkIssueReferences>
|
||||
{item.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
{item.optimistic ? (
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
|
||||
{item.optimistic === "queued" ? "Queued" : "Sending…"}
|
||||
</span>
|
||||
) : item.timestamp ? (
|
||||
<span className="px-1 text-(length:--text-micro) text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{item.timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactElement } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TaskChatComposer } from "./TaskChatComposer";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
// jsdom has no object-URL support; the composer uses it for image previews.
|
||||
URL.createObjectURL = vi.fn(() => "blob:mock-preview");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
flushSync(() => root!.render(ui));
|
||||
}
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
flushSync(() => {
|
||||
nativeSetter.call(textarea, value);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function pressEnter(
|
||||
textarea: HTMLTextAreaElement,
|
||||
modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {},
|
||||
) {
|
||||
flushSync(() => {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", ...modifiers, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function pressCmdEnter(textarea: HTMLTextAreaElement) {
|
||||
pressEnter(textarea, { metaKey: true });
|
||||
}
|
||||
|
||||
async function flushAsync() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function input() {
|
||||
return container.querySelector<HTMLTextAreaElement>('[data-testid="task-chat-composer-input"]')!;
|
||||
}
|
||||
|
||||
function sendButton() {
|
||||
return container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
|
||||
}
|
||||
|
||||
describe("TaskChatComposer", () => {
|
||||
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
expect(sendButton().disabled).toBe(true);
|
||||
setTextareaValue(input(), " hello there ");
|
||||
expect(sendButton().disabled).toBe(false);
|
||||
|
||||
pressCmdEnter(input());
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined);
|
||||
expect(input().value).toBe("");
|
||||
});
|
||||
|
||||
it("submits on Ctrl+Enter", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
setTextareaValue(input(), "hello");
|
||||
pressEnter(input(), { ctrlKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined);
|
||||
});
|
||||
|
||||
it("does not submit on plain Enter or Shift+Enter (newline stays in the draft)", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
|
||||
|
||||
setTextareaValue(input(), "line one");
|
||||
pressEnter(input());
|
||||
pressEnter(input(), { shiftKey: true });
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).not.toHaveBeenCalled();
|
||||
expect(input().value).toBe("line one");
|
||||
});
|
||||
|
||||
it("cycles the pending mode with Shift+Tab and applies it on submit", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
const onWorkModeChange = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<TaskChatComposer onAdd={onAdd} workMode="standard" onWorkModeChange={onWorkModeChange} />,
|
||||
);
|
||||
|
||||
const chip = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-mode"]')!;
|
||||
expect(chip.getAttribute("data-pending-work-mode")).toBe("standard");
|
||||
expect(chip.textContent).toContain("Agent");
|
||||
|
||||
flushSync(() => {
|
||||
input().dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
expect(chip.getAttribute("data-pending-work-mode")).toBe("planning");
|
||||
expect(chip.textContent).toContain("Plan");
|
||||
|
||||
setTextareaValue(input(), "do the plan");
|
||||
pressCmdEnter(input());
|
||||
await flushAsync();
|
||||
|
||||
expect(onWorkModeChange).toHaveBeenCalledWith("planning");
|
||||
expect(onAdd).toHaveBeenCalledWith("do the plan", undefined, undefined);
|
||||
});
|
||||
|
||||
it("passes reopen=true when the issue resumes-to-todo and the assignee is an agent", async () => {
|
||||
const onAdd = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={onAdd}
|
||||
workMode="standard"
|
||||
issueStatus="done"
|
||||
currentAssigneeValue="agent:a1"
|
||||
/>,
|
||||
);
|
||||
|
||||
setTextareaValue(input(), "wake up");
|
||||
pressCmdEnter(input());
|
||||
await flushAsync();
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined);
|
||||
});
|
||||
|
||||
it("hides the attach button without an upload handler and shows it with one", () => {
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-attach"]')).toBeNull();
|
||||
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={vi.fn()}
|
||||
workMode="standard"
|
||||
onAttachImage={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-attach"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uploads pasted files via onAttachImage and inserts the reference into the draft", async () => {
|
||||
const onAttachImage = vi.fn().mockResolvedValue({
|
||||
contentPath: "/attachments/shot.png",
|
||||
originalFilename: "shot.png",
|
||||
});
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
|
||||
|
||||
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files: [file] } });
|
||||
flushSync(() => {
|
||||
input().dispatchEvent(paste);
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(onAttachImage).toHaveBeenCalledWith(file);
|
||||
expect(input().value).toContain("");
|
||||
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]');
|
||||
expect(chips?.textContent).toContain("shot.png");
|
||||
});
|
||||
|
||||
it("shows an object-URL thumbnail for a pasted image attachment", async () => {
|
||||
const onAttachImage = vi.fn().mockResolvedValue({
|
||||
contentPath: "/attachments/shot.png",
|
||||
originalFilename: "shot.png",
|
||||
});
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
|
||||
|
||||
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files: [file] } });
|
||||
flushSync(() => {
|
||||
input().dispatchEvent(paste);
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(file);
|
||||
const thumb = container.querySelector<HTMLImageElement>(
|
||||
'[data-testid="task-chat-composer-attachments"] img',
|
||||
);
|
||||
expect(thumb?.src).toBe("blob:mock-preview");
|
||||
expect(thumb?.alt).toBe("");
|
||||
});
|
||||
|
||||
it("renders no thumbnail for non-image attachments and revokes previews on unmount", async () => {
|
||||
const onAttachImage = vi.fn().mockResolvedValue({
|
||||
contentPath: "/attachments/notes.txt",
|
||||
originalFilename: "notes.txt",
|
||||
});
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
|
||||
|
||||
const image = new File(["png-bytes"], "shot.png", { type: "image/png" });
|
||||
const text = new File(["plain"], "notes.txt", { type: "text/plain" });
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files: [image, text] } });
|
||||
flushSync(() => {
|
||||
input().dispatchEvent(paste);
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// Only the image chip carries a thumbnail; the text chip stays text-only.
|
||||
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]')!;
|
||||
expect(chips.textContent).toContain("notes.txt");
|
||||
expect(chips.querySelectorAll("img")).toHaveLength(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
|
||||
flushSync(() => root!.unmount());
|
||||
root = null;
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-preview");
|
||||
});
|
||||
|
||||
it("shows the assignee combobox only when reassign is enabled, with the current label", () => {
|
||||
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
|
||||
expect(container.querySelector('[data-testid="task-chat-composer-assignee"]')).toBeNull();
|
||||
|
||||
render(
|
||||
<TaskChatComposer
|
||||
onAdd={vi.fn()}
|
||||
workMode="standard"
|
||||
enableReassign
|
||||
reassignOptions={[
|
||||
{ id: "agent:a1", label: "Clippy" },
|
||||
{ id: "user:u1", label: "Sam" },
|
||||
]}
|
||||
currentAssigneeValue="user:u1"
|
||||
/>,
|
||||
);
|
||||
const trigger = container.querySelector('[data-testid="task-chat-composer-assignee"]');
|
||||
expect(trigger?.textContent).toContain("Sam");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,475 @@
|
|||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type ClipboardEvent as ReactClipboardEvent,
|
||||
type CSSProperties,
|
||||
type DragEvent as ReactDragEvent,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertTriangle, ArrowUp, Check, ChevronDown, Loader2, Plus } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
|
||||
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
|
||||
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
|
||||
|
||||
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
|
||||
interface CommentReassignment {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
}
|
||||
|
||||
interface TaskChatComposerProps {
|
||||
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => 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>;
|
||||
enableReassign?: boolean;
|
||||
reassignOptions?: InlineEntityOption[];
|
||||
currentAssigneeValue?: string;
|
||||
issueStatus?: string;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
status: "uploading" | "attached" | "error";
|
||||
error?: string;
|
||||
/** Object URL for an immediate thumbnail while (and after) uploading. */
|
||||
previewUrl?: string;
|
||||
isImage?: boolean;
|
||||
};
|
||||
|
||||
/** 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 hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
|
||||
}
|
||||
|
||||
/**
|
||||
* Composer for the redesigned thread (v7 spec): textarea 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; the picked mode is applied on submit.
|
||||
* Attachments upload via `onAttachImage` (or the inline `onImageUpload`
|
||||
* fallback) from the + button, paste, or drag-drop.
|
||||
*/
|
||||
export function TaskChatComposer({
|
||||
onAdd,
|
||||
workMode,
|
||||
onWorkModeChange,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
placeholder,
|
||||
onAttachImage,
|
||||
onImageUpload,
|
||||
enableReassign = false,
|
||||
reassignOptions,
|
||||
currentAssigneeValue = "",
|
||||
issueStatus,
|
||||
}: 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 [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragDepthRef = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const previewUrlsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Object URLs created for image previews must be revoked or they leak the
|
||||
// underlying blobs for the page's lifetime.
|
||||
useEffect(() => {
|
||||
const urls = previewUrlsRef.current;
|
||||
return () => {
|
||||
for (const url of urls) URL.revokeObjectURL(url);
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function releasePreview(url: string | undefined) {
|
||||
if (!url) return;
|
||||
URL.revokeObjectURL(url);
|
||||
previewUrlsRef.current.delete(url);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
function insertReference(name: string, url: string, asImage: boolean) {
|
||||
const safeName = name.replace(/[[\]]/g, "\\$&");
|
||||
const markdown = asImage ? `` : `[${safeName}](${url})`;
|
||||
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
|
||||
}
|
||||
|
||||
async function attachFile(file: File) {
|
||||
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
|
||||
const isImage = file.type.startsWith("image/");
|
||||
// Create the preview up front so the thumbnail appears while uploading.
|
||||
const previewUrl =
|
||||
isImage && typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : undefined;
|
||||
if (previewUrl) previewUrlsRef.current.add(previewUrl);
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{ id, name: file.name, status: "uploading", previewUrl, isImage: isImage || undefined },
|
||||
]);
|
||||
try {
|
||||
if (onAttachImage) {
|
||||
const attachment = await onAttachImage(file);
|
||||
const name = attachment?.originalFilename ?? file.name;
|
||||
if (attachment?.contentPath) {
|
||||
insertReference(name, attachment.contentPath, file.type.startsWith("image/"));
|
||||
}
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, name, status: "attached" } : item)),
|
||||
);
|
||||
} else if (onImageUpload && file.type.startsWith("image/")) {
|
||||
const url = await onImageUpload(file);
|
||||
insertReference(file.name, url, true);
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, status: "attached" } : item)),
|
||||
);
|
||||
} else {
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "error", error: "This file type cannot be attached here" }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
setAttachments((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed" }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachFiles(files: Iterable<File>) {
|
||||
for (const file of files) {
|
||||
await attachFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
|
||||
const files = evt.target.files;
|
||||
if (files && files.length > 0) void attachFiles(Array.from(files));
|
||||
evt.target.value = "";
|
||||
}
|
||||
|
||||
function handlePaste(evt: ReactClipboardEvent<HTMLTextAreaElement>) {
|
||||
if (!canAcceptFiles) return;
|
||||
const files = Array.from(evt.clipboardData?.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
evt.preventDefault();
|
||||
void attachFiles(files);
|
||||
}
|
||||
|
||||
function handleFileDragEnter(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setIsDragOver(true);
|
||||
}
|
||||
|
||||
function handleFileDragOver(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
evt.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
|
||||
function handleFileDragLeave(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setIsDragOver(false);
|
||||
}
|
||||
|
||||
function handleFileDrop(evt: ReactDragEvent<HTMLDivElement>) {
|
||||
if (!canAcceptFiles || !hasFilePayload(evt)) return;
|
||||
evt.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsDragOver(false);
|
||||
const files = evt.dataTransfer?.files;
|
||||
if (files && files.length > 0) void attachFiles(Array.from(files));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || submitting || disabled) return;
|
||||
|
||||
const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue;
|
||||
const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined;
|
||||
const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined;
|
||||
|
||||
setSubmitting(true);
|
||||
setBody("");
|
||||
try {
|
||||
if (pendingMode !== workMode && onWorkModeChange) {
|
||||
await onWorkModeChange(pendingMode);
|
||||
}
|
||||
await onAdd(trimmed, reopen, reassignment);
|
||||
setAttachments((prev) => {
|
||||
for (const item of prev) releasePreview(item.previewUrl);
|
||||
return [];
|
||||
});
|
||||
setPendingAssignee(null);
|
||||
} catch {
|
||||
setBody(trimmed); // restore on failure
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
isDragOver && "ring-2 ring-primary/40",
|
||||
)}
|
||||
onDragEnter={handleFileDragEnter}
|
||||
onDragOver={handleFileDragOver}
|
||||
onDragLeave={handleFileDragLeave}
|
||||
onDrop={handleFileDrop}
|
||||
>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Cmd/Ctrl+Enter posts; plain Enter inserts a newline (Enter alone
|
||||
// was too easy to trip while drafting).
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setPendingMode((mode) => nextWorkMode(mode));
|
||||
}
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? (disabledReason ?? "Composer disabled") : effectivePlaceholder}
|
||||
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"
|
||||
/>
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<div className="mb-1 flex flex-wrap gap-1 px-1" data-testid="task-chat-composer-attachments">
|
||||
{attachments.map((attachment) => (
|
||||
<span
|
||||
key={attachment.id}
|
||||
className={cn(
|
||||
"inline-flex min-w-0 items-center gap-1 rounded-md border px-2 py-0.5 text-xs",
|
||||
attachment.status === "error"
|
||||
? "border-destructive/50 bg-destructive/10 text-destructive"
|
||||
: "border-border bg-muted/30 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{attachment.isImage && attachment.previewUrl ? (
|
||||
<img
|
||||
src={attachment.previewUrl}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{attachment.status === "uploading" ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
|
||||
) : attachment.status === "error" ? (
|
||||
<AlertTriangle className="h-3 w-3 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Check className="h-3 w-3 shrink-0" aria-hidden />
|
||||
)}
|
||||
<span className="max-w-40 truncate font-medium text-foreground">{attachment.name}</span>
|
||||
<span className="shrink-0">
|
||||
·{" "}
|
||||
{attachment.status === "uploading"
|
||||
? "Uploading…"
|
||||
: attachment.status === "error"
|
||||
? (attachment.error ?? "Upload failed")
|
||||
: "Attached"}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{canAcceptFiles ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden />
|
||||
</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">
|
||||
{workModeMetaList().map((m) => {
|
||||
const Icon = m.icon;
|
||||
const selected = m.value === pendingMode;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={m.value}
|
||||
onSelect={() => setPendingMode(m.value)}
|
||||
style={
|
||||
selected
|
||||
? { backgroundColor: `color-mix(in srgb, ${modeHue(m.value)} 12%, transparent)` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</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}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit()}
|
||||
disabled={disabled || submitting || body.trim().length === 0}
|
||||
title="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"
|
||||
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 />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { RequestConfirmationInteraction } from "@/lib/issue-thread-interactions";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
|
||||
import { TaskChatThreadView } from "./TaskChatThreadView";
|
||||
import type { TaskChatInteractionItem } from "./task-chat-model";
|
||||
|
||||
function createRequestConfirmation(
|
||||
overrides: Partial<RequestConfirmationInteraction> = {},
|
||||
): RequestConfirmationInteraction {
|
||||
return {
|
||||
id: "confirmation-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "request_confirmation",
|
||||
title: "Approve the plan",
|
||||
summary: "Review and approve the latest plan.",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
createdByAgentId: "agent-1",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the plan?",
|
||||
},
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function interactionItem(
|
||||
interaction: RequestConfirmationInteraction,
|
||||
): TaskChatInteractionItem {
|
||||
return { id: `interaction:${interaction.id}`, kind: "interaction", interaction };
|
||||
}
|
||||
|
||||
describe("TaskChatInteractionCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders a pending confirmation as a full interaction card", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatInteractionCard item={interactionItem(createRequestConfirmation())} />
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const card = container.querySelector('[data-testid="task-chat-interaction"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.id).toBe("interaction-confirmation-1");
|
||||
expect(container.textContent).toContain("Approve the plan");
|
||||
});
|
||||
|
||||
it("puts the primary CTA on the right via a reversed action row", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatInteractionCard item={interactionItem(createRequestConfirmation())} />
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const confirm = buttons.find((button) => button.textContent === "Confirm");
|
||||
const decline = buttons.find((button) => button.textContent === "Decline");
|
||||
expect(confirm).not.toBeUndefined();
|
||||
expect(decline).not.toBeUndefined();
|
||||
const row = confirm?.parentElement;
|
||||
expect(row).toBe(decline?.parentElement);
|
||||
expect(row?.className).toContain("flex-row-reverse");
|
||||
});
|
||||
|
||||
it("demotes an expired confirmation to a marker row", () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatInteractionCard
|
||||
item={interactionItem(createRequestConfirmation({ status: "expired" }))}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="task-chat-interaction"]')).toBeNull();
|
||||
expect(container.textContent).toContain("Approve the plan");
|
||||
expect(container.textContent).toContain("expired");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThreadView interaction items", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders interaction items through renderInteraction", () => {
|
||||
const item = interactionItem(createRequestConfirmation());
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatThreadView
|
||||
items={[item]}
|
||||
scroll={false}
|
||||
renderInteraction={(it) => <TaskChatInteractionCard item={it} />}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="task-chat-interaction"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for interaction items without renderInteraction", () => {
|
||||
const item = interactionItem(createRequestConfirmation());
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<ThemeProvider>
|
||||
<TaskChatThreadView items={[item]} scroll={false} />
|
||||
</ThemeProvider>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="task-chat-interaction"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
|
||||
import { TaskChatMarker } from "./TaskChatMarker";
|
||||
import type { TaskChatInteractionItem } from "./task-chat-model";
|
||||
|
||||
type InteractionCardProps = Omit<ComponentProps<typeof IssueThreadInteractionCard>, "interaction">;
|
||||
|
||||
export interface TaskChatInteractionCardProps extends InteractionCardProps {
|
||||
item: TaskChatInteractionItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* v7-grammar wrapper for issue-thread interactions (plan confirmation,
|
||||
* question card, suggested tasks…) inside the redesigned thread: cards are the
|
||||
* only rows that get bubble-level emphasis, so the shared
|
||||
* IssueThreadInteractionCard renders full-width with the standard bubble
|
||||
* entrance. Expired confirmations demote to a marker row — superseded asks are
|
||||
* history, not calls to action (legacy-thread parity).
|
||||
*/
|
||||
export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteractionCardProps) {
|
||||
const interaction = item.interaction;
|
||||
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
|
||||
return (
|
||||
<TaskChatMarker
|
||||
item={{
|
||||
id: item.id,
|
||||
kind: "marker",
|
||||
variant: "turn_boundary",
|
||||
label: interaction.title ?? "Confirmation",
|
||||
detail: "expired",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
id={`interaction-${interaction.id}`}
|
||||
className="tc-enter-bubble w-full"
|
||||
data-testid="task-chat-interaction"
|
||||
>
|
||||
<IssueThreadInteractionCard interaction={interaction} primaryActionOnRight {...cardProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { CircleDot, OctagonX, Flag } from "lucide-react";
|
||||
import type { TaskChatMarkerItem } from "./task-chat-model";
|
||||
|
||||
const VARIANT_ICON = {
|
||||
session_start: CircleDot,
|
||||
interrupted: OctagonX,
|
||||
turn_boundary: Flag,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Lifecycle divider — renders a state boundary (session start, interruption,
|
||||
* turn boundary) as a recessed rule, never a bubble. Interruptions use a dashed
|
||||
* destructive rule to read as a distinct state.
|
||||
*/
|
||||
export function TaskChatMarker({ item }: { item: TaskChatMarkerItem }) {
|
||||
const Icon = VARIANT_ICON[item.variant];
|
||||
const interrupted = item.variant === "interrupted";
|
||||
return (
|
||||
<div className="tc-enter-marker flex items-center gap-2 py-1 text-xs text-muted-foreground">
|
||||
<span className={cn("h-px flex-1", interrupted ? "border-t border-dashed border-destructive/50" : "bg-border")} />
|
||||
<span className={cn("flex items-center gap-1.5", interrupted && "text-destructive")}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{item.label}</span>
|
||||
{item.detail ? <span className="text-muted-foreground">· {item.detail}</span> : null}
|
||||
</span>
|
||||
<span className={cn("h-px flex-1", interrupted ? "border-t border-dashed border-destructive/50" : "bg-border")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Check, Circle, Loader2 } from "lucide-react";
|
||||
import type { TaskChatPlan, TaskChatPlanEntryStatus } from "./task-chat-model";
|
||||
|
||||
const STATUS_ICON: Record<TaskChatPlanEntryStatus, { Icon: typeof Check; spin: boolean; tone: string }> = {
|
||||
completed: { Icon: Check, spin: false, tone: "text-(--status-task-icon-done)" },
|
||||
in_progress: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
pending: { Icon: Circle, spin: false, tone: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
const PRIORITY_TONE = {
|
||||
high: "text-destructive",
|
||||
medium: "text-(--status-agent-paused)",
|
||||
low: "text-muted-foreground",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Structured plan/todo viewer (ACP Plan / PlanEntry). Used both by the harness
|
||||
* "plan-todo" state and by the properties-pane Plans tab. Renders a live
|
||||
* checklist with per-entry status and a revision label; entries stagger in.
|
||||
*/
|
||||
export function TaskChatPlanView({ plan }: { plan: TaskChatPlan }) {
|
||||
const done = plan.entries.filter((e) => e.status === "completed").length;
|
||||
return (
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Plan</span>
|
||||
<span>· rev {plan.revision}</span>
|
||||
<span className="ml-auto">
|
||||
{done}/{plan.entries.length} done
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{plan.entries.map((entry, i) => {
|
||||
const { Icon, spin, tone } = STATUS_ICON[entry.status];
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="tc-enter-plan-entry flex items-start gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm"
|
||||
style={{ animationDelay: `calc(var(--motion-plan-entry-stagger) * ${i})` }}
|
||||
>
|
||||
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", tone, spin && "animate-spin")} />
|
||||
<span className={cn("min-w-0 flex-1", entry.status === "completed" && "text-muted-foreground line-through")}>
|
||||
{entry.content}
|
||||
</span>
|
||||
<span className={cn("shrink-0 text-(length:--text-nano) uppercase tracking-wide", PRIORITY_TONE[entry.priority])}>
|
||||
{entry.priority}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
|
||||
import type { TaskChatStatusItem } from "./task-chat-model";
|
||||
import { toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
function elapsedLabel(ms?: number): string | null {
|
||||
if (ms == null) return null;
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
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 every 100ms 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);
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
awaiting_approval: { Icon: ShieldQuestion, spin: false, tone: "text-primary" },
|
||||
interrupted: { Icon: OctagonX, spin: false, tone: "text-destructive" },
|
||||
refused: { Icon: Ban, spin: false, tone: "text-destructive" },
|
||||
truncated: { Icon: Scissors, spin: false, tone: "text-(--status-agent-paused)" },
|
||||
} as const;
|
||||
|
||||
interface TaskChatStatusPillProps {
|
||||
item: TaskChatStatusItem;
|
||||
onApprovalDecision?: (optionId: string) => 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
|
||||
* exposes the ACP permission options as actions.
|
||||
*/
|
||||
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 elapsed = elapsedLabel(item.elapsedMs);
|
||||
|
||||
if (live) {
|
||||
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
|
||||
const ToolIcon = item.toolName ? toolTaxonomy(item.toolName).icon : null;
|
||||
return (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
{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">{item.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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"tc-enter-status flex flex-col gap-2 rounded-lg border px-3 py-2 text-sm",
|
||||
awaiting ? "tc-approval border-primary/40 bg-primary/5" : "border-border bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className={cn("h-4 w-4 shrink-0", tone, spin && "animate-spin")} />
|
||||
<span className="min-w-0 truncate font-medium">{item.label}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{elapsed ? <span>{elapsed}</span> : null}
|
||||
{item.tokens ? (
|
||||
<span>
|
||||
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
{item.detail ? <p className="text-xs text-muted-foreground">{item.detail}</p> : null}
|
||||
{awaiting && item.approval ? (
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||
{item.approval.options.map((opt) => {
|
||||
const reject = opt.kind.startsWith("reject");
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => onApprovalDecision?.(opt.id)}
|
||||
className={cn(
|
||||
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
reject
|
||||
? "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
: "border-primary bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { TaskChatThinkingItem } from "./task-chat-model";
|
||||
|
||||
/**
|
||||
* Chain-of-thought (ACP agent_thought_chunk) as a flat left-railed block (v7):
|
||||
* a chevron header whose label shimmers while streaming ("Thinking…") and
|
||||
* settles to "Thought for Ns", over a recessed pre-wrap body. Streams open;
|
||||
* settled history arrives collapsed and re-expands on click. No card chrome —
|
||||
* thinking is activity metadata, subordinate to real content.
|
||||
*/
|
||||
export function TaskChatThinkingBlock({ item }: { item: TaskChatThinkingItem }) {
|
||||
const [open, setOpen] = useState(!item.collapsed);
|
||||
const settledLabel = item.summaryLabel ?? "Thought process";
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-border pl-3 text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-1.5 py-0.5 transition-colors hover:text-foreground"
|
||||
>
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} aria-hidden />
|
||||
{item.streaming ? (
|
||||
<span className="shimmer-text shimmer-text-muted font-medium">Thinking…</span>
|
||||
) : (
|
||||
<span className="font-medium">{settledLabel}</span>
|
||||
)}
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="space-y-1 pb-1 pt-0.5">
|
||||
{item.lines.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="tc-enter-cot-line whitespace-pre-wrap"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TaskChatInteractionItem, TaskChatItem } from "./task-chat-model";
|
||||
import { TaskChatTurn } from "./TaskChatTurn";
|
||||
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;
|
||||
className?: string;
|
||||
/** When false, render the list without the scroll container (e.g. previews). */
|
||||
scroll?: boolean;
|
||||
}
|
||||
|
||||
function renderItem(
|
||||
item: TaskChatItem,
|
||||
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
|
||||
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
|
||||
) {
|
||||
switch (item.kind) {
|
||||
case "message":
|
||||
return <TaskChatBubble item={item} />;
|
||||
case "marker":
|
||||
return <TaskChatMarker item={item} />;
|
||||
case "thinking":
|
||||
return <TaskChatThinkingBlock item={item} />;
|
||||
case "tool":
|
||||
return <TaskChatToolCard item={item} />;
|
||||
case "status":
|
||||
return (
|
||||
<TaskChatStatusPill
|
||||
item={item}
|
||||
onApprovalDecision={(optionId) => onApprovalDecision?.(item.id, optionId)}
|
||||
/>
|
||||
);
|
||||
case "usage":
|
||||
return <TaskChatUsageReadout item={item} />;
|
||||
case "interaction":
|
||||
return renderInteraction ? renderInteraction(item) : 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;
|
||||
return _never;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational render layer for the redesigned task thread. Consumed by both
|
||||
* the live thread (adapter over comment/run props) and the dev harness
|
||||
* (synthetic fixtures). Owns no data fetching — it maps a normalized
|
||||
* TaskChatItem[] onto the primitives inside the auto-follow scroller.
|
||||
*/
|
||||
export function TaskChatThreadView({
|
||||
items,
|
||||
header,
|
||||
onApprovalDecision,
|
||||
renderInteraction,
|
||||
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}
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!scroll) return body;
|
||||
|
||||
// Include a cheap content signature so streaming growth (text lengthening
|
||||
// without the item count changing) still advances the auto-follow key.
|
||||
const signatureOf = (it: TaskChatItem): number => {
|
||||
if (it.kind === "message") return it.text.length;
|
||||
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") {
|
||||
return it.settled ? 1 : it.items.reduce((n, child) => n + signatureOf(child), it.items.length);
|
||||
}
|
||||
return 1;
|
||||
};
|
||||
const contentKey = items.reduce((acc, it) => acc + signatureOf(it), items.length);
|
||||
|
||||
return <TaskMessageScroller contentKey={contentKey}>{body}</TaskMessageScroller>;
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
ShieldX,
|
||||
X,
|
||||
} 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" },
|
||||
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.
|
||||
*/
|
||||
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>
|
||||
) : 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}
|
||||
{item.diff ? (
|
||||
<div className="tc-reveal-diff ml-2.5 mt-1 border-l-2 border-border pl-2.5">
|
||||
<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>
|
||||
</div>
|
||||
{item.diff.lines?.length ? (
|
||||
<pre className="overflow-x-auto rounded-sm bg-muted/50 p-2 text-(length:--text-micro) leading-relaxed">
|
||||
{item.diff.lines.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
l.kind === "add" && "text-(--status-task-icon-done)",
|
||||
l.kind === "remove" && "text-destructive",
|
||||
l.kind === "context" && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{l.kind === "add" ? "+" : l.kind === "remove" ? "−" : " "} {l.text}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TaskChatTurn, turnSummaryText } from "./TaskChatTurn";
|
||||
import { buildTurnSummary } from "./transcript-adapter";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import type { TaskChatTurnItem } from "./task-chat-model";
|
||||
|
||||
const SETTLED: TaskChatTurnItem = {
|
||||
id: "t1",
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
summary: { durationLabel: "38s", toolCount: 3, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
|
||||
items: [{ id: "c1", kind: "tool", name: "read auth.ts", status: "completed" }],
|
||||
};
|
||||
|
||||
describe("turnSummaryText", () => {
|
||||
it("joins the known parts with dots", () => {
|
||||
expect(turnSummaryText(SETTLED.summary)).toBe("Worked · 38s · 3 tools · +34 −3 · 12.3k tokens");
|
||||
});
|
||||
|
||||
it("omits unknown parts and singularizes one tool", () => {
|
||||
expect(turnSummaryText({ toolCount: 1, added: 0, removed: 0 })).toBe("Worked · 1 tool");
|
||||
});
|
||||
|
||||
it("labels failed turns Stopped", () => {
|
||||
expect(turnSummaryText({ toolCount: 0, added: 0, removed: 0, failed: true })).toBe("Stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTurnSummary", () => {
|
||||
it("counts tools, diff lines and result tokens from a transcript", () => {
|
||||
const entries: TranscriptEntry[] = [
|
||||
{ kind: "tool_call", ts: "2026-07-29T10:00:00Z", name: "edit", input: {} },
|
||||
{ kind: "diff", ts: "2026-07-29T10:00:05Z", changeType: "add", text: "+a" },
|
||||
{ kind: "diff", ts: "2026-07-29T10:00:05Z", changeType: "remove", text: "-b" },
|
||||
{
|
||||
kind: "result", ts: "2026-07-29T10:00:38Z", text: "done", inputTokens: 12000,
|
||||
outputTokens: 300, cachedTokens: 0, costUsd: 0.01, subtype: "success", isError: false, errors: [],
|
||||
},
|
||||
];
|
||||
const summary = buildTurnSummary(entries);
|
||||
expect(summary.toolCount).toBe(1);
|
||||
expect(summary.added).toBe(1);
|
||||
expect(summary.removed).toBe(1);
|
||||
expect(summary.tokensLabel).toBe("12.3k tokens");
|
||||
expect(summary.durationLabel).toBe("38s");
|
||||
});
|
||||
|
||||
it("prefers an explicit duration and flags failure", () => {
|
||||
const summary = buildTurnSummary([], { durationMs: 95_000, failed: true });
|
||||
expect(summary.durationLabel).toBe("1m 35s");
|
||||
expect(summary.failed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatTurn", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const renderTurn = (item: TaskChatTurnItem) => {
|
||||
flushSync(() => {
|
||||
root.render(<TaskChatTurn item={item} renderChild={(c) => <span>{c.id}</span>} />);
|
||||
});
|
||||
};
|
||||
|
||||
const fold = () => container.querySelector(".tc-turn-fold");
|
||||
const summaryBtn = () =>
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-summary"]');
|
||||
|
||||
it("renders a settled turn folded with its summary line", () => {
|
||||
renderTurn(SETTLED);
|
||||
// Label and mono metrics are adjacent spans (v7 runsum grammar).
|
||||
expect(summaryBtn()?.textContent).toContain("Worked");
|
||||
expect(summaryBtn()?.textContent).toContain("38s · 3 tools · +34 −3 · 12.3k tokens");
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
|
||||
it("toggles open on summary click", () => {
|
||||
renderTurn(SETTLED);
|
||||
flushSync(() => summaryBtn()!.click());
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
});
|
||||
|
||||
it("renders a live turn expanded with no summary line", () => {
|
||||
renderTurn({ ...SETTLED, settled: false });
|
||||
expect(summaryBtn()).toBeNull();
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("false");
|
||||
});
|
||||
|
||||
it("folds when the item settles while mounted", () => {
|
||||
renderTurn({ ...SETTLED, settled: false });
|
||||
renderTurn({ ...SETTLED, animateFold: true });
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check, ChevronRight, X } from "lucide-react";
|
||||
import type { TaskChatTurnItem, TaskChatTurnChildItem } from "./task-chat-model";
|
||||
|
||||
interface TaskChatTurnProps {
|
||||
item: TaskChatTurnItem;
|
||||
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
|
||||
}
|
||||
|
||||
/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
|
||||
export function turnSummaryMetrics(summary: TaskChatTurnItem["summary"]): string {
|
||||
const parts: string[] = [];
|
||||
if (summary.durationLabel) parts.push(summary.durationLabel);
|
||||
if (summary.toolCount > 0) parts.push(`${summary.toolCount} tool${summary.toolCount === 1 ? "" : "s"}`);
|
||||
if (summary.added > 0 || summary.removed > 0) parts.push(`+${summary.added} −${summary.removed}`);
|
||||
if (summary.tokensLabel) parts.push(summary.tokensLabel);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** "✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens" (parts omitted when unknown). */
|
||||
export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
|
||||
const metrics = turnSummaryMetrics(summary);
|
||||
const label = summary.failed ? "Stopped" : "Worked";
|
||||
return metrics ? `${label} · ${metrics}` : label;
|
||||
}
|
||||
|
||||
/**
|
||||
* One agent turn's activity, foldable to a one-line summary once settled.
|
||||
*
|
||||
* While the turn is live it renders fully interleaved with no summary line.
|
||||
* When it settles the activity folds behind a "✓ Worked · …" line — animated
|
||||
* (grid-rows via .tc-turn-fold, --motion-turn-fold) when the settle happens
|
||||
* on-screen, instant for turns that load already settled (the fold class only
|
||||
* transitions on state CHANGE, so first paint in the folded state never
|
||||
* animates). Clicking the summary folds/unfolds with the same motion.
|
||||
* prefers-reduced-motion zeroes the transition in CSS.
|
||||
*/
|
||||
export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
|
||||
const [open, setOpen] = useState(!item.settled);
|
||||
const [prevSettled, setPrevSettled] = useState(item.settled);
|
||||
|
||||
// Live → settled transition observed while mounted: fold (animated via CSS
|
||||
// unless reduced motion). Adjusted during render (not in an effect) so the
|
||||
// fold state lands in the same commit — no unfolded flash. Turns mounted
|
||||
// already-settled start folded via the useState initializers.
|
||||
if (item.settled !== prevSettled) {
|
||||
setPrevSettled(item.settled);
|
||||
if (item.settled) setOpen(false);
|
||||
}
|
||||
|
||||
const folded = item.settled && !open;
|
||||
const SummaryIcon = item.summary.failed ? X : Check;
|
||||
|
||||
return (
|
||||
<div data-testid="task-chat-turn" data-settled={item.settled ? "true" : "false"}>
|
||||
{item.settled ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
data-testid="task-chat-turn-summary"
|
||||
>
|
||||
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
|
||||
{turnSummaryMetrics(item.summary) ? (
|
||||
<span className="font-mono text-(length:--text-micro)">{turnSummaryMetrics(item.summary)}</span>
|
||||
) : null}
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
{item.items.map((child) => (
|
||||
<div key={child.id}>{renderChild(child)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { Gauge } from "lucide-react";
|
||||
import type { TaskChatUsageItem } from "./task-chat-model";
|
||||
|
||||
/**
|
||||
* Second-tier live token/cost readout (ACP UsageUpdate). Concrete progress —
|
||||
* context fill %, tokens, and cost — rather than an indeterminate spinner.
|
||||
* Recedes to metadata weight so it never competes with message content.
|
||||
*/
|
||||
export function TaskChatUsageReadout({ item }: { item: TaskChatUsageItem }) {
|
||||
const { used, size, inputTokens, outputTokens, costUsd } = item.usage;
|
||||
const pct = size > 0 ? Math.min(100, Math.round((used / size) * 100)) : 0;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 px-1 py-1 text-(length:--text-micro) text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Gauge className="h-3 w-3" />
|
||||
<span>
|
||||
{used.toLocaleString()}/{size.toLocaleString()} ctx ({pct}%)
|
||||
</span>
|
||||
{inputTokens != null || outputTokens != null ? (
|
||||
<span>
|
||||
· ↑{(inputTokens ?? 0).toLocaleString()} ↓{(outputTokens ?? 0).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
{costUsd != null ? <span>· ${costUsd.toFixed(4)}</span> : null}
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-border">
|
||||
<div
|
||||
className={cn("h-full rounded-full bg-(--status-agent-running)")}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TaskMessageScroller } from "./TaskMessageScroller";
|
||||
|
||||
const PILL_SELECTOR = 'button[aria-label="Scroll to latest"]';
|
||||
|
||||
/**
|
||||
* jsdom has no layout, so scroll geometry is faked per element: scrollHeight /
|
||||
* clientHeight are pinned and scrollTop is backed by a plain variable.
|
||||
*/
|
||||
function fakeGeometry(el: HTMLElement, { scrollHeight = 1000, clientHeight = 400 } = {}) {
|
||||
let scrollTop = 0;
|
||||
Object.defineProperty(el, "scrollHeight", { value: scrollHeight, configurable: true });
|
||||
Object.defineProperty(el, "clientHeight", { value: clientHeight, configurable: true });
|
||||
Object.defineProperty(el, "scrollTop", {
|
||||
get: () => scrollTop,
|
||||
set: (v: number) => {
|
||||
scrollTop = v;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll/wheel are continuous-priority events, so React flushes the resulting
|
||||
* state updates asynchronously — wait a macrotask after dispatching.
|
||||
*/
|
||||
function flushEvents() {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
describe("TaskMessageScroller", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(contentKey: unknown = 0) {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<TaskMessageScroller contentKey={contentKey}>
|
||||
<div>messages</div>
|
||||
</TaskMessageScroller>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function scroller(): HTMLDivElement {
|
||||
return container.querySelector<HTMLDivElement>('[data-testid="task-chat-scroller"]')!;
|
||||
}
|
||||
|
||||
function pill(): HTMLButtonElement | null {
|
||||
return container.querySelector<HTMLButtonElement>(PILL_SELECTOR);
|
||||
}
|
||||
|
||||
/** Set scrollTop and fire a scroll event, like a user or the browser would. */
|
||||
async function scrollTo(el: HTMLElement, top: number) {
|
||||
el.scrollTop = top;
|
||||
el.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
await flushEvents();
|
||||
}
|
||||
|
||||
async function dispatch(el: HTMLElement, event: Event) {
|
||||
el.dispatchEvent(event);
|
||||
await flushEvents();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("renders children inside the scroll container, pill hidden, scrolled to bottom on mount", () => {
|
||||
render();
|
||||
const el = scroller();
|
||||
expect(el.textContent).toContain("messages");
|
||||
expect(pill()).toBeNull();
|
||||
// Mount effect pins to the bottom.
|
||||
expect(el.scrollTop).toBe(el.scrollHeight);
|
||||
});
|
||||
|
||||
it("auto-follows content instantly while pinned", async () => {
|
||||
render(1);
|
||||
const el = scroller();
|
||||
fakeGeometry(el);
|
||||
await scrollTo(el, 600); // bottom: 1000 - 600 - 400 = 0 → pinned
|
||||
render(2);
|
||||
expect(el.scrollTop).toBe(1000);
|
||||
expect(pill()).toBeNull();
|
||||
});
|
||||
|
||||
it("holds position and shows the icon-only pill when scrolled up", async () => {
|
||||
render(1);
|
||||
const el = scroller();
|
||||
fakeGeometry(el);
|
||||
await scrollTo(el, 100); // 500px from bottom → unpinned
|
||||
const btn = pill();
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn!.className).toContain("tc-scroll-pill-in");
|
||||
expect(btn!.className).toContain("size-8");
|
||||
expect(btn!.className).not.toContain("-translate-x-1/2");
|
||||
// Icon-only: no visible text.
|
||||
expect(btn!.textContent).toBe("");
|
||||
// New content must not yank the held position.
|
||||
render(2);
|
||||
expect(el.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("small drifts within the pin threshold do not show the pill", async () => {
|
||||
render();
|
||||
const el = scroller();
|
||||
fakeGeometry(el);
|
||||
await scrollTo(el, 560); // 40px from bottom, threshold is 48
|
||||
expect(pill()).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking the pill smooth-scrolls, ignores intermediate scroll events, re-pins on arrival", async () => {
|
||||
render(1);
|
||||
const el = scroller();
|
||||
fakeGeometry(el);
|
||||
const scrollToSpy = vi.fn();
|
||||
el.scrollTo = scrollToSpy as unknown as typeof el.scrollTo;
|
||||
|
||||
await scrollTo(el, 100);
|
||||
const btn = pill()!;
|
||||
btn.click();
|
||||
await flushEvents();
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
|
||||
|
||||
// Intermediate glide positions fire scroll events; they must not unpin
|
||||
// (i.e. the easing flag swallows them and the pill state stays put).
|
||||
await scrollTo(el, 250);
|
||||
await scrollTo(el, 400);
|
||||
expect(pill()).not.toBeNull();
|
||||
|
||||
// Arrival within the threshold re-pins and hides the pill (immediately
|
||||
// here: no matchMedia in jsdom → reduced-motion/unmount-now path).
|
||||
await scrollTo(el, 600);
|
||||
expect(pill()).toBeNull();
|
||||
|
||||
// Re-pinned: content growth follows instantly again.
|
||||
render(2);
|
||||
expect(el.scrollTop).toBe(1000);
|
||||
});
|
||||
|
||||
it("a wheel gesture during the glide cancels easing and stays unpinned", async () => {
|
||||
render(1);
|
||||
const el = scroller();
|
||||
fakeGeometry(el);
|
||||
el.scrollTo = vi.fn() as unknown as typeof el.scrollTo;
|
||||
|
||||
await scrollTo(el, 100);
|
||||
pill()!.click();
|
||||
await flushEvents();
|
||||
await dispatch(el, new Event("wheel", { bubbles: true }));
|
||||
|
||||
// Easing cancelled → user is unpinned, pill visible, content holds.
|
||||
expect(pill()).not.toBeNull();
|
||||
render(2);
|
||||
expect(el.scrollTop).toBe(100);
|
||||
|
||||
// Scroll events far from the bottom now behave normally (still unpinned).
|
||||
await scrollTo(el, 200);
|
||||
expect(pill()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("plays the exit animation before unmounting when motion is enabled", async () => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockReturnValue({ matches: false }), // motion allowed
|
||||
);
|
||||
render();
|
||||
const el = scroller();
|
||||
fakeGeometry(el);
|
||||
await scrollTo(el, 100);
|
||||
expect(pill()!.className).toContain("tc-scroll-pill-in");
|
||||
|
||||
// Scrolling back to the bottom starts the exit animation but keeps the
|
||||
// pill mounted until animationend.
|
||||
await scrollTo(el, 600);
|
||||
const exiting = pill();
|
||||
expect(exiting).not.toBeNull();
|
||||
expect(exiting!.className).toContain("tc-scroll-pill-out");
|
||||
|
||||
// jsdom has no window.AnimationEvent, so React's vendor-prefix detection
|
||||
// maps onAnimationEnd to "webkitAnimationEnd" here (real browsers get the
|
||||
// unprefixed event).
|
||||
await dispatch(exiting!, new Event("webkitAnimationEnd", { bubbles: true }));
|
||||
expect(pill()).toBeNull();
|
||||
});
|
||||
|
||||
it("unmounts immediately on hide under prefers-reduced-motion", async () => {
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn().mockReturnValue({ matches: true }), // reduce
|
||||
);
|
||||
render();
|
||||
const el = scroller();
|
||||
fakeGeometry(el);
|
||||
await scrollTo(el, 100);
|
||||
expect(pill()).not.toBeNull();
|
||||
await scrollTo(el, 600);
|
||||
expect(pill()).toBeNull(); // no animationend needed
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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. */
|
||||
contentKey: unknown;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 isPinned = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD_PX;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight; // instant, never smooth
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
|
||||
// Follow new content only when already pinned; otherwise hold position.
|
||||
useLayoutEffect(() => {
|
||||
if (pinnedRef.current) scrollToBottom();
|
||||
}, [contentKey, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<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)}
|
||||
data-testid="task-chat-scroller"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{pillPhase !== "hidden" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll to latest"
|
||||
onClick={handleJumpToLatest}
|
||||
onAnimationEnd={() => {
|
||||
if (pillPhase === "out") setPillPhase("hidden");
|
||||
}}
|
||||
// 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",
|
||||
)}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TweakPanel } from "./TweakPanel";
|
||||
import { MOTION_TOKENS } from "./motion-tokens";
|
||||
|
||||
describe("TweakPanel", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
flushSync(() => root!.render(<TweakPanel />));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
document.documentElement.removeAttribute("style");
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it("lists a control for every motion token in the catalog", () => {
|
||||
const rendered = new Set(
|
||||
Array.from(container.querySelectorAll("[data-token]")).map((el) => el.getAttribute("data-token")),
|
||||
);
|
||||
for (const t of MOTION_TOKENS) {
|
||||
expect(rendered.has(t.name), `panel is missing a control for ${t.name}`).toBe(true);
|
||||
}
|
||||
expect(rendered.size).toBe(MOTION_TOKENS.length);
|
||||
});
|
||||
|
||||
it("writes a token change live to the CSS custom property", () => {
|
||||
const token = MOTION_TOKENS.find((t) => t.kind === "time")!;
|
||||
const input = container.querySelector<HTMLInputElement>(`input[aria-label="${token.name}"]`);
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
// Use the native value setter so React's value tracker registers the change
|
||||
// and fires onChange (setting `.value` directly is swallowed by React).
|
||||
const nativeSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)!.set!;
|
||||
flushSync(() => {
|
||||
nativeSetter.call(input, "500");
|
||||
input!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue(token.name)).toBe("500ms");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Copy, GripHorizontal, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
EASING_PRESETS,
|
||||
MOTION_TOKENS,
|
||||
MOTION_TOKEN_EXPORT_FALLBACK,
|
||||
MOTION_TOKEN_GROUPS,
|
||||
parseCssTimeMs,
|
||||
type MotionTokenDef,
|
||||
} from "./motion-tokens";
|
||||
|
||||
const POS_KEY = "tc-tweak-pos";
|
||||
const OVERRIDES_KEY = "tc-tweak-overrides";
|
||||
|
||||
function readComputed(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
function loadSession<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSession(key: string, value: unknown) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* session storage unavailable — panel still works, just not persisted */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled motion tweak panel (dev/flag only). Floating, draggable,
|
||||
* minimizable overlay that lists every motion token, writes changes live to the
|
||||
* CSS custom properties (no reload), exports a paste-ready :root block, and
|
||||
* persists position + overrides for the session only. No third-party GUI lib —
|
||||
* one file, one dev guard, trivial to strip out later.
|
||||
*/
|
||||
export function TweakPanel() {
|
||||
const [pos, setPos] = useState<{ x: number; y: number }>(() =>
|
||||
loadSession(POS_KEY, { x: 24, y: 24 }),
|
||||
);
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>(() =>
|
||||
loadSession(OVERRIDES_KEY, {}),
|
||||
);
|
||||
const [exportText, setExportText] = useState<string | null>(null);
|
||||
const dragRef = useRef<{ dx: number; dy: number } | null>(null);
|
||||
|
||||
// Initialize display values from computed styles, then re-apply any saved
|
||||
// session overrides so a reload-free session keeps its tuning.
|
||||
useEffect(() => {
|
||||
const initial: Record<string, string> = {};
|
||||
for (const t of MOTION_TOKENS) initial[t.name] = readComputed(t.name);
|
||||
for (const [name, value] of Object.entries(overrides)) {
|
||||
document.documentElement.style.setProperty(name, value);
|
||||
initial[name] = value;
|
||||
}
|
||||
setValues(initial);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const setToken = useCallback((name: string, value: string) => {
|
||||
document.documentElement.style.setProperty(name, value);
|
||||
setValues((v) => ({ ...v, [name]: value }));
|
||||
setOverrides((o) => {
|
||||
const next = { ...o, [name]: value };
|
||||
saveSession(OVERRIDES_KEY, next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
for (const name of Object.keys(overrides)) {
|
||||
document.documentElement.style.removeProperty(name);
|
||||
}
|
||||
setOverrides({});
|
||||
saveSession(OVERRIDES_KEY, {});
|
||||
const refreshed: Record<string, string> = {};
|
||||
for (const t of MOTION_TOKENS) refreshed[t.name] = readComputed(t.name);
|
||||
setValues(refreshed);
|
||||
}, [overrides]);
|
||||
|
||||
const onHeaderPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y };
|
||||
},
|
||||
[pos],
|
||||
);
|
||||
|
||||
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const next = { x: e.clientX - dragRef.current.dx, y: e.clientY - dragRef.current.dy };
|
||||
setPos(next);
|
||||
}, []);
|
||||
|
||||
const onHeaderPointerUp = useCallback(() => {
|
||||
if (dragRef.current) {
|
||||
dragRef.current = null;
|
||||
saveSession(POS_KEY, pos);
|
||||
}
|
||||
}, [pos]);
|
||||
|
||||
const buildExport = useCallback(() => {
|
||||
const lines = MOTION_TOKENS.map((t) => {
|
||||
const val = values[t.name] || readComputed(t.name);
|
||||
return ` ${t.name}: ${val};`;
|
||||
});
|
||||
setExportText(`:root {\n${lines.join("\n")}\n}\n`);
|
||||
}, [values]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, MotionTokenDef[]> = {};
|
||||
for (const g of MOTION_TOKEN_GROUPS) map[g] = [];
|
||||
for (const t of MOTION_TOKENS) map[t.group].push(t);
|
||||
return map;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 w-72 select-none rounded-lg border border-border bg-card text-card-foreground shadow-lg"
|
||||
style={{ left: pos.x, top: pos.y }}
|
||||
data-testid="task-chat-tweak-panel"
|
||||
>
|
||||
<div
|
||||
className="flex cursor-grab items-center gap-2 rounded-t-lg border-b border-border bg-muted/50 px-2 py-1.5 text-xs font-medium active:cursor-grabbing"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
onPointerMove={onHeaderPointerMove}
|
||||
onPointerUp={onHeaderPointerUp}
|
||||
>
|
||||
<GripHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>Motion tweak panel</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button type="button" title="Reset all" onClick={resetAll} className="rounded p-0.5 hover:bg-accent">
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={minimized ? "Expand" : "Minimize"}
|
||||
onClick={() => setMinimized((m) => !m)}
|
||||
className="rounded p-0.5 hover:bg-accent"
|
||||
>
|
||||
{minimized ? <Plus className="h-3.5 w-3.5" /> : <Minus className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{minimized ? null : (
|
||||
<div className="max-h-(--sz-tweak-panel-max) overflow-y-auto p-2">
|
||||
{MOTION_TOKEN_GROUPS.map((group) => (
|
||||
<div key={group} className="mb-3">
|
||||
<p className="mb-1 text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">{group}</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{grouped[group].map((t) => (
|
||||
<div key={t.name} data-token={t.name} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-(length:--text-micro)">
|
||||
<span className="truncate font-mono text-muted-foreground">{t.name.replace("--motion-", "")}</span>
|
||||
<span className="ml-2 shrink-0 tabular-nums">
|
||||
{t.kind === "time" ? `${Math.round(parseCssTimeMs(values[t.name] ?? "0"))}ms` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{t.kind === "time" ? (
|
||||
<input
|
||||
type="range"
|
||||
aria-label={t.name}
|
||||
min={t.min ?? 0}
|
||||
max={t.max ?? 1000}
|
||||
step={t.step ?? 10}
|
||||
value={Math.round(parseCssTimeMs(values[t.name] ?? "0"))}
|
||||
onChange={(e) => setToken(t.name, `${e.target.value}ms`)}
|
||||
className="h-1.5 w-full"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
aria-label={t.name}
|
||||
value={values[t.name] ?? ""}
|
||||
onChange={(e) => setToken(t.name, e.target.value)}
|
||||
className="w-full rounded border border-border bg-background px-1 py-0.5 text-(length:--text-micro)"
|
||||
>
|
||||
{EASING_PRESETS.every((p) => p.value !== (values[t.name] ?? "")) && values[t.name] ? (
|
||||
<option value={values[t.name]}>{values[t.name]}</option>
|
||||
) : null}
|
||||
{EASING_PRESETS.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-1 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={buildExport}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy as @theme
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{exportText ? (
|
||||
<textarea
|
||||
readOnly
|
||||
data-testid="task-chat-tweak-export"
|
||||
className="mt-2 h-32 w-full resize-none rounded border border-border bg-muted/40 p-2 font-mono text-(length:--text-nano)"
|
||||
value={exportText}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.select();
|
||||
void navigator.clipboard?.writeText(exportText).catch(() => {});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the :root export string from arbitrary token values. Exposed for the
|
||||
* finish-line test that asserts export produces valid CSS. Kept in sync with
|
||||
* the panel's own export.
|
||||
*/
|
||||
export function buildTweakExport(values: Record<string, string>): string {
|
||||
const lines = MOTION_TOKENS.map((t) => ` ${t.name}: ${values[t.name] ?? MOTION_TOKEN_EXPORT_FALLBACK};`);
|
||||
return `:root {\n${lines.join("\n")}\n}\n`;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MOTION_TOKENS } from "./motion-tokens";
|
||||
import { buildTweakExport } from "./TweakPanel";
|
||||
|
||||
/** Collect declared --motion-* custom properties from index.css (deduped). */
|
||||
function declaredMotionTokens(): Set<string> {
|
||||
const css = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
|
||||
const names = new Set<string>();
|
||||
const re = /(--motion-[a-z0-9-]+)\s*:/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(css)) !== null) names.add(m[1]);
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("motion token catalog", () => {
|
||||
it("is 1:1 with the --motion-* tokens declared in index.css", () => {
|
||||
const declared = declaredMotionTokens();
|
||||
const catalog = new Set(MOTION_TOKENS.map((t) => t.name));
|
||||
|
||||
const missingFromCatalog = [...declared].filter((n) => !catalog.has(n));
|
||||
const missingFromCss = [...catalog].filter((n) => !declared.has(n));
|
||||
|
||||
expect(missingFromCatalog, "tokens in index.css but not in the catalog").toEqual([]);
|
||||
expect(missingFromCss, "tokens in the catalog but not in index.css").toEqual([]);
|
||||
expect(catalog.size).toBe(declared.size);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tweak panel export", () => {
|
||||
it("emits a valid :root block containing every motion token", () => {
|
||||
const values: Record<string, string> = {};
|
||||
for (const t of MOTION_TOKENS) values[t.name] = t.kind === "time" ? "123ms" : "linear";
|
||||
const out = buildTweakExport(values);
|
||||
|
||||
expect(out.startsWith(":root {")).toBe(true);
|
||||
expect(out.trimEnd().endsWith("}")).toBe(true);
|
||||
// Balanced braces.
|
||||
expect((out.match(/{/g) ?? []).length).toBe((out.match(/}/g) ?? []).length);
|
||||
// Every token present as a declaration.
|
||||
for (const t of MOTION_TOKENS) {
|
||||
expect(out).toContain(`${t.name}: ${values[t.name]};`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Catalog of the redesign's motion tokens. This mirrors the `--motion-*`
|
||||
* custom properties declared in ui/src/index.css :root, and is the list the dev
|
||||
* tweak panel renders controls from.
|
||||
*
|
||||
* A finish-line test parses index.css for `--motion-*` declarations and asserts
|
||||
* this catalog is 1:1 with them, so the two can never silently drift: add a
|
||||
* token to index.css and you must add it here (and vice-versa).
|
||||
*/
|
||||
|
||||
export type MotionTokenKind = "time" | "easing";
|
||||
|
||||
export interface MotionTokenDef {
|
||||
/** CSS custom property name, including leading `--`. */
|
||||
name: string;
|
||||
/** Display group in the tweak panel. */
|
||||
group: string;
|
||||
kind: MotionTokenKind;
|
||||
/** Slider bounds for time tokens (ms). */
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export const MOTION_TOKENS: MotionTokenDef[] = [
|
||||
// Easing primitives
|
||||
{ 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 },
|
||||
{ name: "--motion-duration-fast", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
|
||||
{ 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 },
|
||||
{ name: "--motion-bubble-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-cot-line-stagger", group: "States", kind: "time", min: 0, max: 300, step: 5 },
|
||||
{ name: "--motion-cot-collapse", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-tool-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-diff-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-status-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-status-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-approval-pulse", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
|
||||
{ name: "--motion-plan-entry-stagger", group: "States", kind: "time", min: 0, max: 300, step: 5 },
|
||||
{ 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-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. */
|
||||
export const EASING_PRESETS: { label: string; value: string }[] = [
|
||||
{ label: "ease-out-expo (house)", value: "cubic-bezier(0.16, 1, 0.3, 1)" },
|
||||
{ label: "standard (house)", value: "cubic-bezier(0.4, 0, 0.2, 1)" },
|
||||
{ label: "ease-out", value: "cubic-bezier(0, 0, 0.2, 1)" },
|
||||
{ label: "ease-in-out", value: "cubic-bezier(0.42, 0, 0.58, 1)" },
|
||||
{ label: "linear", value: "linear" },
|
||||
];
|
||||
|
||||
/** Ordered list of the panel's groups. */
|
||||
export const MOTION_TOKEN_GROUPS: string[] = Array.from(
|
||||
new Set(MOTION_TOKENS.map((t) => t.group)),
|
||||
);
|
||||
|
||||
/** Fallback emitted by the tweak-panel export when a token has no value. */
|
||||
export const MOTION_TOKEN_EXPORT_FALLBACK = "0ms";
|
||||
|
||||
/** Parse a CSS time value ("240ms" / "1.6s") to milliseconds. */
|
||||
export function parseCssTimeMs(value: string): number {
|
||||
const v = value.trim();
|
||||
if (v.endsWith("ms")) return parseFloat(v);
|
||||
if (v.endsWith("s")) return parseFloat(v) * 1000;
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Live adapter: normalize the existing IssueChatComment stream (including
|
||||
* optimistic echoes) into the redesign's TaskChatItem[] model. This is the
|
||||
* seam that lets the new render layer show live task data without touching the
|
||||
* comment data pipeline. Rich agent-run streaming (thinking/tool/diff) is
|
||||
* demonstrated in the harness and layered onto this adapter as a later step;
|
||||
* the baseline live thread renders the author-typed message history + optimistic
|
||||
* echo, which is the core legibility win.
|
||||
*/
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import type { IssueChatComment } from "@/lib/issue-chat-messages";
|
||||
import type { TaskChatAuthorKind, TaskChatItem } from "./task-chat-model";
|
||||
|
||||
export interface TaskChatAdapterContext {
|
||||
agentMap?: Map<string, Agent>;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
currentUserId?: 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 {
|
||||
return comment.authorAgentId ?? comment.derivedAuthorAgentId ?? null;
|
||||
}
|
||||
|
||||
function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
|
||||
if (effectiveAgentId(comment)) return "agent";
|
||||
if (comment.authorType === "user") return "human";
|
||||
if (comment.authorType === "agent") return "agent";
|
||||
return "system";
|
||||
}
|
||||
|
||||
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;
|
||||
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function commentsToTaskChatItems(
|
||||
comments: IssueChatComment[],
|
||||
ctx: TaskChatAdapterContext = {},
|
||||
): TaskChatItem[] {
|
||||
const items: TaskChatItem[] = [];
|
||||
for (const comment of comments) {
|
||||
if (comment.deletedAt) continue;
|
||||
const kind = authorKind(comment);
|
||||
let authorName: string | undefined;
|
||||
let agentIcon: string | null | undefined;
|
||||
if (kind === "agent") {
|
||||
const agentId = effectiveAgentId(comment);
|
||||
authorName = (agentId && ctx.agentMap?.get(agentId)?.name) || "Agent";
|
||||
agentIcon = agentId ? ctx.agentMap?.get(agentId)?.icon : undefined;
|
||||
} else if (kind === "human") {
|
||||
authorName =
|
||||
(comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined;
|
||||
}
|
||||
const optimistic =
|
||||
comment.clientStatus === "queued"
|
||||
? "queued"
|
||||
: comment.clientStatus === "pending"
|
||||
? "pending"
|
||||
: undefined;
|
||||
items.push({
|
||||
id: comment.id || comment.clientId || `${comment.createdAt}`,
|
||||
kind: "message",
|
||||
author: kind,
|
||||
authorName,
|
||||
text: comment.body,
|
||||
timestamp: formatTimestamp(comment.createdAt),
|
||||
optimistic,
|
||||
agentIcon,
|
||||
modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
/**
|
||||
* Synthetic fixtures for the Task Chat Redesign dev harness. No live agent is
|
||||
* required: every state in the inventory maps to a deterministic scenario the
|
||||
* harness renders and the finish-line test iterates. Tier-B states are driven
|
||||
* entirely from here (live protocol wiring is a flagged dependency).
|
||||
*/
|
||||
import type { TaskChatItem, TaskChatPlan } from "./task-chat-model";
|
||||
import type { TaskChatStateId } from "./task-chat-states";
|
||||
|
||||
export interface TaskChatScenario {
|
||||
surface: "thread" | "plan";
|
||||
items: TaskChatItem[];
|
||||
plan?: TaskChatPlan;
|
||||
}
|
||||
|
||||
const AGENT = "Atlas";
|
||||
|
||||
/** A short human→agent exchange used as context in several scenarios. */
|
||||
function exchangePrefix(): TaskChatItem[] {
|
||||
return [
|
||||
{ id: "m-user-1", kind: "message", author: "human", text: "Add a rate limiter to the login route.", timestamp: "2:31 PM" },
|
||||
];
|
||||
}
|
||||
|
||||
const SAMPLE_PLAN: TaskChatPlan = {
|
||||
revision: 2,
|
||||
updatedAt: "2:33 PM",
|
||||
entries: [
|
||||
{ id: "p1", content: "Read the login route and existing middleware", status: "completed", priority: "medium" },
|
||||
{ id: "p2", content: "Add a token-bucket rate limiter util", status: "in_progress", priority: "high" },
|
||||
{ id: "p3", content: "Wire the limiter into POST /login", status: "pending", priority: "high" },
|
||||
{ id: "p4", content: "Add tests for the limit + reset window", status: "pending", priority: "low" },
|
||||
],
|
||||
};
|
||||
|
||||
export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
switch (id) {
|
||||
case "session-start":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "mk-start", kind: "marker", variant: "session_start", label: "Session started", detail: "claude · Agent mode" },
|
||||
...exchangePrefix(),
|
||||
],
|
||||
};
|
||||
case "human-message":
|
||||
return { surface: "thread", items: exchangePrefix() };
|
||||
case "agent-message":
|
||||
return {
|
||||
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" },
|
||||
],
|
||||
};
|
||||
case "thinking":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{ 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":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{ 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" },
|
||||
],
|
||||
};
|
||||
case "diff":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{
|
||||
id: "tool-diff", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", decision: "allowed",
|
||||
diff: {
|
||||
path: "server/src/routes/auth.ts", added: 3, removed: 1,
|
||||
lines: [
|
||||
{ kind: "context", text: "router.post('/login', async (req, res) => {" },
|
||||
{ kind: "remove", text: " const ok = await checkPassword(req.body);" },
|
||||
{ kind: "add", text: " await rateLimiter.consume(req.body.email);" },
|
||||
{ kind: "add", text: " const ok = await checkPassword(req.body);" },
|
||||
{ kind: "add", text: " if (!ok) return res.status(401).end();" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
case "working":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "st-working", kind: "status", status: "working", label: "Editing files", detail: "Edit · server/src/routes/auth.ts", toolName: "Edit", startedAtMs: Date.now() - 4200 },
|
||||
],
|
||||
};
|
||||
case "running":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ 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 } },
|
||||
],
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{
|
||||
id: "turn-done",
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
summary: { durationLabel: "38s", toolCount: 3, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
|
||||
items: [
|
||||
{ id: "th-done", kind: "thinking", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] },
|
||||
{ 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: "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" },
|
||||
],
|
||||
};
|
||||
case "awaiting-approval":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{
|
||||
id: "st-approval", kind: "status", status: "awaiting_approval", label: "Approve running a command?",
|
||||
detail: "npm run migrate — modifies the database",
|
||||
approval: {
|
||||
toolName: "execute",
|
||||
options: [
|
||||
{ id: "reject", label: "Deny", kind: "reject_once" },
|
||||
{ id: "allow-always", label: "Always allow", kind: "allow_always" },
|
||||
{ id: "allow", label: "Allow once", kind: "allow_once" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
case "plan-todo":
|
||||
return { surface: "plan", items: [], plan: SAMPLE_PLAN };
|
||||
case "interrupted":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "m-int", kind: "message", author: "agent", authorName: AGENT, text: "Starting the migration now…" },
|
||||
{ id: "mk-int", kind: "marker", variant: "interrupted", label: "Interrupted", detail: "stopped by you at 2:35 PM" },
|
||||
],
|
||||
};
|
||||
case "refused":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "st-refused", kind: "status", status: "refused", label: "Turn ended: refusal", detail: "The agent declined to complete this request." },
|
||||
],
|
||||
};
|
||||
case "truncated":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "st-trunc", kind: "status", status: "truncated", label: "Turn ended: max tokens", detail: "Output was cut off — continue to resume.", tokens: { used: 199120, size: 200000 } },
|
||||
],
|
||||
};
|
||||
case "live-token-cost":
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
{ id: "usage-1", kind: "usage", usage: { used: 42800, size: 200000, inputTokens: 38200, outputTokens: 4600, costUsd: 0.1284 } },
|
||||
],
|
||||
};
|
||||
default: {
|
||||
const _never: never = id;
|
||||
return _never;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Normalized presentation model for the Task Chat Redesign (flag:
|
||||
* enableTaskChatRedesign).
|
||||
*
|
||||
* This is a deliberately small, protocol-agnostic model that the new render
|
||||
* layer consumes. Two producers feed it:
|
||||
* 1. The dev harness, via synthetic fixtures (task-chat-fixtures.ts).
|
||||
* 2. The live thread, via an adapter over the existing comment/run props.
|
||||
*
|
||||
* Every field traces to a real agent-protocol concept (ACP SessionUpdate /
|
||||
* acpx AcpRuntimeEvent / TranscriptEntry). See DESIGN.md and the plan's state
|
||||
* 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";
|
||||
|
||||
/** ACP ToolCallStatus. */
|
||||
export type TaskChatToolStatus = "pending" | "in_progress" | "completed" | "failed";
|
||||
|
||||
/** ACP PermissionOptionKind. */
|
||||
export type TaskChatApprovalOptionKind =
|
||||
| "allow_once"
|
||||
| "allow_always"
|
||||
| "reject_once"
|
||||
| "reject_always";
|
||||
|
||||
/** ACP PlanEntryStatus. */
|
||||
export type TaskChatPlanEntryStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
/** ACP PlanEntryPriority. */
|
||||
export type TaskChatPlanEntryPriority = "high" | "medium" | "low";
|
||||
|
||||
export interface TaskChatApprovalOption {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: TaskChatApprovalOptionKind;
|
||||
}
|
||||
|
||||
export interface TaskChatDiff {
|
||||
path?: string;
|
||||
added: number;
|
||||
removed: number;
|
||||
/** Optional unified-diff-ish lines for display only. */
|
||||
lines?: Array<{ kind: "add" | "remove" | "context"; text: string }>;
|
||||
}
|
||||
|
||||
export interface TaskChatTokenUsage {
|
||||
/** ACP UsageUpdate.used — tokens in context. */
|
||||
used: number;
|
||||
/** ACP UsageUpdate.size — context window size. */
|
||||
size: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
/** A human/agent/system message bubble. */
|
||||
export interface TaskChatMessageItem {
|
||||
id: string;
|
||||
kind: "message";
|
||||
author: TaskChatAuthorKind;
|
||||
authorName?: string;
|
||||
text: string;
|
||||
timestamp?: string;
|
||||
/** Show a streaming cursor and suppress collapse while true. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
|
||||
export interface TaskChatThinkingItem {
|
||||
id: string;
|
||||
kind: "thinking";
|
||||
lines: string[];
|
||||
streaming?: boolean;
|
||||
/** When true, render collapsed ("Worked for N") with an expand affordance. */
|
||||
collapsed?: boolean;
|
||||
/** Human-readable elapsed label for the collapsed header. */
|
||||
summaryLabel?: string;
|
||||
}
|
||||
|
||||
/** A tool invocation row (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. */
|
||||
decision?: "allowed" | "rejected";
|
||||
}
|
||||
|
||||
/**
|
||||
* A lifecycle state rendered as a status affordance — never a bubble.
|
||||
* Covers both LIVE (running/working) and Tier-B (awaiting_approval,
|
||||
* interrupted, refused, truncated) states.
|
||||
*/
|
||||
export interface TaskChatStatusItem {
|
||||
id: string;
|
||||
kind: "status";
|
||||
status:
|
||||
| "running"
|
||||
| "working"
|
||||
| "awaiting_approval"
|
||||
| "interrupted"
|
||||
| "refused"
|
||||
| "truncated";
|
||||
label: string;
|
||||
detail?: string;
|
||||
/** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */
|
||||
toolName?: 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[] };
|
||||
}
|
||||
|
||||
/** A lifecycle divider (session start, interruption, turn boundary). */
|
||||
export interface TaskChatMarkerItem {
|
||||
id: string;
|
||||
kind: "marker";
|
||||
variant: "session_start" | "interrupted" | "turn_boundary";
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** A second-tier live token/cost readout (ACP UsageUpdate). */
|
||||
export interface TaskChatUsageItem {
|
||||
id: string;
|
||||
kind: "usage";
|
||||
usage: TaskChatTokenUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* An issue-thread interaction (plan confirmation, question card, suggested
|
||||
* tasks…) interleaved chronologically into the thread. The payload is the
|
||||
* existing control-plane IssueThreadInteraction — the render layer wraps the
|
||||
* shared IssueThreadInteractionCard rather than re-modeling the five kinds.
|
||||
*/
|
||||
export interface TaskChatInteractionItem {
|
||||
id: string;
|
||||
kind: "interaction";
|
||||
interaction: IssueThreadInteraction;
|
||||
}
|
||||
|
||||
/** Items a turn can group — everything except another turn. */
|
||||
export type TaskChatTurnChildItem =
|
||||
| TaskChatMessageItem
|
||||
| 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"). While `settled` is false the children render fully interleaved;
|
||||
* on the live → settled transition the fold animates (~ --motion-turn-fold),
|
||||
* while turns that load already-settled collapse instantly.
|
||||
*/
|
||||
export interface TaskChatTurnItem {
|
||||
id: string;
|
||||
kind: "turn";
|
||||
items: TaskChatTurnChildItem[];
|
||||
settled: boolean;
|
||||
/** 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;
|
||||
|
||||
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
|
||||
export interface TaskChatPlanEntry {
|
||||
id: string;
|
||||
content: string;
|
||||
status: TaskChatPlanEntryStatus;
|
||||
priority: TaskChatPlanEntryPriority;
|
||||
}
|
||||
|
||||
export interface TaskChatPlan {
|
||||
/** Revision index (1-based); higher supersedes. */
|
||||
revision: number;
|
||||
entries: TaskChatPlanEntry[];
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
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.
|
||||
* Iterates the canonical enum so a new state can never be added without a
|
||||
* fixture + a passing render here.
|
||||
*/
|
||||
describe("Task chat state inventory renders", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
for (const id of TASK_CHAT_STATES) {
|
||||
it(`renders state "${id}" without error`, () => {
|
||||
const scenario = buildScenario(id);
|
||||
const root = createRoot(container);
|
||||
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>,
|
||||
);
|
||||
});
|
||||
}).not.toThrow();
|
||||
expect(container.textContent && container.textContent.length).toBeTruthy();
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Canonical state inventory for the Task Chat Redesign (flag:
|
||||
* enableTaskChatRedesign).
|
||||
*
|
||||
* This list is the single source of truth for:
|
||||
* - the dev harness state switcher (/dev/task-chat-lab), and
|
||||
* - the finish-line test that asserts every state renders without error.
|
||||
*
|
||||
* Each id traces to a real agent-protocol state (plan Deliverable 1). `tier`
|
||||
* marks whether the state already streams live ("live") or is emitted upstream
|
||||
* but dropped by acpx today ("tier-b", driven by synthetic events in the
|
||||
* harness, live wiring flagged). `surface` says where the state renders.
|
||||
*/
|
||||
|
||||
export const TASK_CHAT_STATES = [
|
||||
"session-start",
|
||||
"human-message",
|
||||
"agent-message",
|
||||
"thinking",
|
||||
"responding",
|
||||
"tool-call",
|
||||
"diff",
|
||||
"working",
|
||||
"running",
|
||||
"completed",
|
||||
"awaiting-approval",
|
||||
"plan-todo",
|
||||
"interrupted",
|
||||
"refused",
|
||||
"truncated",
|
||||
"live-token-cost",
|
||||
] as const;
|
||||
|
||||
export type TaskChatStateId = (typeof TASK_CHAT_STATES)[number];
|
||||
|
||||
export type TaskChatStateTier = "live" | "tier-b";
|
||||
export type TaskChatStateSurface = "thread" | "plan";
|
||||
|
||||
export interface TaskChatStateMeta {
|
||||
id: TaskChatStateId;
|
||||
label: string;
|
||||
tier: TaskChatStateTier;
|
||||
surface: TaskChatStateSurface;
|
||||
/** Real protocol source, quoted for the harness inspector. */
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> = {
|
||||
"session-start": {
|
||||
id: "session-start",
|
||||
label: "Session start",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: 'acpx.session → TranscriptEntry kind:"init"',
|
||||
},
|
||||
"human-message": {
|
||||
id: "human-message",
|
||||
label: "Human message",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: 'IssueComment authorType:"user"',
|
||||
},
|
||||
"agent-message": {
|
||||
id: "agent-message",
|
||||
label: "Agent message",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output (ACP agent_message_chunk)",
|
||||
},
|
||||
thinking: {
|
||||
id: "thinking",
|
||||
label: "Thinking",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:thought (ACP agent_thought_chunk)",
|
||||
},
|
||||
responding: {
|
||||
id: "responding",
|
||||
label: "Responding (streaming)",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "text_delta stream:output, streaming",
|
||||
},
|
||||
"tool-call": {
|
||||
id: "tool-call",
|
||||
label: "Tool call",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "acpx.tool_call (ACP tool_call / tool_call_update)",
|
||||
},
|
||||
diff: {
|
||||
id: "diff",
|
||||
label: "Diff",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: 'ToolCallContent type:"diff" → TranscriptEntry kind:"diff"',
|
||||
},
|
||||
working: {
|
||||
id: "working",
|
||||
label: "Working",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "heartbeat.run.progress + acpx.status",
|
||||
},
|
||||
running: {
|
||||
id: "running",
|
||||
label: "Running",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: 'message.status.type === "running"',
|
||||
},
|
||||
completed: {
|
||||
id: "completed",
|
||||
label: "Completed (collapsed)",
|
||||
tier: "live",
|
||||
surface: "thread",
|
||||
protocol: "acpx.result (StopReason in subtype)",
|
||||
},
|
||||
"awaiting-approval": {
|
||||
id: "awaiting-approval",
|
||||
label: "Awaiting approval",
|
||||
tier: "tier-b",
|
||||
surface: "thread",
|
||||
protocol: "ACP RequestPermissionRequest + PermissionOptionKind",
|
||||
},
|
||||
"plan-todo": {
|
||||
id: "plan-todo",
|
||||
label: "Plan / todo",
|
||||
tier: "tier-b",
|
||||
surface: "plan",
|
||||
protocol: "ACP Plan { entries: PlanEntry[] }, PlanEntryStatus",
|
||||
},
|
||||
interrupted: {
|
||||
id: "interrupted",
|
||||
label: "Interrupted",
|
||||
tier: "tier-b",
|
||||
surface: "thread",
|
||||
protocol: 'AcpRuntimeTurnResult.status:"cancelled" / StopReason "cancelled"',
|
||||
},
|
||||
refused: {
|
||||
id: "refused",
|
||||
label: "Refused",
|
||||
tier: "tier-b",
|
||||
surface: "thread",
|
||||
protocol: 'StopReason "refusal"',
|
||||
},
|
||||
truncated: {
|
||||
id: "truncated",
|
||||
label: "Truncated",
|
||||
tier: "tier-b",
|
||||
surface: "thread",
|
||||
protocol: 'StopReason "max_tokens" | "max_turn_requests"',
|
||||
},
|
||||
"live-token-cost": {
|
||||
id: "live-token-cost",
|
||||
label: "Live token / cost",
|
||||
tier: "tier-b",
|
||||
surface: "thread",
|
||||
protocol: "ACP UsageUpdate { used, size, cost }",
|
||||
},
|
||||
};
|
||||
|
||||
export const TASK_CHAT_STATE_LIST: TaskChatStateMeta[] = TASK_CHAT_STATES.map(
|
||||
(id) => TASK_CHAT_STATE_META[id],
|
||||
);
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Pencil,
|
||||
Plug,
|
||||
SquareTerminal,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { isGenericToolName, mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
describe("toolTaxonomy", () => {
|
||||
it("maps each family to its icon and verb", () => {
|
||||
expect(toolTaxonomy("Bash")).toEqual({
|
||||
family: "terminal",
|
||||
icon: SquareTerminal,
|
||||
verbLabel: "Running a command",
|
||||
});
|
||||
expect(toolTaxonomy("Shell").family).toBe("terminal");
|
||||
|
||||
expect(toolTaxonomy("Grep")).toEqual({
|
||||
family: "search",
|
||||
icon: FileSearch,
|
||||
verbLabel: "Searching",
|
||||
});
|
||||
expect(toolTaxonomy("Glob").family).toBe("search");
|
||||
expect(toolTaxonomy("WebSearch").family).toBe("search");
|
||||
|
||||
expect(toolTaxonomy("Read")).toEqual({
|
||||
family: "read",
|
||||
icon: BookOpen,
|
||||
verbLabel: "Reading files",
|
||||
});
|
||||
expect(toolTaxonomy("NotebookRead").family).toBe("read");
|
||||
|
||||
expect(toolTaxonomy("Edit")).toEqual({
|
||||
family: "edit",
|
||||
icon: Pencil,
|
||||
verbLabel: "Editing files",
|
||||
});
|
||||
expect(toolTaxonomy("Write").family).toBe("edit");
|
||||
expect(toolTaxonomy("MultiEdit").family).toBe("edit");
|
||||
expect(toolTaxonomy("NotebookEdit").family).toBe("edit");
|
||||
|
||||
expect(toolTaxonomy("WebFetch")).toEqual({
|
||||
family: "web",
|
||||
icon: Globe,
|
||||
verbLabel: "Fetching the web",
|
||||
});
|
||||
|
||||
expect(toolTaxonomy("Task")).toEqual({
|
||||
family: "agent",
|
||||
icon: Bot,
|
||||
verbLabel: "Delegating",
|
||||
});
|
||||
expect(toolTaxonomy("Agent").family).toBe("agent");
|
||||
});
|
||||
|
||||
it("collapses mcp__ names to a Plug entry with the tool segment verb", () => {
|
||||
const entry = toolTaxonomy("mcp__linear-server__search_issues");
|
||||
expect(entry.family).toBe("mcp");
|
||||
expect(entry.icon).toBe(Plug);
|
||||
expect(entry.verbLabel).toBe("Using Search_issues");
|
||||
});
|
||||
|
||||
it("falls back to the Wrench for unknown or empty names", () => {
|
||||
expect(toolTaxonomy("SomethingNovel")).toEqual({
|
||||
family: "other",
|
||||
icon: Wrench,
|
||||
verbLabel: "Working",
|
||||
});
|
||||
expect(toolTaxonomy("")).toEqual({ family: "other", icon: Wrench, verbLabel: "Working" });
|
||||
expect(toolTaxonomy(undefined).icon).toBe(Wrench);
|
||||
expect(toolTaxonomy(null).icon).toBe(Wrench);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolTaxonomy multi-word ACP titles", () => {
|
||||
it("classifies by the first word", () => {
|
||||
expect(toolTaxonomy("Read File").icon).toBe(BookOpen);
|
||||
expect(toolTaxonomy("Edit File").icon).toBe(Pencil);
|
||||
expect(toolTaxonomy("Write File").icon).toBe(Pencil);
|
||||
expect(toolTaxonomy("Terminal").icon).toBe(SquareTerminal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGenericToolName", () => {
|
||||
it("flags acpx placeholder names, including status-suffixed variants", () => {
|
||||
expect(isGenericToolName("tool call")).toBe(true);
|
||||
expect(isGenericToolName("tool call (completed)")).toBe(true);
|
||||
expect(isGenericToolName("Tool Call (failed)")).toBe(true);
|
||||
expect(isGenericToolName("acp_tool")).toBe(true);
|
||||
expect(isGenericToolName("tool")).toBe(true);
|
||||
expect(isGenericToolName("")).toBe(true);
|
||||
expect(isGenericToolName(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps real names", () => {
|
||||
expect(isGenericToolName("Terminal")).toBe(false);
|
||||
expect(isGenericToolName("Read")).toBe(false);
|
||||
expect(isGenericToolName("mcp__linear__search_issues")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpToolSegment", () => {
|
||||
it("extracts and capitalizes the tool segment", () => {
|
||||
expect(mcpToolSegment("mcp__linear-server__search_issues")).toBe("Search_issues");
|
||||
expect(mcpToolSegment("mcp__gh__create_pr")).toBe("Create_pr");
|
||||
});
|
||||
|
||||
it("returns null for non-mcp names", () => {
|
||||
expect(mcpToolSegment("Bash")).toBeNull();
|
||||
expect(mcpToolSegment("mcpish")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Shared tool taxonomy: raw ACP tool_call names ("Bash", "Grep",
|
||||
* "mcp__<server>__<tool>", …) → { family, icon, verbLabel } so the live status
|
||||
* pill, the chat tool rows, and the classic transcript all agree on which
|
||||
* glyph and verb a tool gets. The Wrench is reserved for genuinely unknown
|
||||
* tools.
|
||||
*/
|
||||
import {
|
||||
BookOpen,
|
||||
Bot,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Pencil,
|
||||
Plug,
|
||||
SquareTerminal,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type ToolFamily =
|
||||
| "terminal"
|
||||
| "search"
|
||||
| "read"
|
||||
| "edit"
|
||||
| "web"
|
||||
| "agent"
|
||||
| "mcp"
|
||||
| "other";
|
||||
|
||||
export interface ToolTaxonomyEntry {
|
||||
family: ToolFamily;
|
||||
icon: LucideIcon;
|
||||
/** Progressive verb for the status pill, without the trailing ellipsis. */
|
||||
verbLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder names that carry no tool identity. acpx fills a tool_call_update
|
||||
* whose ACP payload omits `title` with the literal "tool call", and older
|
||||
* stored logs carry "tool call (completed)" / "acp_tool" variants — none of
|
||||
* these should ever displace a real name like "Terminal" or "Read".
|
||||
*/
|
||||
const GENERIC_TOOL_NAMES = new Set(["tool", "tool call", "tool_call", "acp_tool"]);
|
||||
|
||||
export function isGenericToolName(name: string | undefined | null): boolean {
|
||||
const raw = (name ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s*\((?:pending|in_progress|completed|failed|cancelled)\)$/, "");
|
||||
return !raw || GENERIC_TOOL_NAMES.has(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse an MCP tool name ("mcp__server__tool") to its tool segment,
|
||||
* capitalized — the same rule toolDisplayName() applies. Returns null for
|
||||
* non-MCP names.
|
||||
*/
|
||||
export function mcpToolSegment(name: string): string | null {
|
||||
const mcp = name.match(/^mcp__[^_]+(?:_[^_]+)*__(.+)$/);
|
||||
if (!mcp) return null;
|
||||
const base = mcp[1];
|
||||
return base.charAt(0).toUpperCase() + base.slice(1);
|
||||
}
|
||||
|
||||
const FAMILY_META: Record<Exclude<ToolFamily, "mcp">, Omit<ToolTaxonomyEntry, "family">> = {
|
||||
terminal: { icon: SquareTerminal, verbLabel: "Running a command" },
|
||||
search: { icon: FileSearch, verbLabel: "Searching" },
|
||||
read: { icon: BookOpen, verbLabel: "Reading files" },
|
||||
edit: { icon: Pencil, verbLabel: "Editing files" },
|
||||
web: { icon: Globe, verbLabel: "Fetching the web" },
|
||||
agent: { icon: Bot, verbLabel: "Delegating" },
|
||||
other: { icon: Wrench, verbLabel: "Working" },
|
||||
};
|
||||
|
||||
function classify(n: string): Exclude<ToolFamily, "mcp"> {
|
||||
// ACP titles are often multi-word ("Read File", "Edit File") — the first
|
||||
// word carries the family.
|
||||
const first = n.split(/[\s_:-]+/, 1)[0] ?? "";
|
||||
if (first === "read" || n === "notebookread") return "read";
|
||||
if (first === "edit" || first === "write" || n === "multiedit" || n === "notebookedit") {
|
||||
return "edit";
|
||||
}
|
||||
if (first === "bash" || first === "shell" || first === "run" || n.includes("terminal")) {
|
||||
return "terminal";
|
||||
}
|
||||
if (first === "grep" || first === "glob" || n.includes("search")) return "search";
|
||||
if (n.includes("fetch") || n.includes("web") || n.includes("http")) return "web";
|
||||
if (first === "task" || first === "agent") return "agent";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Map a raw tool name to its taxonomy entry; unknown/empty names → Wrench. */
|
||||
export function toolTaxonomy(name: string | undefined | null): ToolTaxonomyEntry {
|
||||
const raw = (name ?? "").trim();
|
||||
if (!raw) return { family: "other", ...FAMILY_META.other };
|
||||
const mcpTool = mcpToolSegment(raw);
|
||||
if (mcpTool) return { family: "mcp", icon: Plug, verbLabel: `Using ${mcpTool}` };
|
||||
const family = classify(raw.toLowerCase());
|
||||
return { family, ...FAMILY_META[family] };
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import {
|
||||
buildTurnSummary,
|
||||
deriveRunStatusLabel,
|
||||
toolDisplayName,
|
||||
transcriptToTaskChatItems,
|
||||
} from "./transcript-adapter";
|
||||
|
||||
const TS = "2026-07-31T12:00:00.000Z";
|
||||
|
||||
function toolCall(name: string, input?: unknown): TranscriptEntry {
|
||||
return { kind: "tool_call", ts: TS, name, toolUseId: `tool-${name}`, input } as TranscriptEntry;
|
||||
}
|
||||
|
||||
describe("deriveRunStatusLabel", () => {
|
||||
it("labels a tail tool_call with the taxonomy verb and tool · target detail", () => {
|
||||
const status = deriveRunStatusLabel([toolCall("Grep", { pattern: "ui/src/components" })]);
|
||||
expect(status.label).toBe("Searching");
|
||||
expect(status.detail).toBe("Grep · ui/src/components");
|
||||
expect(status.toolName).toBe("Grep");
|
||||
});
|
||||
|
||||
it("uses family verbs per tool", () => {
|
||||
expect(deriveRunStatusLabel([toolCall("Bash", { command: "pnpm test" })]).label).toBe(
|
||||
"Running a command",
|
||||
);
|
||||
expect(deriveRunStatusLabel([toolCall("Edit", { file_path: "a.ts" })]).label).toBe(
|
||||
"Editing files",
|
||||
);
|
||||
expect(deriveRunStatusLabel([toolCall("mcp__linear__search_issues")]).label).toBe(
|
||||
"Using Search_issues",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the target from the detail when the input has none", () => {
|
||||
const status = deriveRunStatusLabel([toolCall("Read")]);
|
||||
expect(status.detail).toBe("Read");
|
||||
});
|
||||
|
||||
it("keeps Thinking / Responding / Running fallbacks", () => {
|
||||
expect(
|
||||
deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hmm" } as TranscriptEntry]).label,
|
||||
).toBe("Thinking");
|
||||
expect(
|
||||
deriveRunStatusLabel([{ kind: "assistant", ts: TS, text: "done" } as TranscriptEntry]).label,
|
||||
).toBe("Responding");
|
||||
expect(deriveRunStatusLabel([]).label).toBe("Running");
|
||||
// A settled tool (tool_result at the tail) means "between tools" → Running.
|
||||
expect(
|
||||
deriveRunStatusLabel([
|
||||
toolCall("Bash", { command: "ls" }),
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "tool-Bash", content: "ok" } as TranscriptEntry,
|
||||
]).label,
|
||||
).toBe("Running");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolDisplayName", () => {
|
||||
it("collapses mcp names the same way the taxonomy does", () => {
|
||||
expect(toolDisplayName("mcp__linear-server__search_issues")).toBe("Search_issues");
|
||||
expect(toolDisplayName("bash")).toBe("Bash");
|
||||
expect(toolDisplayName("")).toBe("Tool");
|
||||
expect(toolDisplayName("tool")).toBe("Tool");
|
||||
});
|
||||
|
||||
it("maps acpx placeholder names to the generic Tool label", () => {
|
||||
expect(toolDisplayName("tool call")).toBe("Tool");
|
||||
expect(toolDisplayName("tool call (completed)")).toBe("Tool");
|
||||
expect(toolDisplayName("acp_tool")).toBe("Tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transcriptToTaskChatItems tool_call updates", () => {
|
||||
const opts = { runId: "run-1", running: false };
|
||||
|
||||
function update(toolUseId: string, status: string): TranscriptEntry {
|
||||
// Mirrors what a persisted acpx tool_call_update line parses to: the
|
||||
// literal placeholder name plus a synthesized { text, status } input.
|
||||
return {
|
||||
kind: "tool_call",
|
||||
ts: TS,
|
||||
name: "tool call",
|
||||
toolUseId,
|
||||
input: { text: `tool call (${status})`, status },
|
||||
} as TranscriptEntry;
|
||||
}
|
||||
|
||||
it("keeps the initial real name and target when a generic update arrives", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
toolCall("Terminal", { command: "pnpm test" }),
|
||||
update("tool-Terminal", "completed"),
|
||||
{
|
||||
kind: "tool_result",
|
||||
ts: TS,
|
||||
toolUseId: "tool-Terminal",
|
||||
toolName: "tool call",
|
||||
content: "ok",
|
||||
} as TranscriptEntry,
|
||||
],
|
||||
opts,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
const tool = items[0];
|
||||
expect(tool.kind).toBe("tool");
|
||||
if (tool.kind !== "tool") return;
|
||||
expect(tool.name).toBe("Terminal");
|
||||
expect(tool.rawName).toBe("Terminal");
|
||||
expect(tool.target).toBe("pnpm test");
|
||||
expect(tool.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("keeps identity on retitle and uses the invocation as the target", () => {
|
||||
// Real stored sequence: "Terminal" (pending) → retitle to the command →
|
||||
// generic "tool call" completion updates.
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
{ kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-2" } as TranscriptEntry,
|
||||
{ kind: "tool_call", ts: TS, name: "ls -la", toolUseId: "tc-2" } as TranscriptEntry,
|
||||
update("tc-2", "completed"),
|
||||
],
|
||||
opts,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
if (items[0].kind !== "tool") return;
|
||||
expect(items[0].name).toBe("Terminal");
|
||||
expect(items[0].rawName).toBe("Terminal");
|
||||
expect(items[0].target).toBe("ls -la");
|
||||
});
|
||||
|
||||
it("never renders the synthesized { text, status } input as a target", () => {
|
||||
const items = transcriptToTaskChatItems([update("tc-orphan", "pending")], opts);
|
||||
expect(items).toHaveLength(1);
|
||||
if (items[0].kind !== "tool") return;
|
||||
expect(items[0].target).toBeUndefined();
|
||||
});
|
||||
|
||||
it("upgrades a generic-named call when a later entry carries the real name", () => {
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
{ kind: "tool_call", ts: TS, name: "tool call", toolUseId: "tc-1" } as TranscriptEntry,
|
||||
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "tc-1" } as TranscriptEntry,
|
||||
],
|
||||
opts,
|
||||
);
|
||||
expect(items).toHaveLength(1);
|
||||
if (items[0].kind !== "tool") return;
|
||||
expect(items[0].name).toBe("Read");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTurnSummary tool counting", () => {
|
||||
function statusEntry(toolUseId: string | undefined, status: string): TranscriptEntry {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
ts: TS,
|
||||
name: "Bash",
|
||||
toolUseId,
|
||||
input: { text: `tool call (${status})`, status },
|
||||
} as TranscriptEntry;
|
||||
}
|
||||
|
||||
it("counts unique tool calls, not per-status transcript entries", () => {
|
||||
// 4 real calls × 4 status changes each = 16 entries; the summary must
|
||||
// match the 4 rows the expanded list renders.
|
||||
const entries = ["tc-1", "tc-2", "tc-3", "tc-4"].flatMap((id) =>
|
||||
["pending", "in_progress", "in_progress", "completed"].map((status) =>
|
||||
statusEntry(id, status),
|
||||
),
|
||||
);
|
||||
expect(entries).toHaveLength(16);
|
||||
expect(buildTurnSummary(entries).toolCount).toBe(4);
|
||||
});
|
||||
|
||||
it("counts id-less legacy entries once each", () => {
|
||||
const entries = [
|
||||
statusEntry(undefined, "completed"),
|
||||
statusEntry(undefined, "completed"),
|
||||
toolCall("Read"),
|
||||
];
|
||||
expect(buildTurnSummary(entries).toolCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRunStatusLabel with generic tail updates", () => {
|
||||
it("recovers the real tool name from the call's initial entry", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
toolCall("Terminal", { command: "ls -la" }),
|
||||
{
|
||||
kind: "tool_call",
|
||||
ts: TS,
|
||||
name: "tool call",
|
||||
toolUseId: "tool-Terminal",
|
||||
} as TranscriptEntry,
|
||||
]);
|
||||
expect(status.label).toBe("Running a command");
|
||||
expect(status.toolName).toBe("Terminal");
|
||||
});
|
||||
|
||||
it("treats a tail retitle as the invocation, not the identity", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
{ kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-3" } as TranscriptEntry,
|
||||
{ kind: "tool_call", ts: TS, name: "pnpm test", toolUseId: "tc-3" } as TranscriptEntry,
|
||||
]);
|
||||
expect(status.label).toBe("Running a command");
|
||||
expect(status.toolName).toBe("Terminal");
|
||||
expect(status.detail).toBe("Terminal · pnpm test");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,389 @@
|
|||
/**
|
||||
* Live adapter: map a run's streaming TranscriptEntry[] (from
|
||||
* useLiveRunTranscripts — the same source the current thread consumes) into the
|
||||
* redesign's TaskChatItem[]. This is what lets a real task stream
|
||||
* thinking → tool → diff → responding while a run is in flight, rather than
|
||||
* only showing the final message once the turn produces a comment.
|
||||
*/
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import type {
|
||||
TaskChatDiff,
|
||||
TaskChatItem,
|
||||
TaskChatToolItem,
|
||||
TaskChatTurnItem,
|
||||
} from "./task-chat-model";
|
||||
import { isGenericToolName, mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
|
||||
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
"failed",
|
||||
"timed_out",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
"succeeded",
|
||||
]);
|
||||
|
||||
export function isTerminalRunStatus(status: string | undefined | null): boolean {
|
||||
return status != null && TERMINAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
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;
|
||||
/** True while the run is still in flight (drives streaming cursors). */
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a transcript into ordered items, coalescing consecutive thinking and
|
||||
* assistant deltas and threading tool_result/diff onto their tool_call. Mirrors
|
||||
* buildAssistantPartsFromTranscript's grouping, emitting our presentation model.
|
||||
*/
|
||||
export function transcriptToTaskChatItems(
|
||||
entries: readonly TranscriptEntry[],
|
||||
{ runId, agentName, running }: TranscriptAdapterOptions,
|
||||
): 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;
|
||||
|
||||
const resetInline = () => {
|
||||
thinkingIndex = -1;
|
||||
messageIndex = -1;
|
||||
};
|
||||
|
||||
for (const [i, entry] of entries.entries()) {
|
||||
switch (entry.kind) {
|
||||
case "thinking": {
|
||||
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;
|
||||
}
|
||||
} 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,
|
||||
});
|
||||
thinkingIndex = items.length - 1;
|
||||
thinkingStartTs.set(thinkingIndex, entry.ts);
|
||||
messageIndex = -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
if (!entry.text) break;
|
||||
if (messageIndex >= 0) {
|
||||
const it = items[messageIndex];
|
||||
if (it.kind === "message") it.text += entry.text;
|
||||
} else {
|
||||
items.push({
|
||||
id: `${runId}:msg:${i}`,
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
authorName: agentName,
|
||||
text: entry.text,
|
||||
streaming: running,
|
||||
});
|
||||
messageIndex = items.length - 1;
|
||||
thinkingIndex = -1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
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!;
|
||||
} else {
|
||||
items.push({
|
||||
id: `${runId}:tool:${toolCallId}`,
|
||||
kind: "tool",
|
||||
name: toolDisplayName(entry.name),
|
||||
rawName: entry.name ?? undefined,
|
||||
target: summarizeToolInput(entry.input),
|
||||
status: "in_progress",
|
||||
});
|
||||
toolIndexById.set(toolCallId, items.length - 1);
|
||||
lastToolIndex = items.length - 1;
|
||||
}
|
||||
resetInline();
|
||||
break;
|
||||
}
|
||||
case "tool_result": {
|
||||
const toolCallId = entry.toolUseId || `tool-result-${i}`;
|
||||
const idx = toolIndexById.get(toolCallId);
|
||||
if (idx != null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
resetInline();
|
||||
break;
|
||||
}
|
||||
case "diff": {
|
||||
const line = { kind: diffKind(entry.changeType), text: entry.text ?? "" };
|
||||
if (lastToolIndex >= 0 && items[lastToolIndex].kind === "tool") {
|
||||
const tool = items[lastToolIndex] as TaskChatToolItem;
|
||||
const diff: TaskChatDiff = tool.diff ?? { added: 0, removed: 0, lines: [] };
|
||||
diff.lines = diff.lines ?? [];
|
||||
diff.lines.push(line);
|
||||
if (line.kind === "add") diff.added += 1;
|
||||
if (line.kind === "remove") diff.removed += 1;
|
||||
tool.diff = diff;
|
||||
} else {
|
||||
items.push({
|
||||
id: `${runId}:diff:${i}`,
|
||||
kind: "tool",
|
||||
name: "Edit",
|
||||
status: "completed",
|
||||
diff: {
|
||||
added: line.kind === "add" ? 1 : 0,
|
||||
removed: line.kind === "remove" ? 1 : 0,
|
||||
lines: [line],
|
||||
},
|
||||
});
|
||||
lastToolIndex = items.length - 1;
|
||||
}
|
||||
resetInline();
|
||||
break;
|
||||
}
|
||||
// init / result / stderr / stdout / system / user carry no thread-visible
|
||||
// content in the live turn (status is rendered separately).
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
let durationMs = opts.durationMs;
|
||||
if (durationMs == null && entries.length >= 2) {
|
||||
const first = Date.parse(entries[0].ts);
|
||||
const last = Date.parse(entries[entries.length - 1].ts);
|
||||
if (Number.isFinite(first) && Number.isFinite(last)) durationMs = last - first;
|
||||
}
|
||||
return {
|
||||
durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined,
|
||||
toolCount: toolIds.size,
|
||||
added,
|
||||
removed,
|
||||
tokensLabel: formatTokensLabel(tokens),
|
||||
failed: opts.failed || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
|
||||
label: string;
|
||||
detail?: string;
|
||||
toolName?: 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,
|
||||
};
|
||||
}
|
||||
if (entry.kind === "tool_result") break;
|
||||
if (entry.kind === "assistant") return { label: "Responding" };
|
||||
if (entry.kind === "thinking") return { label: "Thinking" };
|
||||
}
|
||||
return { label: "Running" };
|
||||
}
|
||||
|
|
@ -12,8 +12,14 @@ import {
|
|||
GitCompare,
|
||||
TerminalSquare,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { toolTaxonomy } from "../task-chat/tool-taxonomy";
|
||||
|
||||
/** Family glyph for a tool block/row; the taxonomy falls back to Wrench. */
|
||||
function ToolFamilyIcon({ name, className }: { name: string; className?: string }) {
|
||||
const Icon = toolTaxonomy(name).icon;
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
|
||||
export type TranscriptMode = "nice" | "raw";
|
||||
export type TranscriptDensity = "comfortable" | "compact";
|
||||
|
|
@ -930,7 +936,7 @@ function TranscriptToolCard({
|
|||
) : block.status === "completed" ? (
|
||||
<Check className={iconClass} />
|
||||
) : (
|
||||
<Wrench className={iconClass} />
|
||||
<ToolFamilyIcon name={block.name} className={iconClass} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
|
|
@ -1197,7 +1203,7 @@ function TranscriptToolGroup({
|
|||
isItemRunning && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
<ToolFamilyIcon name={item.name} className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1240,7 +1246,7 @@ function TranscriptToolGroup({
|
|||
? "border-blue-500/25 bg-blue-500/[0.08] text-blue-600 dark:text-blue-300"
|
||||
: "border-border/70 bg-background text-foreground/55",
|
||||
)}>
|
||||
<Wrench className="h-3 w-3" />
|
||||
<ToolFamilyIcon name={item.name} className="h-3 w-3" />
|
||||
</span>
|
||||
<span className={cn("text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-eyebrow) text-muted-foreground")}>
|
||||
{humanizeLabel(item.name)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { IssueDocument } from "@paperclipai/shared";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/**
|
||||
* The issue's `plan` document, or null when none exists (a 404 is the normal
|
||||
* "no plan yet" answer, not an error). Shared by the redesigned thread (its
|
||||
* "Plan updated" marker) and the properties pane's Plan tab, so both consume
|
||||
* one cached fetch. Keyed under queryKeys.issues.documents so document-scope
|
||||
* invalidations refresh it.
|
||||
*/
|
||||
export function useIssuePlanDocument(issueId: string | null | undefined) {
|
||||
return useQuery<IssueDocument | null>({
|
||||
queryKey: [...queryKeys.issues.documents(issueId ?? ""), "plan"],
|
||||
enabled: Boolean(issueId),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await issuesApi.getDocument(issueId!, "plan");
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { useContext } from "react";
|
||||
import { QueryClient, QueryClientContext, useQuery } from "@tanstack/react-query";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
/**
|
||||
* Fallback client for hosts that render gated components without a
|
||||
* QueryClientProvider (isolated unit-test mounts, storybook-style renders).
|
||||
* The query is disabled in that case, so this client never fetches — it only
|
||||
* keeps `useQuery` from throwing. Created lazily so app code never pays for it.
|
||||
*/
|
||||
let detachedClient: QueryClient | null = null;
|
||||
function getDetachedClient(): QueryClient {
|
||||
detachedClient ??= new QueryClient();
|
||||
return detachedClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task Chat Redesign experimental flag.
|
||||
*
|
||||
* Wraps the shared experimental-settings query so gated call sites don't
|
||||
* repeat the boilerplate. `enabled` stays false while the query is in flight
|
||||
* (no flash of gated UI); `loaded` lets route gates avoid redirecting away
|
||||
* before the flag value is actually known.
|
||||
*
|
||||
* Renders without a QueryClientProvider resolve to the flag-off default
|
||||
* (`{ enabled: false, loaded: true }`) instead of throwing, so widely shared
|
||||
* leaf components (e.g. the task detail thread) stay mountable in isolation —
|
||||
* and, critically, flag-off is provably the current behavior.
|
||||
*/
|
||||
export function useTaskChatRedesignEnabled(): { enabled: boolean; loaded: boolean } {
|
||||
const contextClient = useContext(QueryClientContext);
|
||||
const { data, isFetched } = useQuery(
|
||||
{
|
||||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
enabled: contextClient != null,
|
||||
},
|
||||
contextClient ?? getDetachedClient(),
|
||||
);
|
||||
if (!contextClient) {
|
||||
return { enabled: false, loaded: true };
|
||||
}
|
||||
return { enabled: data?.enableTaskChatRedesign === true, loaded: isFetched };
|
||||
}
|
||||
187
ui/src/index.css
187
ui/src/index.css
|
|
@ -193,6 +193,75 @@
|
|||
--gtc-folder-row-actions: 1rem minmax(0, 1fr) auto auto;
|
||||
--sz-folder-rail: 13.25rem;
|
||||
--sz-folder-sheet-max: 80dvh;
|
||||
--sz-tweak-panel-max: 70vh; /* Task Chat Redesign dev tweak panel scroll cap. */
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────
|
||||
Motion tokens — Task Chat Redesign (flag: enableTaskChatRedesign).
|
||||
|
||||
These are the single source of truth for the redesigned thread's motion.
|
||||
Kept in :root (NOT @theme inline) on purpose: @theme inline bakes literals
|
||||
at build time, so the dev tweak panel — which writes these live via
|
||||
element.style.setProperty — could not move a baked value. Rationale for
|
||||
each transition point lives in DESIGN.md; VALUES here are tunable
|
||||
placeholders the human tunes live via the tweak panel, then pastes back.
|
||||
Reuses the two house easing curves already used across the app. Any new
|
||||
motion in the redesigned thread MUST reference one of these tokens — no
|
||||
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. */
|
||||
--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 */
|
||||
|
||||
/* Duration primitives (tunable) */
|
||||
--motion-duration-instant: 80ms;
|
||||
--motion-duration-fast: 160ms;
|
||||
--motion-duration-base: 240ms;
|
||||
--motion-duration-slow: 360ms;
|
||||
--motion-duration-deliberate: 520ms;
|
||||
|
||||
/* State/component-scoped — grouped for the tweak panel (tunable) */
|
||||
--motion-marker-enter: var(--motion-duration-base);
|
||||
--motion-bubble-enter: var(--motion-duration-fast);
|
||||
--motion-cot-line-stagger: 40ms;
|
||||
--motion-cot-collapse: var(--motion-duration-base);
|
||||
--motion-tool-enter: var(--motion-duration-base);
|
||||
--motion-diff-reveal: var(--motion-duration-base);
|
||||
--motion-status-enter: var(--motion-duration-fast);
|
||||
--motion-status-exit: var(--motion-duration-fast);
|
||||
--motion-approval-pulse: 1.6s;
|
||||
--motion-plan-entry-stagger: 40ms;
|
||||
--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-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 {
|
||||
|
|
@ -204,6 +273,7 @@
|
|||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--bubble-agent: oklch(0.24 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
|
|
@ -450,6 +520,115 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Task Chat Redesign motion tokens collapse to instant under reduced motion.
|
||||
Zeroing the primitives cascades to every scoped token that references them;
|
||||
the literal-valued scoped tokens (staggers, loops) are zeroed explicitly. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--motion-duration-instant: 0ms;
|
||||
--motion-duration-fast: 0ms;
|
||||
--motion-duration-base: 0ms;
|
||||
--motion-duration-slow: 0ms;
|
||||
--motion-duration-deliberate: 0ms;
|
||||
--motion-cot-line-stagger: 0ms;
|
||||
--motion-approval-pulse: 0ms;
|
||||
--motion-plan-entry-stagger: 0ms;
|
||||
--motion-streaming-cursor-blink: 0ms;
|
||||
--motion-turn-fold: 0ms;
|
||||
--motion-scroll-pill-enter: 0ms;
|
||||
--motion-scroll-pill-exit: 0ms;
|
||||
--motion-pane-glide: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────
|
||||
Task Chat Redesign animation utilities (flag: enableTaskChatRedesign).
|
||||
|
||||
These classes are the ONLY place the redesigned thread's motion is applied;
|
||||
every duration/easing is a var(--motion-*) reference (never a literal), so
|
||||
(a) the no-hardcoded-timing gate passes and (b) the dev tweak panel retunes
|
||||
them live by writing the tokens on :root. Keep timing here, in tokens — the
|
||||
components only add class names.
|
||||
──────────────────────────────────────────────────────────────────────── */
|
||||
@keyframes tc-fade-rise {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes tc-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes tc-status-in {
|
||||
from { opacity: 0; transform: translateY(2px) scale(0.98); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes tc-approval-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.0); }
|
||||
50% { box-shadow: 0 0 0 3px hsl(var(--primary) / 0.18); }
|
||||
}
|
||||
@keyframes tc-cursor-blink {
|
||||
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; }
|
||||
.tc-enter-status { animation: tc-status-in var(--motion-status-enter) var(--motion-ease-out-expo) both; }
|
||||
.tc-enter-cot-line { animation: tc-fade-rise var(--motion-duration-fast) var(--motion-ease-standard) both; }
|
||||
.tc-reveal-diff { animation: tc-fade-in var(--motion-diff-reveal) var(--motion-ease-standard) both; }
|
||||
.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; }
|
||||
|
||||
/* 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,
|
||||
.tc-enter-marker,
|
||||
.tc-enter-tool,
|
||||
.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-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; }
|
||||
}
|
||||
|
||||
/* Expandable dialog transition for max-width changes */
|
||||
[data-slot="dialog-content"] {
|
||||
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
|
@ -709,6 +888,14 @@
|
|||
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);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer-text {
|
||||
animation: none;
|
||||
|
|
|
|||
|
|
@ -258,7 +258,7 @@ function sortByCreated<T extends { createdAt: Date | string; id: string }>(items
|
|||
});
|
||||
}
|
||||
|
||||
function latestSameRunHandoffTimestamp(args: {
|
||||
export function latestSameRunHandoffTimestamp(args: {
|
||||
interactionCreatedAtMs: number;
|
||||
sourceRunId: string;
|
||||
comments: readonly IssueChatComment[];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import { extractIssueTimelineEvents } from "./issue-timeline-events";
|
||||
import {
|
||||
extractIssueTimelineEvents,
|
||||
extractIssueWorkModeChanges,
|
||||
workModeInEffectAt,
|
||||
} from "./issue-timeline-events";
|
||||
|
||||
describe("extractIssueTimelineEvents", () => {
|
||||
it("extracts and sorts status and assignee changes from issue updates", () => {
|
||||
|
|
@ -291,3 +295,120 @@ describe("extractIssueTimelineEvents", () => {
|
|||
expect(events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractIssueWorkModeChanges", () => {
|
||||
function workModeEvent(args: {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
details: Record<string, unknown>;
|
||||
}): ActivityEvent {
|
||||
return {
|
||||
id: args.id,
|
||||
companyId: "company-1",
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
createdAt: new Date(args.createdAt),
|
||||
details: args.details,
|
||||
} satisfies ActivityEvent;
|
||||
}
|
||||
|
||||
it("extracts and sorts real work-mode switches", () => {
|
||||
const changes = extractIssueWorkModeChanges([
|
||||
workModeEvent({
|
||||
id: "evt-2",
|
||||
createdAt: "2026-03-31T12:05:00.000Z",
|
||||
details: { workMode: "standard", _previous: { workMode: "ask" } },
|
||||
}),
|
||||
workModeEvent({
|
||||
id: "evt-1",
|
||||
createdAt: "2026-03-31T12:01:00.000Z",
|
||||
details: { workMode: "ask", _previous: { workMode: "planning" } },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(changes).toEqual([
|
||||
{
|
||||
id: "evt-1",
|
||||
createdAt: new Date("2026-03-31T12:01:00.000Z"),
|
||||
from: "planning",
|
||||
to: "ask",
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
createdAt: new Date("2026-03-31T12:05:00.000Z"),
|
||||
from: "ask",
|
||||
to: "standard",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores no-op patches and unrelated updates", () => {
|
||||
const changes = extractIssueWorkModeChanges([
|
||||
// A PATCH resending the current mode records no _previous.workMode.
|
||||
workModeEvent({
|
||||
id: "evt-noop",
|
||||
createdAt: "2026-03-31T12:01:00.000Z",
|
||||
details: { workMode: "ask", _previous: undefined },
|
||||
}),
|
||||
workModeEvent({
|
||||
id: "evt-other-field",
|
||||
createdAt: "2026-03-31T12:02:00.000Z",
|
||||
details: { workMode: "ask", _previous: { status: "todo" } },
|
||||
}),
|
||||
workModeEvent({
|
||||
id: "evt-status-only",
|
||||
createdAt: "2026-03-31T12:03:00.000Z",
|
||||
details: { status: "done", _previous: { status: "in_progress" } },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workModeInEffectAt", () => {
|
||||
const changes = [
|
||||
{
|
||||
id: "evt-1",
|
||||
createdAt: "2026-03-31T12:10:00.000Z",
|
||||
from: "standard",
|
||||
to: "planning",
|
||||
},
|
||||
{
|
||||
id: "evt-2",
|
||||
createdAt: "2026-03-31T12:20:00.000Z",
|
||||
from: "planning",
|
||||
to: "ask",
|
||||
},
|
||||
];
|
||||
|
||||
function at(iso: string) {
|
||||
return new Date(iso).getTime();
|
||||
}
|
||||
|
||||
it("uses the first change's `from` before any switch happened", () => {
|
||||
expect(workModeInEffectAt(changes, at("2026-03-31T12:00:00.000Z"), "ask")).toBe("standard");
|
||||
});
|
||||
|
||||
it("uses the last switch at or before the timestamp", () => {
|
||||
expect(workModeInEffectAt(changes, at("2026-03-31T12:10:00.000Z"), "standard")).toBe("planning");
|
||||
expect(workModeInEffectAt(changes, at("2026-03-31T12:15:00.000Z"), "standard")).toBe("planning");
|
||||
expect(workModeInEffectAt(changes, at("2026-03-31T12:30:00.000Z"), "standard")).toBe("ask");
|
||||
});
|
||||
|
||||
it("falls back to the issue's current mode without usable history", () => {
|
||||
expect(workModeInEffectAt([], at("2026-03-31T12:00:00.000Z"), "planning")).toBe("planning");
|
||||
expect(
|
||||
workModeInEffectAt(
|
||||
[{ id: "evt-x", createdAt: "2026-03-31T12:10:00.000Z", from: null, to: "bogus" }],
|
||||
at("2026-03-31T12:00:00.000Z"),
|
||||
"ask",
|
||||
),
|
||||
).toBe("ask");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import type { ActivityEvent, IssueWorkMode } from "@paperclipai/shared";
|
||||
import { isIssueWorkMode } from "@/lib/work-mode-meta";
|
||||
|
||||
export interface IssueTimelineAssignee {
|
||||
agentId: string | null;
|
||||
|
|
@ -95,6 +96,65 @@ function sortTimelineEvents<T extends { createdAt: Date | string; id: string }>(
|
|||
});
|
||||
}
|
||||
|
||||
export interface IssueWorkModeChange {
|
||||
id: string;
|
||||
createdAt: Date | string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Work-mode switch history for an issue, from `issue.updated` activity events
|
||||
* (the PATCH route records changed fields plus their `_previous` values).
|
||||
* Extracted separately from extractIssueTimelineEvents so mode switches don't
|
||||
* become system messages in the thread — they only feed per-reply mode
|
||||
* attribution in the task-chat redesign.
|
||||
*/
|
||||
export function extractIssueWorkModeChanges(
|
||||
activity: ActivityEvent[] | null | undefined,
|
||||
): IssueWorkModeChange[] {
|
||||
const changes: IssueWorkModeChange[] = [];
|
||||
for (const event of activity ?? []) {
|
||||
if (event.action !== "issue.updated") continue;
|
||||
const details = asRecord(event.details);
|
||||
if (!details || !hasOwn(details, "workMode")) continue;
|
||||
// `_previous` only records fields whose value actually changed — a PATCH
|
||||
// that resent the current mode has no `_previous.workMode` and is a no-op.
|
||||
const previous = asRecord(details._previous);
|
||||
if (!previous || !hasOwn(previous, "workMode")) continue;
|
||||
changes.push({
|
||||
id: event.id,
|
||||
createdAt: event.createdAt,
|
||||
from: nullableString(previous.workMode),
|
||||
to: nullableString(details.workMode),
|
||||
});
|
||||
}
|
||||
return sortTimelineEvents(changes);
|
||||
}
|
||||
|
||||
/**
|
||||
* The issue's work mode in effect at `atMs`, reconstructed from the (sorted)
|
||||
* change history: the last change at-or-before `atMs` wins; before the first
|
||||
* known change the mode is that change's `from`; with no usable history the
|
||||
* `fallback` (the issue's current mode) applies.
|
||||
*/
|
||||
export function workModeInEffectAt(
|
||||
changes: readonly IssueWorkModeChange[],
|
||||
atMs: number,
|
||||
fallback: IssueWorkMode,
|
||||
): IssueWorkMode {
|
||||
let mode: IssueWorkMode | null = null;
|
||||
for (const change of changes) {
|
||||
if (toTimestamp(change.createdAt) <= atMs) {
|
||||
if (isIssueWorkMode(change.to)) mode = change.to;
|
||||
} else {
|
||||
if (mode === null && isIssueWorkMode(change.from)) mode = change.from;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return mode ?? fallback;
|
||||
}
|
||||
|
||||
export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | undefined): IssueTimelineEvent[] {
|
||||
const events: IssueTimelineEvent[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ const STREAMLINED_TOGGLE_SELECTOR =
|
|||
'button[aria-label="Toggle streamlined left navigation experimental setting"]';
|
||||
const TASK_WATCHDOGS_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle task watchdogs experimental setting"]';
|
||||
const TASK_CHAT_REDESIGN_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle chat-style tasks experimental setting"]';
|
||||
const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle goals sidebar link experimental setting"]';
|
||||
const DECISIONS_TOGGLE_SELECTOR =
|
||||
|
|
@ -76,6 +78,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableTaskChatRedesign: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
|
|
@ -290,6 +293,52 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
|||
});
|
||||
});
|
||||
|
||||
it("renders and patches the Chat-Style Tasks experimental toggle on and off", async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Chat-Style Tasks");
|
||||
expect(container.textContent).toContain(
|
||||
"Reimagines the task detail page as a live conversation with your agents",
|
||||
);
|
||||
expect(container.textContent).toContain(
|
||||
"Turning this off instantly restores the classic task page. No task data is affected.",
|
||||
);
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(TASK_CHAT_REDESIGN_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
||||
enableTaskChatRedesign: true,
|
||||
});
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
flushSync(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container.textContent = "";
|
||||
await renderPage();
|
||||
|
||||
const enabledToggle = container.querySelector<HTMLButtonElement>(
|
||||
TASK_CHAT_REDESIGN_TOGGLE_SELECTOR,
|
||||
);
|
||||
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
enabledToggle?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({
|
||||
enableTaskChatRedesign: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders and patches the Decisions experimental toggle", async () => {
|
||||
await renderPage();
|
||||
|
||||
|
|
|
|||
|
|
@ -363,6 +363,7 @@ export function InstanceExperimentalSettings() {
|
|||
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
|
||||
// experimental opt-out was retired, so it no longer surfaces a toggle here.
|
||||
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
|
||||
const enableTaskChatRedesign = experimentalQuery.data?.enableTaskChatRedesign === true;
|
||||
const enableIssuePlanDecompositions =
|
||||
experimentalQuery.data?.enableIssuePlanDecompositions === true;
|
||||
const enableExperimentalFileViewer =
|
||||
|
|
@ -675,6 +676,18 @@ export function InstanceExperimentalSettings() {
|
|||
ariaLabel="Toggle task plan decomposition panel experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Chat-Style Tasks"
|
||||
experimental
|
||||
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."
|
||||
footnote="Turning this off instantly restores the classic task page. No task data is affected."
|
||||
checked={enableTaskChatRedesign}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ enableTaskChatRedesign: checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
managed={managedKeys.enableTaskChatRedesign}
|
||||
ariaLabel="Toggle chat-style tasks experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Task Watchdogs"
|
||||
description="Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue."
|
||||
|
|
|
|||
|
|
@ -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 } from "../lib/issue-timeline-events";
|
||||
import { extractIssueTimelineEvents, extractIssueWorkModeChanges } from "../lib/issue-timeline-events";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
|
|
@ -90,6 +90,8 @@ import {
|
|||
type IssueChatComposerHandle,
|
||||
type IssueChatRunFinalizationAction,
|
||||
} from "../components/IssueChatThread";
|
||||
import { TaskChatThread } from "../components/TaskChatThread";
|
||||
import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
|
||||
import { workModeMetaFor } from "../lib/work-mode-meta";
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
|
||||
|
|
@ -684,9 +686,16 @@ function IssueDetailLoadingState({
|
|||
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
|
||||
}) {
|
||||
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
|
||||
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? "mx-auto w-full max-w-(--tc-shell-max-w) space-y-6"
|
||||
: "max-w-3xl space-y-6"
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-3 w-40" />
|
||||
|
||||
|
|
@ -898,6 +907,12 @@ type IssueDetailChatTabProps = {
|
|||
composerRef: Ref<IssueChatComposerHandle>;
|
||||
/** Optional node rendered inline directly above the reply composer (e.g. the monitor strip). */
|
||||
composerAccessory?: ReactNode;
|
||||
/**
|
||||
* Issue header (title row, badges, plugin toolbars) that the redesigned
|
||||
* thread renders inside its scroll viewport so it scrolls away with the
|
||||
* messages (flag: enableTaskChatRedesign). Ignored by the legacy thread.
|
||||
*/
|
||||
threadHeader?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
feedbackVotes?: FeedbackVote[];
|
||||
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
|
||||
|
|
@ -987,6 +1002,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
onWorkModeChange,
|
||||
composerRef,
|
||||
composerAccessory,
|
||||
threadHeader,
|
||||
footer,
|
||||
feedbackVotes,
|
||||
feedbackDataSharingPreference,
|
||||
|
|
@ -1026,7 +1042,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
externalReferences,
|
||||
linkCaseReferences,
|
||||
}: IssueDetailChatTabProps) {
|
||||
const ThreadComponent = IssueChatThread;
|
||||
// Seam for the Task Chat Redesign (flag: enableTaskChatRedesign). Flag OFF
|
||||
// renders IssueChatThread verbatim — the flag-off branch is provably today's
|
||||
// UI. Both components share one prop type, so no cast is needed.
|
||||
const { enabled: taskChatRedesignEnabled } = useTaskChatRedesignEnabled();
|
||||
const ThreadComponent = taskChatRedesignEnabled ? TaskChatThread : IssueChatThread;
|
||||
const { data: activity } = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId),
|
||||
queryFn: () => activityApi.forIssue(issueId),
|
||||
|
|
@ -1161,28 +1181,44 @@ 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="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}
|
||||
<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 />
|
||||
) : (
|
||||
<ThreadComponent
|
||||
composerRef={composerRef}
|
||||
composerAccessory={composerAccessory}
|
||||
threadHeader={
|
||||
taskChatRedesignEnabled && (threadHeader || loadOlderButton) ? (
|
||||
<>
|
||||
{threadHeader}
|
||||
{loadOlderButton}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
comments={commentsWithRunMeta}
|
||||
interactions={interactions}
|
||||
feedbackVotes={feedbackVotes}
|
||||
|
|
@ -1190,6 +1226,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
|||
feedbackTermsUrl={feedbackTermsUrl}
|
||||
linkedRuns={timelineRuns}
|
||||
timelineEvents={timelineEvents}
|
||||
workModeChanges={workModeChanges}
|
||||
liveRuns={resolvedLiveRuns}
|
||||
activeRun={resolvedActiveRun}
|
||||
issueId={issueId}
|
||||
|
|
@ -1533,6 +1570,18 @@ 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();
|
||||
|
|
@ -1547,6 +1596,9 @@ export function IssueDetail() {
|
|||
const [mobilePropsOpen, setMobilePropsOpen] = useState(false);
|
||||
const [fileViewerPromptOpen, setFileViewerPromptOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState("chat");
|
||||
// Redesign: the center tab strip is hidden, so chat is the only surface —
|
||||
// deep links that would switch tabs (e.g. #document- hashes) stay on chat.
|
||||
const resolvedDetailTab = taskChatShellEnabled ? "chat" : detailTab;
|
||||
const [handoffFocusSignal, setHandoffFocusSignal] = useState(0);
|
||||
const [pendingApprovalAction, setPendingApprovalAction] = useState<{
|
||||
approvalId: string;
|
||||
|
|
@ -4068,12 +4120,13 @@ export function IssueDetail() {
|
|||
</>
|
||||
);
|
||||
|
||||
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">
|
||||
// Task Chat Redesign ("not sticky" header): the parent breadcrumb, the
|
||||
// title/badge block, and the plugin toolbars render INSIDE the thread's
|
||||
// scroll viewport, so they scroll away with the messages and the composer
|
||||
// stays near the viewport bottom. Flag OFF renders the same nodes in the
|
||||
// page flow, in their original order relative to the alert banners.
|
||||
const ancestorsNav = ancestors.length > 0 && (
|
||||
<nav className={cn("flex items-center gap-1 text-xs text-muted-foreground flex-wrap", shellSectionClass)}>
|
||||
{[...ancestors].reverse().map((ancestor, i) => (
|
||||
<span key={ancestor.id} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
|
|
@ -4096,91 +4149,10 @@ export function IssueDetail() {
|
|||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
<span className="text-foreground/60 truncate max-w-(--sz-200px)">{issue.title}</span>
|
||||
</nav>
|
||||
)}
|
||||
);
|
||||
|
||||
{issue.hiddenAt && (
|
||||
<div className="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">
|
||||
const issueHeaderBlock = (
|
||||
<div className={cn("space-y-3", shellSectionClass)}>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<StatusIcon
|
||||
status={issue.status}
|
||||
|
|
@ -4239,7 +4211,11 @@ export function IssueDetail() {
|
|||
</Badge>
|
||||
) : null}
|
||||
|
||||
{issue.workMode === "ask" || issue.workMode === "planning" ? (() => {
|
||||
{/* 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") ? (() => {
|
||||
const workModeMeta = workModeMetaFor(issue.workMode);
|
||||
const WorkModeIcon = workModeMeta.icon;
|
||||
return (
|
||||
|
|
@ -4508,7 +4484,7 @@ export function IssueDetail() {
|
|||
value={issue.title}
|
||||
onSave={(title) => updateIssue.mutateAsync({ title })}
|
||||
as="h2"
|
||||
className="text-xl font-bold"
|
||||
className={taskChatShellEnabled ? "text-base font-semibold" : "text-xl font-bold"}
|
||||
/>
|
||||
|
||||
<IssueMonitorBanner
|
||||
|
|
@ -4519,26 +4495,31 @@ export function IssueDetail() {
|
|||
|
||||
<PendingDecisionStrip companyId={issue.companyId} issueId={issue.id} />
|
||||
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const pluginOutletsBlock = (
|
||||
<>
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["toolbarButton", "contextMenuItem"]}
|
||||
entityType="issue"
|
||||
|
|
@ -4548,7 +4529,7 @@ export function IssueDetail() {
|
|||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className="flex flex-wrap gap-2"
|
||||
className={cn("flex flex-wrap gap-2", shellSectionClass)}
|
||||
itemClassName="inline-flex"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
|
|
@ -4562,7 +4543,7 @@ export function IssueDetail() {
|
|||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className="flex flex-wrap gap-2"
|
||||
className={cn("flex flex-wrap gap-2", shellSectionClass)}
|
||||
itemClassName="inline-flex"
|
||||
/>
|
||||
|
||||
|
|
@ -4575,12 +4556,123 @@ export function IssueDetail() {
|
|||
entityId: issue.id,
|
||||
entityType: "issue",
|
||||
}}
|
||||
className="space-y-3"
|
||||
className={cn("space-y-3", shellSectionClass)}
|
||||
itemClassName="rounded-lg border border-border p-3"
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
{showRichSubIssuesSection ? (
|
||||
const taskChatThreadHeader = taskChatShellEnabled ? (
|
||||
<>
|
||||
{ancestorsNav}
|
||||
{issueHeaderBlock}
|
||||
{pluginOutletsBlock}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<FileViewerProvider issueId={issue.id} enabled={fileViewerEnabled}>
|
||||
<div
|
||||
className={
|
||||
taskChatShellEnabled
|
||||
? // Fill main exactly so the outer page never scrolls — the thread's
|
||||
// own viewport is the only scroll surface (h-full is inert on
|
||||
// mobile, where main has no fixed height and the page scrolls).
|
||||
"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 ? (
|
||||
<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>
|
||||
|
|
@ -4615,7 +4707,7 @@ export function IssueDetail() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showPlanDecompositionsSection ? (
|
||||
{!taskChatShellEnabled && showPlanDecompositionsSection ? (
|
||||
<IssuePlanDecompositionsSection
|
||||
issueId={issue.id}
|
||||
issueIdentifier={issue.identifier}
|
||||
|
|
@ -4623,6 +4715,9 @@ 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)}
|
||||
|
|
@ -4650,7 +4745,9 @@ export function IssueDetail() {
|
|||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
{taskChatShellEnabled ? null : (
|
||||
<IssueOutputSection
|
||||
workProducts={workProducts}
|
||||
onMediaClick={(item) => {
|
||||
|
|
@ -4665,8 +4762,9 @@ export function IssueDetail() {
|
|||
setGalleryOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{attachmentsInitialLoading ? (
|
||||
{taskChatShellEnabled ? null : attachmentsInitialLoading ? (
|
||||
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
|
||||
) : hasAttachments ? (
|
||||
<IssueAttachmentsSection
|
||||
|
|
@ -4704,6 +4802,7 @@ export function IssueDetail() {
|
|||
onOpenChange={setGalleryOpen}
|
||||
/>
|
||||
|
||||
{taskChatShellEnabled ? null : (
|
||||
<IssueWorkspaceCard
|
||||
issue={issue}
|
||||
project={resolvedProject}
|
||||
|
|
@ -4711,8 +4810,9 @@ export function IssueDetail() {
|
|||
onBrowseFiles={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
|
||||
onOpenFileByPath={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
|
||||
{!taskChatShellEnabled && fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
|
||||
const workProductsWithFileRefs = issue.workProducts
|
||||
.map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) }))
|
||||
.filter(({ fileRef }) => fileRef !== null);
|
||||
|
|
@ -4737,10 +4837,17 @@ export function IssueDetail() {
|
|||
);
|
||||
})()}
|
||||
|
||||
<Separator />
|
||||
{taskChatShellEnabled ? null : <Separator className={shellSectionClass} />}
|
||||
|
||||
<Tabs value={detailTab} onValueChange={setDetailTab} className="space-y-3">
|
||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||
<Tabs
|
||||
value={resolvedDetailTab}
|
||||
onValueChange={setDetailTab}
|
||||
className={taskChatShellEnabled ? "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)}>
|
||||
<TabsTrigger value="chat" className="gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
Chat
|
||||
|
|
@ -4759,10 +4866,18 @@ export function IssueDetail() {
|
|||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
<TabsContent value="chat">
|
||||
{detailTab === "chat" ? (
|
||||
{/* Flag ON the thread viewport extends under main's horizontal padding
|
||||
(symmetric, so the centered column keeps the same axis) and the
|
||||
scrollbar sits flush against the properties-pane border. */}
|
||||
<TabsContent
|
||||
value="chat"
|
||||
className={taskChatShellEnabled ? "-mx-4 md:-mx-6 flex min-h-0 flex-col" : undefined}
|
||||
>
|
||||
{resolvedDetailTab === "chat" ? (
|
||||
<IssueDetailChatTab
|
||||
threadHeader={taskChatThreadHeader}
|
||||
issueId={issue.id}
|
||||
companyId={issue.companyId}
|
||||
projectId={issue.projectId ?? null}
|
||||
|
|
@ -4805,7 +4920,7 @@ export function IssueDetail() {
|
|||
) : null
|
||||
}
|
||||
footer={
|
||||
siblingNavigation ? (
|
||||
!taskChatShellEnabled && siblingNavigation ? (
|
||||
<IssueSiblingNavigation
|
||||
navigation={siblingNavigation}
|
||||
linkState={resolvedIssueDetailState ?? location.state}
|
||||
|
|
@ -4862,7 +4977,7 @@ export function IssueDetail() {
|
|||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity">
|
||||
<TabsContent value="activity" className={shellSectionClass}>
|
||||
{detailTab === "activity" ? (
|
||||
<IssueDetailActivityTab
|
||||
issue={issue}
|
||||
|
|
@ -4884,7 +4999,7 @@ export function IssueDetail() {
|
|||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="related-work">
|
||||
<TabsContent value="related-work" className={shellSectionClass}>
|
||||
<IssueRelatedWorkPanel
|
||||
relatedWork={issue.relatedWork}
|
||||
externalObjectsEnabled={externalObjectsState.isEnabled}
|
||||
|
|
@ -4896,7 +5011,7 @@ export function IssueDetail() {
|
|||
</TabsContent>
|
||||
|
||||
{activePluginTab && (
|
||||
<TabsContent value={activePluginTab.value}>
|
||||
<TabsContent value={activePluginTab.value} className={shellSectionClass}>
|
||||
<PluginSlotMount
|
||||
slot={activePluginTab.slot}
|
||||
context={{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Pause, Play, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
TASK_CHAT_STATE_LIST,
|
||||
type TaskChatStateId,
|
||||
} from "@/components/task-chat/task-chat-states";
|
||||
import { buildScenario } from "@/components/task-chat/task-chat-fixtures";
|
||||
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
|
||||
import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView";
|
||||
import { TweakPanel } from "@/components/task-chat/TweakPanel";
|
||||
import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
|
||||
|
||||
/** Progressively reveal any streaming message/thinking text at `speed`. */
|
||||
function useStreamingReplay(
|
||||
baseItems: TaskChatItem[],
|
||||
speed: number,
|
||||
playToken: number,
|
||||
): TaskChatItem[] {
|
||||
const [chars, setChars] = useState<number>(Number.MAX_SAFE_INTEGER);
|
||||
const streamingIndex = baseItems.findIndex(
|
||||
(i) => (i.kind === "message" && i.streaming) || (i.kind === "thinking" && i.streaming),
|
||||
);
|
||||
const fullText = useMemo(() => {
|
||||
const it = baseItems[streamingIndex];
|
||||
if (!it) return "";
|
||||
if (it.kind === "message") return it.text;
|
||||
if (it.kind === "thinking") return it.lines.join("\n");
|
||||
return "";
|
||||
}, [baseItems, streamingIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (streamingIndex < 0) {
|
||||
setChars(Number.MAX_SAFE_INTEGER);
|
||||
return;
|
||||
}
|
||||
setChars(0);
|
||||
let n = 0;
|
||||
const baseIntervalMs = 24;
|
||||
const interval = window.setInterval(() => {
|
||||
n += 1;
|
||||
setChars(n);
|
||||
if (n >= fullText.length) window.clearInterval(interval);
|
||||
}, Math.max(4, baseIntervalMs / speed));
|
||||
return () => window.clearInterval(interval);
|
||||
}, [streamingIndex, fullText, speed, playToken]);
|
||||
|
||||
if (streamingIndex < 0) return baseItems;
|
||||
return baseItems.map((it, idx) => {
|
||||
if (idx !== streamingIndex) return it;
|
||||
const shown = fullText.slice(0, chars);
|
||||
const done = chars >= fullText.length;
|
||||
if (it.kind === "message") return { ...it, text: shown, streaming: !done };
|
||||
if (it.kind === "thinking") return { ...it, lines: shown.split("\n"), streaming: !done };
|
||||
return it;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev harness for the Task Chat Redesign (route: /dev/task-chat-lab, behind the
|
||||
* enableTaskChatRedesign flag). Drives the render layer into every inventory
|
||||
* state via synthetic events — no live agent — and is also the human's
|
||||
* post-baseline iteration cockpit: state switcher, streaming replay, a
|
||||
* 0.1×–10× speed control, and the live motion tweak panel.
|
||||
*/
|
||||
export function TaskChatLab() {
|
||||
const [selected, setSelected] = useState<TaskChatStateId>("agent-message");
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
const [playToken, setPlayToken] = useState(0);
|
||||
const scenario = useMemo(() => buildScenario(selected), [selected]);
|
||||
const replayItems = useStreamingReplay(scenario.items, speed, playToken);
|
||||
const items = playing ? replayItems : scenario.items;
|
||||
const targetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const meta = TASK_CHAT_STATE_LIST.find((m) => m.id === selected)!;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="border-b border-border px-4 py-2">
|
||||
<h1 className="text-sm font-semibold">Task Chat Lab</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Synthetic harness for the task chat redesign · every state renders here with no live agent.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* State switcher */}
|
||||
<nav className="w-56 shrink-0 overflow-y-auto border-r border-border p-2" aria-label="States">
|
||||
{(["live", "tier-b"] as const).map((tier) => (
|
||||
<div key={tier} className="mb-3">
|
||||
<p className="mb-1 px-1 text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{tier === "live" ? "Live states" : "Tier-B (synthetic)"}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{TASK_CHAT_STATE_LIST.filter((m) => m.tier === tier).map((m) => (
|
||||
<li key={m.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-state-id={m.id}
|
||||
onClick={() => {
|
||||
setSelected(m.id);
|
||||
setPlayToken((t) => t + 1);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded px-2 py-1 text-left text-xs",
|
||||
selected === m.id ? "bg-primary text-primary-foreground" : "hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Stage */}
|
||||
<main className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying((p) => !p)}
|
||||
className="flex items-center gap-1 rounded border border-border px-2 py-1 hover:bg-accent"
|
||||
>
|
||||
{playing ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
||||
{playing ? "Pause" : "Play"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlayToken((t) => t + 1)}
|
||||
className="flex items-center gap-1 rounded border border-border px-2 py-1 hover:bg-accent"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Replay
|
||||
</button>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Speed</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={speed}
|
||||
onChange={(e) => setSpeed(parseFloat(e.target.value))}
|
||||
aria-label="Streaming speed"
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="w-10 tabular-nums">{speed.toFixed(1)}×</span>
|
||||
</label>
|
||||
<span className="ml-auto font-mono text-(length:--text-micro) text-muted-foreground">{meta.protocol}</span>
|
||||
</div>
|
||||
|
||||
<div ref={targetRef} className="flex min-h-0 flex-1 flex-col" 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} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<TweakPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskChatLab;
|
||||
Loading…
Reference in New Issue