From 95061c648f09d73de6fe48ac91460c7a3cb0752a Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:11:19 +0200 Subject: [PATCH] feat(web,mobile): dock expandable live task pill above composer and fold plan steps --- apps/mobile/app/thread.tsx | 278 ++++++++- apps/web/src/pages/Shell.tsx | 12 +- .../pages/shell/composer-task-pill.test.tsx | 224 ++++++++ apps/web/src/pages/shell/composer.tsx | 5 + apps/web/src/pages/shell/message-cards.tsx | 542 +++++++++++++++++- apps/web/src/pages/shell/transcript.tsx | 48 +- apps/web/src/styles.css | 22 + 7 files changed, 1118 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/pages/shell/composer-task-pill.test.tsx diff --git a/apps/mobile/app/thread.tsx b/apps/mobile/app/thread.tsx index 37b1125..5d3533e 100644 --- a/apps/mobile/app/thread.tsx +++ b/apps/mobile/app/thread.tsx @@ -358,6 +358,9 @@ function Thread() { () => reactionView.visibleMessages.filter((message) => !runNarration.hidden.has(message.id)), [reactionView.visibleMessages, runNarration], ); + const [taskPillExpanded, setTaskPillExpanded] = useState(false); + const [dismissedPlanKey, setDismissedPlanKey] = useState(null); + const [taskPillSubStepsOpen, setTaskPillSubStepsOpen] = useState(false); const latestMessageId = visibleMessages.at(-1)?.id ?? null; const activePendingAttachments = attachmentsForThread(pendingAttachments, threadKey); const composerMentionTargets = useMemo( @@ -436,6 +439,92 @@ function Thread() { }); }, [inGroup, snap?.activeRuns, snap?.members, snap?.run]); const working = inGroup ? workingGroupBots.length > 0 : isWorkingStatus(currentBotStatus); + const activeMobilePlan = useMemo(() => { + const msgs = reactionView.visibleMessages; + let lastUserIdx = -1; + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i]?.role === "user") { + lastUserIdx = i; + break; + } + } + let planIdx = -1; + let planBlock: Extract | null = null; + for (let i = msgs.length - 1; i > lastUserIdx; i--) { + const m = msgs[i]; + if (!m) continue; + for (let b = m.blocks.length - 1; b >= 0; b--) { + const block = m.blocks[b]; + if (block?.kind === "plan") { + planBlock = block; + planIdx = i; + break; + } + } + if (planBlock) break; + } + if (!planBlock) { + for (let i = lastUserIdx - 1; i >= 0; i--) { + const m = msgs[i]; + if (!m) continue; + for (let b = m.blocks.length - 1; b >= 0; b--) { + const block = m.blocks[b]; + if (block?.kind === "plan" && block.status !== "completed") { + planBlock = block; + planIdx = i; + break; + } + } + if (planBlock) break; + } + } + if (!planBlock) return null; + const absorbedIds = new Set(); + const stepIds = new Set(); + const steps: MobileMessage[] = []; + for (let i = Math.max(0, lastUserIdx + 1, planIdx); i < msgs.length; i++) { + const m = msgs[i]; + if (!m) continue; + const folded = runNarration.steps.get(m.id); + if (folded && folded.length > 0) { + absorbedIds.add(m.id); + for (const s of folded) { + if (!stepIds.has(s.id)) { + stepIds.add(s.id); + steps.push(s); + } + } + } + } + const doneCount = planBlock.steps.filter( + (s) => s.status === "completed" || s.status === "skipped", + ).length; + const isCompleted = + planBlock.status === "completed" || + (planBlock.steps.length > 0 && doneCount === planBlock.steps.length && !working); + return { + block: planBlock, + steps, + absorbedIds, + doneCount, + isCompleted, + completionKey: `${planBlock.planId}:${isCompleted ? "done" : "active"}`, + }; + }, [reactionView.visibleMessages, runNarration.steps, working]); + + useEffect(() => { + if (!activeMobilePlan?.isCompleted) { + setDismissedPlanKey(null); + return; + } + const key = activeMobilePlan.completionKey; + const collapseTimer = setTimeout(() => setTaskPillExpanded(false), 1800); + const hideTimer = setTimeout(() => setDismissedPlanKey(key), 3500); + return () => { + clearTimeout(collapseTimer); + clearTimeout(hideTimer); + }; + }, [activeMobilePlan?.completionKey, activeMobilePlan?.isCompleted]); const activeRunCreatedAt = useMemo(() => { const candidates = snap?.activeRuns ?? (snap?.run ? [snap.run] : []); return candidates.find((run) => isWorkingStatus(run.status))?.createdAt; @@ -1654,7 +1743,9 @@ function Thread() { flexShrink: 1, }} > - {foldedSteps ? : null} + {foldedSteps && !activeMobilePlan?.absorbedIds.has(message.id) ? ( + + ) : null} ) : null} + {activeMobilePlan && + !(activeMobilePlan.isCompleted && dismissedPlanKey === activeMobilePlan.completionKey) ? ( + + setTaskPillExpanded((current) => !current)} + style={{ gap: 6 }} + > + + + + + {activeMobilePlan.isCompleted ? "✓" : "▸"} + + + {activeMobilePlan.isCompleted + ? activeMobilePlan.block.title + : (( + activeMobilePlan.block.steps.find((s) => s.status === "in_progress") ?? + activeMobilePlan.block.steps.find((s) => s.status === "pending") + )?.label ?? activeMobilePlan.block.title)} + + + + + {activeMobilePlan.isCompleted + ? t("Completed") + : `${activeMobilePlan.doneCount}/${activeMobilePlan.block.steps.length}`} + + + + + + {taskPillExpanded ? ( + + + {activeMobilePlan.block.title} + + {activeMobilePlan.block.steps.map((step) => { + const done = step.status === "completed"; + const active = step.status === "in_progress"; + const skipped = step.status === "skipped"; + const glyph = done ? "✓" : active ? "▸" : skipped ? "–" : "○"; + return ( + + + {glyph} + + + + {step.label} + + {step.note ? ( + + {step.note} + + ) : null} + + + ); + })} + {activeMobilePlan.steps.length > 0 ? ( + + setTaskPillSubStepsOpen((current) => !current)} + > + + {t("{count} steps", { count: activeMobilePlan.steps.length })}{" "} + {taskPillSubStepsOpen ? "▴" : "▾"} + + + {taskPillSubStepsOpen + ? activeMobilePlan.steps.map((s) => ( + + {blockText(s)} + + )) + : null} + + ) : null} + + ) : null} + + ) : null} bot.id === activeSnapshot.leadBotId) - ? activeSnapshot.leadBotId - : coordinatorBot?.id) + ? activeSnapshot?.leadBotId && bots.some((bot) => bot.id === activeSnapshot.leadBotId) + ? activeSnapshot.leadBotId + : coordinatorBot?.id : undefined; // A bot message without a sender speaks for the conversation's own bot, never as an anonymous one. const defaultSpeakerId = inGroup ? activeSnapshot?.leadBotId : active?.id; @@ -4368,6 +4369,11 @@ export function ShellPage() { bootstrapMe?.needsModel ? () => openSettings("models") : undefined } onAutopilot={startAutopilot} + activePlan={extractActivePlanState( + transcriptMessages, + transcriptRunning, + workingRuns[0]?.createdAt, + )} onSlashAction={(action) => { if (action === "chat-settings") { setPanel(inGroup ? "group-settings" : "settings"); diff --git a/apps/web/src/pages/shell/composer-task-pill.test.tsx b/apps/web/src/pages/shell/composer-task-pill.test.tsx new file mode 100644 index 0000000..125d018 --- /dev/null +++ b/apps/web/src/pages/shell/composer-task-pill.test.tsx @@ -0,0 +1,224 @@ +// @vitest-environment jsdom + +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import type { ThreadMessage } from "@rakazo/contracts"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ComposerTaskPill, extractActivePlanState, PlanCard } from "./message-cards"; + +// @ts-expect-error React act environment flag +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function buildSampleMessages(): ThreadMessage[] { + return [ + { + id: "u1", + threadId: "t1", + seq: 1, + role: "user", + runId: "r1", + createdAt: "2026-10-22T12:00:00Z", + blocks: [{ kind: "text", text: "Build brand concept" }], + }, + { + id: "b-plan", + threadId: "t1", + seq: 2, + role: "bot", + botId: "bot-1", + runId: "r1", + createdAt: "2026-10-22T12:00:05Z", + blocks: [ + { + kind: "plan", + planId: "p1", + title: "Branding-Konzept finalisieren", + status: "active", + steps: [ + { id: "s1", label: "Slogans erstellen", status: "completed" }, + { + id: "s2", + label: "Brand Guide HTML-Seite erstellen", + status: "in_progress", + note: "Komplette Zusammenfassung aller Brand-Elemente", + }, + { id: "s3", label: "Alles auf Server kopieren", status: "pending" }, + ], + }, + ], + }, + { + id: "b-step-1", + threadId: "t1", + seq: 3, + role: "bot", + botId: "bot-1", + runId: "r1", + createdAt: "2026-10-22T12:00:10Z", + blocks: [{ kind: "text", text: "running: pnpm build" }], + }, + { + id: "b-step-2", + threadId: "t1", + seq: 4, + role: "bot", + botId: "bot-1", + runId: "r1", + createdAt: "2026-10-22T12:00:15Z", + blocks: [{ kind: "text", text: "writing brand-guide.html" }], + }, + { + id: "progress:r1", + threadId: "t1", + seq: 5, + role: "bot", + botId: "bot-1", + runId: "r1", + createdAt: "2026-10-22T12:00:20Z", + blocks: [{ kind: "progress", text: "Working..." }], + }, + ]; +} + +describe("ComposerTaskPill & PlanCard integration", () => { + beforeEach(() => { + i18n.load("en", {}); + i18n.activate("en"); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("extractActivePlanState collects the active plan and folded narration steps from the current turn", () => { + const messages = buildSampleMessages(); + const state = extractActivePlanState(messages, true, "2026-10-22T12:00:00Z"); + expect(state).not.toBeNull(); + expect(state?.block.planId).toBe("p1"); + expect(state?.steps.map((s) => s.id)).toEqual(["b-step-1", "b-step-2"]); + expect(state?.running).toBe(true); + }); + + it("renders compact active step + progress badge and expands to reveal full checklist & intermediate steps", async () => { + const state = extractActivePlanState(buildSampleMessages(), true, "2026-10-22T12:00:00Z")!; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const pill = container.querySelector('[data-testid="composer-task-pill"]'); + expect(pill).not.toBeNull(); + expect(pill?.getAttribute("data-expanded")).toBe("false"); + expect(container.querySelector('[data-testid="composer-task-pill-label"]')?.textContent).toBe( + "Brand Guide HTML-Seite erstellen", + ); + expect( + container.querySelector('[data-testid="composer-task-pill-progress"]')?.textContent, + ).toBe("1/3"); + + const toggle = container.querySelector( + '[data-testid="composer-task-pill-toggle"]', + ) as HTMLButtonElement; + await act(async () => { + toggle.click(); + }); + + expect(pill?.getAttribute("data-expanded")).toBe("true"); + const intermediate = container.querySelector( + '[data-testid="composer-plan-intermediate-steps"]', + ); + expect(intermediate).not.toBeNull(); + + const stepToggle = intermediate?.querySelector("button") as HTMLButtonElement; + await act(async () => { + stepToggle.click(); + }); + + expect(intermediate?.textContent).toContain("running: pnpm build"); + expect(intermediate?.textContent).toContain("writing brand-guide.html"); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it("auto-collapses and hides after all steps complete", async () => { + vi.useFakeTimers(); + const completedPlan = { + block: { + kind: "plan" as const, + planId: "p-done", + title: "Branding-Konzept finalisieren", + status: "completed" as const, + steps: [ + { id: "s1", label: "Slogans erstellen", status: "completed" as const }, + { id: "s2", label: "Brand Guide erstellen", status: "completed" as const }, + ], + }, + steps: [], + running: false, + }; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.querySelector('[data-testid="composer-task-pill"]')).not.toBeNull(); + expect( + container.querySelector('[data-testid="composer-task-pill-progress"]')?.textContent, + ).toBe("Done"); + + await act(async () => { + vi.advanceTimersByTime(3600); + }); + + expect(container.querySelector('[data-testid="composer-task-pill"]')).toBeNull(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + it("PlanCard embeds intermediate steps under the active step", async () => { + const state = extractActivePlanState(buildSampleMessages(), true, "2026-10-22T12:00:00Z")!; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + + + , + ); + }); + + const activeStepRow = container.querySelector('[data-testid="plan-step-s2"]'); + expect(activeStepRow).not.toBeNull(); + expect(activeStepRow?.querySelector('[data-testid="plan-intermediate-steps"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); +}); diff --git a/apps/web/src/pages/shell/composer.tsx b/apps/web/src/pages/shell/composer.tsx index 348bf88..b7906b2 100644 --- a/apps/web/src/pages/shell/composer.tsx +++ b/apps/web/src/pages/shell/composer.tsx @@ -40,6 +40,8 @@ import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } fr import { VoiceAuroraRim } from "../../components/voice/VoiceAurora"; import { isFileDrag, isFilePaste } from "../../lib/pending-attachments"; import { AutopilotDialog } from "./autopilot-dialog"; +import type { ComposerActivePlan } from "./message-cards"; +import { ComposerTaskPill } from "./message-cards"; import { previewMessageText } from "./message-preview"; import type { PendingAttachment } from "./shell-types"; import { FALLBACK_BOT_COLOR } from "./shell-types"; @@ -80,6 +82,7 @@ export const Composer = memo(function Composer({ onSlashAction, onConnectModel, onAutopilot, + activePlan, }: { draftKey?: string; running: boolean; @@ -112,6 +115,7 @@ export const Composer = memo(function Composer({ onConnectModel?: () => void; /** Hand the order to the autopilot; the composer menu offers it only when set. */ onAutopilot?: (goal: string, until?: string) => Promise; + activePlan?: ComposerActivePlan | null; }) { const { t } = useLingui(); const initialDraft = useMemo(() => readComposerDraft(draftKey), [draftKey]); @@ -643,6 +647,7 @@ export const Composer = memo(function Composer({ })} ) : null} + {activePlan ? : null}
["options"][number]; @@ -1018,7 +1028,150 @@ export function ExternalLinksBar({ text }: { text: string }) { ); } -export function PlanCard({ block }: { block: Extract }) { +export type PlanBlock = Extract; + +export interface ComposerActivePlan { + block: PlanBlock; + steps: readonly ThreadMessage[]; + running: boolean; + startedAt?: number; +} + +function summarizePlanSteps(steps: readonly ThreadMessage[]) { + const seen = new Set(); + const badges: { label: string; icon: React.ReactNode; count: number; type: string }[] = []; + const counts = new Map(); + + for (const step of steps) { + const meta = detectStepMeta(step); + counts.set(meta.type, (counts.get(meta.type) ?? 0) + 1); + if (meta.type !== "default" && !seen.has(meta.type)) { + seen.add(meta.type); + badges.push({ label: meta.label, icon: meta.icon, count: 1, type: meta.type }); + } + } + + for (const badge of badges) { + badge.count = counts.get(badge.type) ?? 1; + } + + let latestSnippet: string | null = null; + for (let i = steps.length - 1; i >= 0; i--) { + const text = steps[i]?.blocks + .filter((b): b is { kind: "text"; text: string } => b.kind === "text") + .map((b) => b.text) + .join(" ") + .trim(); + if (text) { + latestSnippet = text; + break; + } + } + + return { badges, latestSnippet }; +} + +function useLiveTimer(startedAtMs?: number, active = false): string { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!active || !startedAtMs) return; + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [active, startedAtMs]); + + if (!startedAtMs) return ""; + const sec = Math.max(0, Math.floor((now - startedAtMs) / 1000)); + const m = Math.floor(sec / 60); + const s = sec % 60; + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function PlanIntermediateSteps({ + steps, + testId = "plan-intermediate-steps", +}: { + steps: readonly ThreadMessage[]; + testId?: string; +}) { + const [open, setOpen] = useState(false); + const { badges } = useMemo(() => summarizePlanSteps(steps), [steps]); + const stepCount = steps.length; + + if (stepCount === 0) return null; + + return ( +
+ + {open ? ( +
    + {steps.map((step, index) => { + const meta = detectStepMeta(step); + return ( +
  1. + + {String(index + 1).padStart(2, "0")} + + + {meta.icon} + +
    + {step.blocks.map((b, idx) => + b.kind === "text" ? ( +
    + {b.text} +
    + ) : null, + )} +
    +
  2. + ); + })} +
+ ) : null} +
+ ); +} + +export function PlanCard({ + block, + steps = [], +}: { + block: PlanBlock; + steps?: readonly ThreadMessage[]; +}) { const { t } = useLingui(); const total = block.steps.length; const doneCount = block.steps.filter( @@ -1026,6 +1179,10 @@ export function PlanCard({ block }: { block: Extract s.status === "in_progress")?.id ?? + block.steps.find((s) => s.status === "pending")?.id ?? + block.steps.at(-1)?.id; return (
0 && step.id === activeStepId; return (
  • {done ? ( @@ -1092,6 +1255,7 @@ export function PlanCard({ block }: { block: Extract ) : null} + {attachSteps ? : null}
  • ); @@ -1100,3 +1264,369 @@ export function PlanCard({ block }: { block: Extract ); } + +/** + * Extracts the active or latest turn's plan and its intermediate narration steps so the + * floating composer pill and inline PlanCard share one source of truth without duplicate cards. + */ +export function extractActivePlanState( + messages: readonly ThreadMessage[], + running: boolean, + runStartedAt?: string, +): ComposerActivePlan | null { + if (messages.length === 0) return null; + const coalesced = coalesceRunAttachmentMessages(messages); + const narration = foldRunNarration(coalesced); + + let lastUserIdx = -1; + for (let i = coalesced.length - 1; i >= 0; i--) { + if (coalesced[i]?.role === "user") { + lastUserIdx = i; + break; + } + } + + let planMessageIdx = -1; + let latestPlan: PlanBlock | null = null; + + for (let i = coalesced.length - 1; i > lastUserIdx; i--) { + const msg = coalesced[i]; + if (!msg) continue; + for (let b = msg.blocks.length - 1; b >= 0; b--) { + const block = msg.blocks[b]; + if (block?.kind === "plan") { + latestPlan = block; + planMessageIdx = i; + break; + } + } + if (latestPlan) break; + } + + // Also check earlier messages if an uncompleted plan is still active across a user follow-up. + if (!latestPlan) { + for (let i = lastUserIdx - 1; i >= 0; i--) { + const msg = coalesced[i]; + if (!msg) continue; + for (let b = msg.blocks.length - 1; b >= 0; b--) { + const block = msg.blocks[b]; + if (block?.kind === "plan" && block.status !== "completed") { + latestPlan = block; + planMessageIdx = i; + break; + } + } + if (latestPlan) break; + } + } + + if (!latestPlan) return null; + + const stepIds = new Set(); + const collectedSteps: ThreadMessage[] = []; + const startIdx = Math.max(0, lastUserIdx + 1, planMessageIdx); + + for (let i = startIdx; i < coalesced.length; i++) { + const msg = coalesced[i]; + if (!msg) continue; + const folded = narration.steps.get(msg.id); + if (folded) { + for (const step of folded) { + if (!stepIds.has(step.id)) { + stepIds.add(step.id); + collectedSteps.push(step); + } + } + } + } + + let startedAt: number | undefined; + if (runStartedAt) { + const parsed = Date.parse(runStartedAt); + if (!Number.isNaN(parsed)) startedAt = parsed; + } + if (!startedAt) { + const firstCandidate = collectedSteps[0] ?? coalesced[planMessageIdx]; + if (firstCandidate?.createdAt) { + const parsed = Date.parse(firstCandidate.createdAt); + if (!Number.isNaN(parsed)) startedAt = parsed; + } + } + + return { + block: latestPlan, + steps: collectedSteps, + running, + startedAt, + }; +} + +/** + * Floating task pill (Variant B) docked right above the composer input bar. + * Shows the current active step + live intermediate step summary, and expands upward on tap or swipe-up. + */ +export function ComposerTaskPill({ activePlan }: { activePlan: ComposerActivePlan }) { + const { t } = useLingui(); + const drawerId = useId(); + const { block, steps, running, startedAt } = activePlan; + const total = block.steps.length; + const doneCount = block.steps.filter( + (s) => s.status === "completed" || s.status === "skipped", + ).length; + const isCompleted = + block.status === "completed" || (total > 0 && doneCount === total && !running); + const isAwaiting = block.status === "awaiting_approval"; + + const [expanded, setExpanded] = useState(false); + const [drawerHidden, setDrawerHidden] = useState(true); + const [dismissedPlanKey, setDismissedPlanKey] = useState(null); + const dragStartYRef = useRef(null); + const didSwipeRef = useRef(false); + + const planCompletionKey = `${block.planId}:${isCompleted ? "done" : "active"}`; + + useEffect(() => { + if (!isCompleted) { + setDismissedPlanKey(null); + return; + } + const collapseTimer = window.setTimeout(() => { + setExpanded(false); + }, 1800); + const hideTimer = window.setTimeout(() => { + setDismissedPlanKey(planCompletionKey); + }, 3500); + return () => { + window.clearTimeout(collapseTimer); + window.clearTimeout(hideTimer); + }; + }, [isCompleted, planCompletionKey]); + + useEffect(() => { + if (expanded) { + setDrawerHidden(false); + } + }, [expanded]); + + const activeStep = + block.steps.find((s) => s.status === "in_progress") ?? + block.steps.find((s) => s.status === "pending") ?? + block.steps.at(-1); + + const liveTimer = useLiveTimer(startedAt, running && !isCompleted); + const { badges, latestSnippet } = useMemo(() => summarizePlanSteps(steps), [steps]); + + if (isCompleted && dismissedPlanKey === planCompletionKey) { + return null; + } + + const headline = isCompleted ? block.title : (activeStep?.label ?? block.title); + const sublineText = !isCompleted ? (latestSnippet ?? activeStep?.note ?? null) : null; + const showSubline = Boolean(!isCompleted && (liveTimer || badges.length > 0 || sublineText)); + + return ( +
    + + + +
    + ); +} diff --git a/apps/web/src/pages/shell/transcript.tsx b/apps/web/src/pages/shell/transcript.tsx index 44e2791..3effbe4 100644 --- a/apps/web/src/pages/shell/transcript.tsx +++ b/apps/web/src/pages/shell/transcript.tsx @@ -260,6 +260,42 @@ export const Transcript = memo(function Transcript({ [reactionView.visibleMessages, reactionView.reactions], ); const runNarration = useMemo(() => foldRunNarration(coalescedMessages), [coalescedMessages]); + const planStepRouting = useMemo(() => { + const absorbed = new Set(); + const stepsByPlanMsgId = new Map(); + let activePlanMsgId: string | null = null; + for (const msg of coalescedMessages) { + if (msg.role === "user") { + const prevPlanMsg = activePlanMsgId + ? coalescedMessages.find((m) => m.id === activePlanMsgId) + : undefined; + const hasIncompletePlan = prevPlanMsg?.blocks.some( + (b) => b.kind === "plan" && b.status !== "completed", + ); + if (!hasIncompletePlan) { + activePlanMsgId = null; + } + continue; + } + if (msg.blocks.some((b) => b.kind === "plan")) { + activePlanMsgId = msg.id; + } + if (activePlanMsgId) { + const folded = runNarration.steps.get(msg.id); + if (folded && folded.length > 0) { + absorbed.add(msg.id); + const existing = stepsByPlanMsgId.get(activePlanMsgId) ?? []; + for (const step of folded) { + if (!existing.some((s) => s.id === step.id)) { + existing.push(step); + } + } + stepsByPlanMsgId.set(activePlanMsgId, existing); + } + } + } + return { absorbed, stepsByPlanMsgId }; + }, [coalescedMessages, runNarration.steps]); const trailingAnsweringBots = useMemo(() => { const liveProgressMessages = coalescedMessages.filter( (message) => !runNarration.hidden.has(message.id) && message.id.startsWith("progress:"), @@ -636,9 +672,12 @@ export const Transcript = memo(function Transcript({ {coalescedMessages.map((message) => { if (runNarration.hidden.has(message.id)) return null; const foldedSteps = runNarration.steps.get(message.id); + const showRunSteps = + Boolean(foldedSteps?.length) && !planStepRouting.absorbed.has(message.id); + const planSteps = planStepRouting.stepsByPlanMsgId.get(message.id); if ( !message.id.startsWith("progress:") && - !foldedSteps?.length && + !showRunSteps && !message.blocks.some((block) => !isToolActivityBlock(block)) ) return null; @@ -666,7 +705,7 @@ export const Transcript = memo(function Transcript({ data-quotable={message.id.includes(":") ? undefined : ""} className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"} > - {foldedSteps ? ( + {showRunSteps && foldedSteps ? ( Promise; onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise; onOpenBot: (botId: string) => void; @@ -2129,7 +2171,7 @@ const MessageView = memo(function MessageView({ if (block.kind === "plan") { return (
    - +
    ); } diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index b01209c..6fe5457 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -565,6 +565,24 @@ body, animation: ooHoleHopOut 0.76s cubic-bezier(0.22, 1, 0.36, 1) both; } +:root { + interpolate-size: allow-keywords; +} + +.task-pill-drawer { + block-size: 0; + opacity: 0; + overflow: hidden; + transition: + block-size 0.28s cubic-bezier(0.22, 1, 0.36, 1), + opacity 0.2s ease; +} + +.task-pill-drawer.is-expanded { + block-size: auto; + opacity: 1; +} + @media (prefers-reduced-motion: reduce) { .voice-bloom-emerald, .oo-hole-pit, @@ -572,4 +590,8 @@ body, .oo-hole-jumper { animation: none !important; } + + .task-pill-drawer { + transition: none; + } }